1948 lines
65 KiB
JavaScript
1948 lines
65 KiB
JavaScript
/**
|
|
* UI renders the chat and owns every DOM interaction: messages,
|
|
* streaming drafts, the compose area, the toolbar, and the modals.
|
|
* It reports user actions to main.js through the handlers given at
|
|
* construction, and main.js calls back with data to render.
|
|
*
|
|
* Division of labor:
|
|
* - UI decides how things look and when a click counts. Destructive
|
|
* actions (send-as-stop, reset, delete) need a second press.
|
|
* - Main decides what the clicks mean: it sends the requests and
|
|
* changes the state, then calls UI with the result.
|
|
* - UI never sends requests and never reads or writes localStorage.
|
|
* It keeps presentation state only: scroll-follow, open and
|
|
* closed sections, recording indicators, and the set of tool
|
|
* calls that wait for an approval.
|
|
*
|
|
* Saved messages are rendered from row objects: { id, role,
|
|
* sequence, updated_at, content, metadata, voice }. content is the
|
|
* model-visible message JSON, the same object the server stores.
|
|
* Drafts are rendered from the unfinished output that main.js
|
|
* accumulates; drafts have no row and are never cached.
|
|
*/
|
|
|
|
const ICONS_URL = '/static/icons.svg';
|
|
|
|
/**
|
|
* extractText concatenates the text of a message content field.
|
|
* The field is either a plain string or an array of content parts;
|
|
* only text and refusal parts carry text.
|
|
* @param {string | Array<Object>} content
|
|
* @returns {string}
|
|
*/
|
|
export function extractText(content) {
|
|
if (typeof content === 'string') return content;
|
|
if (!Array.isArray(content)) return '';
|
|
return content
|
|
.filter((part) => part.type === 'text' || part.type === 'refusal')
|
|
.map((part) => part.text || part.refusal || '')
|
|
.join('\n');
|
|
}
|
|
|
|
// CONVERSATION_DATE formats conversation timestamps in the browser
|
|
// locale. Until the server sets a title from the first user message, an
|
|
// untitled conversation uses its formatted creation date as its label.
|
|
const CONVERSATION_DATE = new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
});
|
|
|
|
/**
|
|
* conversationLabel returns the text that names a conversation in
|
|
* the picker: its title, or its creation date when it has none.
|
|
* @param {{title: string | null, created_at: string}} conversation
|
|
* @returns {string}
|
|
*/
|
|
export function conversationLabel(conversation) {
|
|
return (
|
|
conversation.title ??
|
|
CONVERSATION_DATE.format(new Date(conversation.created_at))
|
|
);
|
|
}
|
|
|
|
export class UI {
|
|
/**
|
|
* @param {Object} options
|
|
* @param {Document} options.document
|
|
* @param {Object} options.handlers - callbacks main.js provides.
|
|
*/
|
|
constructor({ document, handlers }) {
|
|
this.document = document;
|
|
this.handlers = handlers;
|
|
|
|
// Presentation state.
|
|
this.isAutoScrollEnabled = false;
|
|
this.modelContextSizes = new Map();
|
|
this.pendingApprovals = [];
|
|
this.$pendingConfirm = null;
|
|
|
|
// Chat and compose.
|
|
this.$chat = document.getElementById('chat');
|
|
this.$chatLoading = document.getElementById('chat-loading');
|
|
this.$attachments = document.getElementById('attachments');
|
|
this.$compose = document.querySelector('.compose');
|
|
this.$textInput = document.getElementById('text-input');
|
|
this.$fileInput = document.getElementById('file-input');
|
|
this.$attach = document.getElementById('attach');
|
|
this.$location = document.getElementById('location');
|
|
this.$ptt = document.getElementById('ptt');
|
|
this.$transcribe = document.getElementById('transcribe');
|
|
this.$send = document.getElementById('send');
|
|
// Last enabled state of the send button. setBusy restores it
|
|
// when a run ends.
|
|
this.sendEnabled = false;
|
|
|
|
// Toolbar.
|
|
this.$context = document.getElementById('context');
|
|
this.$reset = document.getElementById('reset');
|
|
this.$scrollDown = document.getElementById('scroll-down');
|
|
this.$mute = document.getElementById('mute');
|
|
this.$settings = document.getElementById('settings');
|
|
|
|
// Settings modal.
|
|
this.$settingsOverlay = document.getElementById('settings-overlay');
|
|
this.$settingsClose = document.getElementById('settings-close');
|
|
this.$settingsTabs = document.querySelectorAll('.settings-tab');
|
|
this.$settingsPanels = document.querySelectorAll('.settings-tab-panel');
|
|
this.$model = document.getElementById('model');
|
|
this.$voice = document.getElementById('voice');
|
|
this.$systemMessageInput = document.getElementById('system-message-input');
|
|
this.$saveSystemMessage = document.getElementById('save-system-message');
|
|
|
|
// Conversations modal.
|
|
this.$conversations = document.getElementById('conversations');
|
|
this.$conversationsOverlay = document.getElementById(
|
|
'conversations-overlay',
|
|
);
|
|
this.$conversationsClose = document.getElementById('conversations-close');
|
|
this.$conversationsSearch = document.getElementById('conversation-search');
|
|
this.$conversationNew = document.getElementById('conversation-new');
|
|
this.$conversationsList = document.getElementById('conversation-list');
|
|
this.$tplConversationItem = document.getElementById(
|
|
'tpl-conversation-item',
|
|
);
|
|
this.$tplRenameModal = document.getElementById('tpl-rename-modal');
|
|
|
|
// Compose settings modal.
|
|
this.$composeSettings = document.getElementById('compose-settings');
|
|
this.$composeSettingsOverlay = document.getElementById(
|
|
'compose-settings-overlay',
|
|
);
|
|
this.$composeSettingsClose = document.getElementById(
|
|
'compose-settings-close',
|
|
);
|
|
this.$maxTurnsInput = document.getElementById('max-turns-input');
|
|
this.$reasoningBudgetInput = document.getElementById(
|
|
'reasoning-budget-input',
|
|
);
|
|
|
|
// Message templates.
|
|
this.$tplUserMessage = document.getElementById('tpl-user-message');
|
|
this.$tplAssistantMessage = document.getElementById(
|
|
'tpl-assistant-message',
|
|
);
|
|
this.$tplReasoningMessage = document.getElementById(
|
|
'tpl-reasoning-message',
|
|
);
|
|
this.$tplToolMessage = document.getElementById('tpl-tool-message');
|
|
this.$tplSystemMessage = document.getElementById('tpl-system-message');
|
|
this.$tplErrorMessage = document.getElementById('tpl-error-message');
|
|
this.$tplAttachmentChip = document.getElementById('tpl-attachment-chip');
|
|
this.$tplImagePill = document.getElementById('tpl-image-pill');
|
|
this.$tplDebugRow = document.getElementById('tpl-debug-row');
|
|
this.$tplEditModal = document.getElementById('tpl-edit-modal');
|
|
this.$tplEditToolModal = document.getElementById('tpl-edit-tool-modal');
|
|
|
|
this._bindEvents();
|
|
this._initViewport();
|
|
}
|
|
|
|
// ====================
|
|
// COMPOSE AREA
|
|
// ====================
|
|
|
|
/**
|
|
* getComposeText reads the compose text without changing it.
|
|
* @returns {string}
|
|
*/
|
|
getComposeText() {
|
|
return this.$textInput.value;
|
|
}
|
|
|
|
/**
|
|
* takeComposeText reads the compose text and clears the field.
|
|
* Call it only when the text is accepted for sending; main.js
|
|
* restores it with appendComposeText when a request fails.
|
|
* @returns {string}
|
|
*/
|
|
takeComposeText() {
|
|
const text = this.$textInput.value;
|
|
this.$textInput.value = '';
|
|
this._resizeTextarea();
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* appendComposeText puts text back into the compose field after a
|
|
* failed send, keeping any text typed in the meantime.
|
|
* @param {string} text
|
|
*/
|
|
appendComposeText(text) {
|
|
if (!text) return;
|
|
const existing = this.$textInput.value;
|
|
this.$textInput.value = existing ? `${existing}\n${text}` : text;
|
|
this._resizeTextarea();
|
|
}
|
|
|
|
/** focusCompose moves the caret into the compose field. */
|
|
focusCompose() {
|
|
this.$textInput.focus();
|
|
}
|
|
|
|
/**
|
|
* setBusy reflects whether work is running. While busy, the send
|
|
* button acts as a stop control and the input controls are
|
|
* disabled; the compose text stays editable so nothing typed is
|
|
* lost.
|
|
* @param {boolean} busy
|
|
*/
|
|
setBusy(busy) {
|
|
this.$send.classList.toggle('loading', busy);
|
|
if (busy) {
|
|
this.$send.setAttribute('aria-label', 'Stop');
|
|
// While busy the send button is the stop control, so it must
|
|
// stay clickable whatever the last enabled state was.
|
|
this.$send.disabled = false;
|
|
} else {
|
|
// Restore the enabled state recorded before the run. A caller
|
|
// cannot leave the button disabled by forgetting to call
|
|
// updateSendEnabled after this.
|
|
this.setSendEnabled(this.sendEnabled);
|
|
}
|
|
this.$chatLoading.hidden = !busy;
|
|
this.$ptt.disabled = busy;
|
|
this.$transcribe.disabled = busy;
|
|
this.$attach.disabled = busy;
|
|
this.$location.disabled = busy;
|
|
}
|
|
|
|
/**
|
|
* setSendEnabled enables the send button. The button stays
|
|
* clickable while busy, because it is the stop control then.
|
|
* @param {boolean} enabled
|
|
*/
|
|
setSendEnabled(enabled) {
|
|
// Record the state even while busy, so that setBusy can
|
|
// restore it when the run ends.
|
|
this.sendEnabled = enabled;
|
|
if (this.$send.classList.contains('loading')) return;
|
|
this.$send.disabled = !enabled;
|
|
this.$send.setAttribute(
|
|
'aria-label',
|
|
enabled ? 'Send message' : 'Start a conversation to enable send',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* setRecording reflects the push-to-talk state.
|
|
* @param {boolean} recording
|
|
*/
|
|
setRecording(recording) {
|
|
this.$ptt.classList.toggle('recording', recording);
|
|
this.$ptt.setAttribute('aria-pressed', String(recording));
|
|
}
|
|
|
|
/**
|
|
* setTranscribing reflects the record-to-text state.
|
|
* @param {boolean} transcribing
|
|
*/
|
|
setTranscribing(transcribing) {
|
|
this.$transcribe.classList.toggle(
|
|
'compose__action-btn--recording',
|
|
transcribing,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* renderAttachments shows one chip per attached file. onRemove is
|
|
* called with the index of the chip the user removed.
|
|
* @param {File[]} files
|
|
* @param {(index: number) => void} onRemove
|
|
*/
|
|
renderAttachments(files, onRemove) {
|
|
this.$attachments.replaceChildren();
|
|
for (const [index, file] of files.entries()) {
|
|
const $chip =
|
|
this.$tplAttachmentChip.content.cloneNode(true).firstElementChild;
|
|
const $name = $chip.querySelector('.compose__attachment-name');
|
|
$name.textContent = file.name;
|
|
$name.title = file.name;
|
|
$chip
|
|
.querySelector('.compose__attachment-remove')
|
|
.addEventListener('click', () => onRemove(index));
|
|
this.$attachments.appendChild($chip);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* setLocationActive reflects whether the next message will include
|
|
* the GPS location.
|
|
* @param {boolean} active
|
|
*/
|
|
setLocationActive(active) {
|
|
this.$location.classList.toggle('compose__action-btn--location', active);
|
|
this.$location.classList.toggle('active', active);
|
|
}
|
|
|
|
// ====================
|
|
// MESSAGES
|
|
// ====================
|
|
|
|
/**
|
|
* renderMessages renders saved message rows, in the order given,
|
|
* into the chat. Bubbles that already exist are skipped, so
|
|
* calling it again with the same rows is safe.
|
|
*
|
|
* A tool result fills the bubble of the call it answers, wherever
|
|
* that bubble is. A tool result whose call has no bubble renders
|
|
* as a standalone bubble with its output only.
|
|
*
|
|
* @param {Array<Object>} rows - saved message rows, in order.
|
|
*/
|
|
renderMessages(rows) {
|
|
// Map each tool call ID in rows to its result row.
|
|
const callIds = new Set();
|
|
for (const row of rows) {
|
|
for (const call of row.content?.tool_calls ?? []) {
|
|
callIds.add(call.id);
|
|
}
|
|
}
|
|
const results = new Map();
|
|
for (const row of rows) {
|
|
const callId = row.content?.tool_call_id;
|
|
if (row.role === 'tool' && callIds.has(callId)) {
|
|
results.set(callId, row);
|
|
}
|
|
}
|
|
|
|
for (const row of rows) {
|
|
switch (row.role) {
|
|
case 'system':
|
|
if (!this._hasBubble(row.id)) this._renderSystemMessage(row);
|
|
break;
|
|
case 'user':
|
|
if (!this._hasBubble(row.id)) this._renderUserMessage(row);
|
|
break;
|
|
case 'assistant':
|
|
this._renderAssistantMessage(row, results);
|
|
break;
|
|
case 'tool': {
|
|
const $bubble = this.$chat.querySelector(
|
|
`[data-tool-call-id="${CSS.escape(row.content?.tool_call_id || '')}"]`,
|
|
);
|
|
if ($bubble) {
|
|
this._fillToolOutput($bubble, row);
|
|
break;
|
|
}
|
|
if (this._hasBubble(row.id)) break;
|
|
this._renderOrphanToolResult(row);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* _hasBubble reports whether a bubble with the given data-id is
|
|
* rendered.
|
|
* @param {string} id
|
|
*/
|
|
_hasBubble(id) {
|
|
return this.$chat.querySelector(`[data-id="${CSS.escape(id)}"]`) !== null;
|
|
}
|
|
|
|
/**
|
|
* attachToolResult fills the output section of the tool bubble
|
|
* for one call. The bubble exists once its assistant message is
|
|
* rendered. When it does not, one is created: from the parent
|
|
* row's call when the call is found, or from the result row alone
|
|
* when the assistant message is unknown or no longer has the
|
|
* call.
|
|
*
|
|
* @param {Object} parentRow - the assistant message row that owns
|
|
* the call, or null.
|
|
* @param {string} callId
|
|
* @param {Object} resultRow - the saved tool result row.
|
|
*/
|
|
attachToolResult(parentRow, callId, resultRow) {
|
|
const $bubble =
|
|
this._findToolBubble(parentRow?.id, callId) ??
|
|
this.$chat.querySelector(`[data-tool-call-id="${CSS.escape(callId)}"]`);
|
|
if ($bubble) {
|
|
this._fillToolOutput($bubble, resultRow);
|
|
return;
|
|
}
|
|
const call =
|
|
parentRow?.content?.tool_calls?.find((c) => c.id === callId) ?? null;
|
|
this._renderToolBubble(call ? parentRow : null, call, resultRow);
|
|
}
|
|
|
|
/**
|
|
* removeMessageBubbles removes the bubbles of one saved message
|
|
* row: the text bubble, the reasoning bubble, and the tool
|
|
* bubbles. It does not remove the rows of other messages, such as
|
|
* tool results that belong to a deleted assistant message.
|
|
* @param {string} id - message row ID.
|
|
*/
|
|
removeMessageBubbles(id) {
|
|
this.$chat
|
|
.querySelectorAll(
|
|
`[data-id="${CSS.escape(id)}"],` +
|
|
` [data-id="${CSS.escape(`${id}/reasoning`)}"],` +
|
|
` [data-parent-id="${CSS.escape(id)}"]`,
|
|
)
|
|
.forEach(($bubble) => {
|
|
$bubble.remove();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* clearToolOutput empties the output section of the bubble that
|
|
* shows one tool result, after the result row was deleted. The
|
|
* call itself stays: it can run again.
|
|
* @param {Object} resultRow - the deleted tool result row.
|
|
*/
|
|
clearToolOutput(resultRow) {
|
|
const id = CSS.escape(resultRow.id);
|
|
const $bubble = this.$chat.querySelector(
|
|
`[data-parent-id][data-id="${id}"]`,
|
|
);
|
|
if (!$bubble) return;
|
|
$bubble.querySelector('[data-output]').textContent = '';
|
|
$bubble
|
|
.querySelector('.message__tool-section--output')
|
|
.setAttribute('hidden', '');
|
|
$bubble.dataset.id = `${$bubble.dataset.parentId}/${$bubble.dataset.toolCallId}`;
|
|
}
|
|
|
|
/**
|
|
* replaceDraftWithSaved swaps the streaming bubbles of one draft
|
|
* for the bubbles of its saved message row, keeping each bubble's
|
|
* open or closed state. resultRows are the tool results saved for
|
|
* the same message, when known.
|
|
* @param {string} draftId - the draft ID, which equals the saved
|
|
* row ID: the server allocates it before the first fragment.
|
|
* @param {Object} row - the saved assistant message row.
|
|
* @param {Object[]} resultRows
|
|
*/
|
|
replaceDraftWithSaved(draftId, row, resultRows = []) {
|
|
const openOf = (selector) => {
|
|
const $bubble = this.$chat.querySelector(selector);
|
|
return $bubble?.querySelector('details')?.open ?? null;
|
|
};
|
|
const textOpen = openOf(`[data-id="${CSS.escape(draftId)}"]`);
|
|
const reasoningOpen = openOf(
|
|
`[data-id="${CSS.escape(`${draftId}/reasoning`)}"]`,
|
|
);
|
|
const toolOpen = new Map();
|
|
for (const $bubble of this.$chat.querySelectorAll(
|
|
`[data-parent-id="${CSS.escape(draftId)}"][data-tool-call-id]`,
|
|
)) {
|
|
toolOpen.set(
|
|
$bubble.dataset.toolCallId,
|
|
$bubble.querySelector('details')?.open,
|
|
);
|
|
}
|
|
|
|
this.removeDraft(draftId);
|
|
this.renderMessages([row, ...resultRows]);
|
|
|
|
const $text = this.$chat.querySelector(`[data-id="${CSS.escape(row.id)}"]`);
|
|
if ($text && textOpen !== null) {
|
|
$text.querySelector('details').open = textOpen;
|
|
}
|
|
const $reasoning = this.$chat.querySelector(
|
|
`[data-id="${CSS.escape(`${row.id}/reasoning`)}"]`,
|
|
);
|
|
if ($reasoning && reasoningOpen !== null) {
|
|
$reasoning.querySelector('details').open = reasoningOpen;
|
|
}
|
|
for (const call of row.content?.tool_calls ?? []) {
|
|
const $bubble = this._findToolBubble(row.id, call.id);
|
|
if ($bubble && toolOpen.has(call.id)) {
|
|
$bubble.querySelector('details').open = toolOpen.get(call.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* renderDraft renders the unfinished output of one streaming
|
|
* assistant message. Repeated calls update the bubbles in place.
|
|
* Draft bubbles have no message actions: their content is not
|
|
* saved and may never be.
|
|
* @param {Object} draft - { id, reasoning, text, refusal,
|
|
* toolCalls: [{ id, function: { name, arguments } }] }.
|
|
*/
|
|
renderDraft(draft) {
|
|
if (draft.reasoning) {
|
|
let $bubble = this.$chat.querySelector(
|
|
`[data-id="${CSS.escape(`${draft.id}/reasoning`)}"]`,
|
|
);
|
|
if (!$bubble) $bubble = this._createDraftReasoning(draft.id);
|
|
$bubble.querySelector('.message__content').textContent = draft.reasoning;
|
|
}
|
|
const text = draft.refusal || draft.text;
|
|
if (text) {
|
|
let $bubble = this.$chat.querySelector(
|
|
`[data-id="${CSS.escape(draft.id)}"]`,
|
|
);
|
|
if (!$bubble) $bubble = this._createDraftText(draft.id);
|
|
$bubble.querySelector('.message__content').textContent = text;
|
|
}
|
|
for (const [index, call] of draft.toolCalls.entries()) {
|
|
if (!call) continue;
|
|
const callId = call.id || `#${index}`;
|
|
let $bubble = this._findToolBubble(draft.id, callId);
|
|
if (!$bubble) $bubble = this._createDraftToolBubble(draft.id, callId);
|
|
$bubble.querySelector('.message__collapsible-label').textContent =
|
|
call.function.name || 'Tool call';
|
|
$bubble.querySelector('[data-args]').textContent =
|
|
call.function.arguments;
|
|
}
|
|
this.scrollToBottom();
|
|
}
|
|
|
|
/**
|
|
* removeDraft removes the streaming bubbles of one draft. Call it
|
|
* when a draft is replaced by its saved message, or when its
|
|
* stream is lost: an unfinished draft is not a saved message, and
|
|
* a re-sync either delivers the saved message or proves that none
|
|
* was saved.
|
|
* @param {string} draftId
|
|
*/
|
|
removeDraft(draftId) {
|
|
this.removeMessageBubbles(draftId);
|
|
}
|
|
|
|
/**
|
|
* renderSystemDefault renders the system message bubble shown
|
|
* when no conversation is selected. It shows the default system
|
|
* message for the next conversation.
|
|
* @param {string} text
|
|
*/
|
|
renderSystemDefault(text) {
|
|
this.$chat.querySelector('[data-id="system"]')?.remove();
|
|
const $bubble =
|
|
this.$tplSystemMessage.content.cloneNode(true).firstElementChild;
|
|
$bubble.dataset.id = 'system';
|
|
const $content = $bubble.querySelector('.message__content');
|
|
if (text?.trim()) {
|
|
$content.textContent = text;
|
|
$bubble.dataset.copyText = text;
|
|
} else {
|
|
$content.innerHTML =
|
|
'<em class="message__placeholder">System Default</em>';
|
|
}
|
|
this.$chat.insertBefore($bubble, this.$chatLoading);
|
|
}
|
|
|
|
/**
|
|
* renderError renders one error message in the chat. Errors are
|
|
* display-only: the delete button removes the bubble without a
|
|
* request.
|
|
* @param {string} message
|
|
*/
|
|
renderError(message) {
|
|
const $bubble =
|
|
this.$tplErrorMessage.content.cloneNode(true).firstElementChild;
|
|
$bubble.querySelector('.message__content').textContent = message;
|
|
$bubble.dataset.copyText = message;
|
|
this.$chat.insertBefore($bubble, this.$chatLoading);
|
|
this.scrollToBottom();
|
|
}
|
|
|
|
/**
|
|
* clearChat removes every message bubble. The loading spinner
|
|
* stays.
|
|
*/
|
|
clearChat() {
|
|
for (const $bubble of [...this.$chat.children]) {
|
|
if ($bubble !== this.$chatLoading) $bubble.remove();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* renderConversation replaces the whole chat with rows, keeping
|
|
* each bubble's open or closed state and the scroll position.
|
|
* @param {Array<Object>} rows
|
|
*/
|
|
renderConversation(rows) {
|
|
const open = new Map();
|
|
for (const $bubble of this.$chat.querySelectorAll('.message')) {
|
|
const details = $bubble.querySelector('details');
|
|
if (details) open.set($bubble.dataset.id, details.open);
|
|
}
|
|
const { scrollTop } = this.$chat;
|
|
this.clearChat();
|
|
this.renderMessages(rows);
|
|
for (const [id, isOpen] of open) {
|
|
const $bubble = this.$chat.querySelector(`[data-id="${CSS.escape(id)}"]`);
|
|
if ($bubble) $bubble.querySelector('details').open = isOpen;
|
|
}
|
|
this.$chat.scrollTop = scrollTop;
|
|
this.scrollToBottom();
|
|
}
|
|
|
|
// ====================
|
|
// TOOL APPROVALS
|
|
// ====================
|
|
|
|
/**
|
|
* setPendingApprovals shows the approve and deny buttons on the
|
|
* tool bubbles named by the given executions, and hides them on
|
|
* every other bubble. The set is remembered, so bubbles rendered
|
|
* later show their buttons too.
|
|
* @param {Array<{executionId: string, messageId: string, toolCallId: string}>} pending
|
|
*/
|
|
setPendingApprovals(pending) {
|
|
this.pendingApprovals = pending;
|
|
for (const $bubble of this.$chat.querySelectorAll(
|
|
'[data-parent-id][data-tool-call-id]',
|
|
)) {
|
|
const $approval = $bubble.querySelector('.message__approval');
|
|
if (!$approval) continue;
|
|
const isPending = pending.some(
|
|
({ messageId, toolCallId }) =>
|
|
messageId === $bubble.dataset.parentId &&
|
|
toolCallId === $bubble.dataset.toolCallId,
|
|
);
|
|
$approval.hidden = !isPending;
|
|
}
|
|
}
|
|
|
|
// ====================
|
|
// MESSAGE DETAILS
|
|
// ====================
|
|
|
|
/**
|
|
* setPlayButton switches the play button of one message between
|
|
* its play and stop icon, for speech that starts or ends.
|
|
* @param {string} messageId
|
|
* @param {boolean} playing
|
|
*/
|
|
setPlayButton(messageId, playing) {
|
|
const $bubble = this.$chat.querySelector(
|
|
`[data-id="${CSS.escape(messageId)}"]`,
|
|
);
|
|
const $btn = $bubble?.querySelector('[data-action="play"]');
|
|
if (!$btn) return;
|
|
$btn.replaceChildren(this._icon(playing ? 'stop' : 'volume'));
|
|
$btn.classList.toggle('playing', playing);
|
|
$btn.setAttribute('aria-label', playing ? 'Stop' : 'Play');
|
|
}
|
|
|
|
/**
|
|
* updateContext shows the token counter in the toolbar. When the
|
|
* selected model reports its context size, the counter is the
|
|
* used percentage; otherwise it is the token count.
|
|
* @param {number} tokens - total tokens of the last inference.
|
|
* @param {number | null} contextSize
|
|
*/
|
|
updateContext(tokens, contextSize) {
|
|
if (tokens <= 0) {
|
|
this.$context.hidden = true;
|
|
return;
|
|
}
|
|
this.$context.hidden = false;
|
|
this.$context.classList.remove(
|
|
'footer__toolbar-context--warning',
|
|
'footer__toolbar-context--critical',
|
|
);
|
|
const tokenLabel =
|
|
tokens >= 1000
|
|
? `${(tokens / 1000).toFixed(1)}k tokens`
|
|
: `${tokens} tokens`;
|
|
if (contextSize > 0) {
|
|
const pct = Math.round((tokens / contextSize) * 100);
|
|
this.$context.textContent = `${pct}%`;
|
|
this.$context.title = tokenLabel;
|
|
if (pct >= 90) {
|
|
this.$context.classList.add('footer__toolbar-context--critical');
|
|
} else if (pct >= 70) {
|
|
this.$context.classList.add('footer__toolbar-context--warning');
|
|
}
|
|
} else {
|
|
this.$context.textContent = tokenLabel;
|
|
this.$context.title = '';
|
|
}
|
|
}
|
|
|
|
// ====================
|
|
// SETTINGS MODAL
|
|
// ====================
|
|
|
|
/**
|
|
* populateModels fills the model selection. stored selects the
|
|
* model chosen earlier; without it the server default is used,
|
|
* then the first available model.
|
|
* @param {Object} data - /models response.
|
|
* @param {{provider: string | null, model: string | null}} stored
|
|
*/
|
|
populateModels(data, stored) {
|
|
this.$model.replaceChildren();
|
|
this.modelContextSizes.clear();
|
|
|
|
for (const provider of Object.keys(data.providers).sort()) {
|
|
const $group = this.document.createElement('optgroup');
|
|
$group.label = provider;
|
|
for (const model of [...data.providers[provider]].sort((a, b) =>
|
|
a.model.localeCompare(b.model),
|
|
)) {
|
|
const $option = this.document.createElement('option');
|
|
$option.value = model.model;
|
|
$option.dataset.provider = provider;
|
|
$option.dataset.model = model.model;
|
|
if (model.context_size) {
|
|
this.modelContextSizes.set(model.model, model.context_size);
|
|
}
|
|
switch (model.status) {
|
|
case 'available':
|
|
$option.textContent = model.model;
|
|
break;
|
|
case 'not_found':
|
|
$option.textContent = `~~${model.model}~~ (not found)`;
|
|
$option.disabled = true;
|
|
$option.classList.add('model-unavailable-not-found');
|
|
break;
|
|
default:
|
|
$option.textContent = `${model.model} (provider error)`;
|
|
$option.disabled = true;
|
|
$option.classList.add('model-unavailable-error');
|
|
}
|
|
$group.appendChild($option);
|
|
}
|
|
this.$model.appendChild($group);
|
|
}
|
|
|
|
const find = (provider, model) =>
|
|
this.$model.querySelector(
|
|
`option[data-provider="${CSS.escape(provider ?? '')}"]` +
|
|
`[data-model="${CSS.escape(model ?? '')}"]`,
|
|
);
|
|
let $option =
|
|
(stored.provider &&
|
|
stored.model &&
|
|
find(stored.provider, stored.model)) ||
|
|
find(data.default_provider, data.default_model);
|
|
if (!$option) {
|
|
$option = [...this.$model.options].find((option) => !option.disabled);
|
|
}
|
|
if ($option) {
|
|
this.$model.value = $option.value;
|
|
} else {
|
|
// No model answered: name the failure in the selection.
|
|
const $none = this.document.createElement('option');
|
|
$none.value = '';
|
|
$none.disabled = true;
|
|
$none.textContent = 'Failed to load';
|
|
this.$model.replaceChildren($none);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* getSelectedModel reports the selected provider and model, with
|
|
* the model's context size when the provider reported one.
|
|
* @returns {{provider: string | null, model: string | null, contextSize: number | null}}
|
|
*/
|
|
getSelectedModel() {
|
|
const $option = this.$model.options[this.$model.selectedIndex];
|
|
const model = $option?.dataset.model ?? null;
|
|
return {
|
|
provider: $option?.dataset.provider ?? null,
|
|
model,
|
|
contextSize: model ? (this.modelContextSizes.get(model) ?? null) : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* populateVoices fills the voice selection from the TTS server.
|
|
* @param {string[]} voices
|
|
* @param {string} selected
|
|
*/
|
|
populateVoices(voices, selected) {
|
|
const labels = {
|
|
af: '🇺🇸 American English (F)',
|
|
am: '🇺🇸 American English (M)',
|
|
bf: '🇬🇧 British English (F)',
|
|
bm: '🇬🇧 British English (M)',
|
|
ef: '🇪🇸 Spanish (F)',
|
|
em: '🇪🇸 Spanish (M)',
|
|
ff: '🇫🇷 French (F)',
|
|
fm: '🇫🇷 French (M)',
|
|
hf: '🇮🇳 Hindi (F)',
|
|
hm: '🇮🇳 Hindi (M)',
|
|
if: '🇮🇹 Italian (F)',
|
|
im: '🇮🇹 Italian (M)',
|
|
jf: '🇯🇵 Japanese (F)',
|
|
jm: '🇯🇵 Japanese (M)',
|
|
kf: '🇰🇷 Korean (F)',
|
|
km: '🇰🇷 Korean (M)',
|
|
pf: '🇧🇷 Brazilian Portuguese (F)',
|
|
pm: '🇧🇷 Brazilian Portuguese (M)',
|
|
zf: '🇨🇳 Mandarin Chinese (F)',
|
|
zm: '🇨🇳 Mandarin Chinese (M)',
|
|
};
|
|
|
|
this.$voice.replaceChildren();
|
|
const $auto = this.document.createElement('option');
|
|
$auto.value = '';
|
|
$auto.textContent = '🌐 Auto';
|
|
this.$voice.appendChild($auto);
|
|
|
|
const groups = new Map();
|
|
for (const voice of voices) {
|
|
const prefix = voice.substring(0, 2);
|
|
if (!groups.has(prefix)) groups.set(prefix, []);
|
|
groups.get(prefix).push(voice);
|
|
}
|
|
for (const prefix of [...groups.keys()].sort()) {
|
|
const $group = this.document.createElement('optgroup');
|
|
$group.label = labels[prefix] ?? prefix.toUpperCase();
|
|
for (const voice of groups.get(prefix).sort()) {
|
|
const name = voice.substring(3).replace(/_/g, ' ');
|
|
const $option = this.document.createElement('option');
|
|
$option.value = voice;
|
|
$option.textContent = name.charAt(0).toUpperCase() + name.slice(1);
|
|
$group.appendChild($option);
|
|
}
|
|
this.$voice.appendChild($group);
|
|
}
|
|
if (voices.length === 0) {
|
|
// No voice answered: name the failure in the selection.
|
|
const $none = this.document.createElement('option');
|
|
$none.value = '';
|
|
$none.disabled = true;
|
|
$none.textContent = 'Failed to load';
|
|
this.$voice.appendChild($none);
|
|
return;
|
|
}
|
|
this.$voice.value = this.$voice.querySelector(
|
|
`option[value="${CSS.escape(selected)}"]`,
|
|
)
|
|
? selected
|
|
: '';
|
|
}
|
|
|
|
/**
|
|
* setSystemMessageDraft fills the system message textarea of the
|
|
* settings modal.
|
|
* @param {string} text
|
|
*/
|
|
setSystemMessageDraft(text) {
|
|
this.$systemMessageInput.value = text;
|
|
}
|
|
|
|
// ====================
|
|
// COMPOSE SETTINGS MODAL
|
|
// ====================
|
|
|
|
/**
|
|
* openComposeSettings shows the compose settings with the given
|
|
* values. Closing it reports the values back, even unchanged.
|
|
* @param {{maxIterations: number, reasoningBudget: number | null}} values
|
|
*/
|
|
openComposeSettings({ maxIterations, reasoningBudget }) {
|
|
this.$maxTurnsInput.value = String(maxIterations);
|
|
this.$reasoningBudgetInput.value =
|
|
reasoningBudget == null ? '' : String(reasoningBudget);
|
|
this.$composeSettingsOverlay.classList.add('open');
|
|
this.$composeSettingsClose.focus();
|
|
}
|
|
|
|
// ====================
|
|
// CONVERSATIONS MODAL
|
|
// ====================
|
|
|
|
/**
|
|
* openConversations shows the conversation picker and asks
|
|
* main.js for a fresh list. The search field starts empty and
|
|
* holds the caret, so typing filters the list at once.
|
|
*/
|
|
openConversations() {
|
|
this.$conversationsSearch.value = '';
|
|
this.$conversationsOverlay.classList.add('open');
|
|
this.$conversationsSearch.focus();
|
|
this.handlers.onConversationsOpen();
|
|
}
|
|
|
|
/** closeConversations hides the picker. */
|
|
closeConversations() {
|
|
this.$conversationsOverlay.classList.remove('open');
|
|
this.$conversations.focus();
|
|
}
|
|
|
|
/**
|
|
* renderConversationList fills the picker with the rows given,
|
|
* in the order given. main.js filters the rows; the row that
|
|
* matches currentId is marked as the conversation in view. The
|
|
* search field decides which empty message shows.
|
|
* @param {Array<{id: string, title: string | null, created_at: string, updated_at: string}>} rows
|
|
* @param {{currentId?: string | null}} [options]
|
|
*/
|
|
renderConversationList(rows, { currentId = null } = {}) {
|
|
this.$conversationsList.replaceChildren();
|
|
if (rows.length === 0) {
|
|
const $empty = this.document.createElement('div');
|
|
$empty.className = 'conversations__empty';
|
|
$empty.textContent = this.$conversationsSearch.value
|
|
? 'No conversations match the search.'
|
|
: 'No conversations yet.';
|
|
this.$conversationsList.appendChild($empty);
|
|
return;
|
|
}
|
|
for (const conversation of rows) {
|
|
const $item =
|
|
this.$tplConversationItem.content.cloneNode(true).firstElementChild;
|
|
$item.dataset.conversationId = conversation.id;
|
|
$item.querySelector('.conversations__item-title').textContent =
|
|
conversationLabel(conversation);
|
|
$item.querySelector('.conversations__item-created').textContent =
|
|
`Created ${CONVERSATION_DATE.format(
|
|
new Date(conversation.created_at),
|
|
)}`;
|
|
$item.querySelector('.conversations__item-updated').textContent =
|
|
`Updated ${CONVERSATION_DATE.format(
|
|
new Date(conversation.updated_at),
|
|
)}`;
|
|
if (conversation.id === currentId) {
|
|
$item.classList.add('conversations__item--current');
|
|
$item.setAttribute('aria-current', 'true');
|
|
}
|
|
this.$conversationsList.appendChild($item);
|
|
}
|
|
}
|
|
|
|
// ====================
|
|
// MUTE
|
|
// ====================
|
|
|
|
/**
|
|
* setMuted switches the mute icon.
|
|
* @param {boolean} muted
|
|
*/
|
|
setMuted(muted) {
|
|
this.$mute.replaceChildren(this._icon(muted ? 'volume-off' : 'volume'));
|
|
this.$mute.classList.toggle('footer__toolbar-btn--muted', muted);
|
|
this.$mute.setAttribute('aria-label', muted ? 'Unmute' : 'Mute');
|
|
}
|
|
|
|
// ====================
|
|
// EDIT MODALS
|
|
// ====================
|
|
|
|
/**
|
|
* editText opens the text edit modal. It resolves with the edited
|
|
* text when saved, and with null when cancelled. The modal shows
|
|
* the raw text; markdown renders after saving.
|
|
* @param {{title: string, value: string}} options
|
|
* @returns {Promise<string | null>}
|
|
*/
|
|
editText({ title, value }) {
|
|
const $modal = this.$tplEditModal.content.cloneNode(true).firstElementChild;
|
|
$modal.querySelector('h2').textContent = title;
|
|
$modal.querySelector('#edit-textarea').value = value;
|
|
return this._openModal(
|
|
$modal,
|
|
($modal) => $modal.querySelector('#edit-textarea').value,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* editConversationTitle opens the rename modal with the current
|
|
* title. It resolves with the trimmed new title when saved, and
|
|
* with null when cancelled. An empty title is not a rename, so
|
|
* the modal stays open until the user enters one or cancels.
|
|
* @param {string} title
|
|
* @returns {Promise<string | null>}
|
|
*/
|
|
editConversationTitle(title) {
|
|
const $modal =
|
|
this.$tplRenameModal.content.cloneNode(true).firstElementChild;
|
|
$modal.querySelector('#rename-input').value = title;
|
|
return this._openModal($modal, ($modal) => {
|
|
const value = $modal.querySelector('#rename-input').value.trim();
|
|
if (value === '') {
|
|
throw new Error('the title must not be empty');
|
|
}
|
|
return value;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* editToolCall opens the tool edit modal, which edits the call's
|
|
* name and arguments and, when a result exists, its text. It
|
|
* resolves with { name, arguments, result } when saved, and with
|
|
* null when cancelled. The call is edited as JSON; saving stays in
|
|
* the modal until the JSON parses.
|
|
* @param {{name: string, arguments: string, result: string}} options
|
|
* @returns {Promise<{name: string, arguments: string, result: string} | null>}
|
|
*/
|
|
editToolCall({ name, arguments: args, result }) {
|
|
const $modal =
|
|
this.$tplEditToolModal.content.cloneNode(true).firstElementChild;
|
|
const $call = $modal.querySelector('#edit-tool-call');
|
|
try {
|
|
$call.value = JSON.stringify(
|
|
{ name, arguments: JSON.parse(args || '{}') },
|
|
null,
|
|
2,
|
|
);
|
|
} catch {
|
|
$call.value = JSON.stringify({ name, arguments: args }, null, 2);
|
|
}
|
|
$modal.querySelector('#edit-tool-result').value = result;
|
|
return this._openModal($modal, ($modal) => {
|
|
const parsed = JSON.parse($modal.querySelector('#edit-tool-call').value);
|
|
return {
|
|
name: String(parsed.name ?? ''),
|
|
arguments:
|
|
typeof parsed.arguments === 'string'
|
|
? parsed.arguments
|
|
: JSON.stringify(parsed.arguments ?? {}),
|
|
result: $modal.querySelector('#edit-tool-result').value,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ====================
|
|
// SCROLL
|
|
// ====================
|
|
|
|
/**
|
|
* setAutoScroll turns following the newest output on or off, and
|
|
* syncs the toolbar indicator. Following is off until the user
|
|
* turns it on; scrolling up turns it off.
|
|
* @param {boolean} enabled
|
|
*/
|
|
setAutoScroll(enabled) {
|
|
this.isAutoScrollEnabled = enabled;
|
|
this.$scrollDown.classList.toggle('following', enabled);
|
|
}
|
|
|
|
/** scrollToBottom follows the output only while enabled. */
|
|
scrollToBottom() {
|
|
if (this.isAutoScrollEnabled) {
|
|
this.$chat.scrollTop = this.$chat.scrollHeight;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* scrollToLastUserMessage anchors the last user message to the
|
|
* top of the viewport, so the reply grows below it. No effect
|
|
* while following, or when the message is already visible.
|
|
*/
|
|
scrollToLastUserMessage() {
|
|
const $last = this.$chat.querySelectorAll('.message--user');
|
|
const $message = $last[$last.length - 1];
|
|
if (!$message || this.isAutoScrollEnabled) return;
|
|
const { scrollTop, clientHeight } = this.$chat;
|
|
const top = $message.offsetTop - this.$chat.offsetTop;
|
|
const bottom = top + $message.offsetHeight;
|
|
if (top >= scrollTop && bottom <= scrollTop + clientHeight) return;
|
|
this.$chat.scrollTop = top;
|
|
}
|
|
|
|
// ====================
|
|
// PRIVATE: EVENT BINDING
|
|
// ====================
|
|
|
|
_bindEvents() {
|
|
// One delegated click listener routes every data-action.
|
|
this.document.addEventListener('click', (event) =>
|
|
this._handleClick(event),
|
|
);
|
|
|
|
// Press-and-hold for push-to-talk and transcription.
|
|
this.$compose.addEventListener('mousedown', (event) =>
|
|
this._handlePressStart(event),
|
|
);
|
|
this.$compose.addEventListener('touchstart', (event) =>
|
|
this._handlePressStart(event),
|
|
);
|
|
this.document.addEventListener('mouseup', (event) =>
|
|
this._handlePressEnd(event),
|
|
);
|
|
this.document.addEventListener('touchend', (event) =>
|
|
this._handlePressEnd(event),
|
|
);
|
|
this.document.addEventListener('touchcancel', (event) =>
|
|
this._handlePressEnd(event),
|
|
);
|
|
this.$ptt.addEventListener('contextmenu', (event) =>
|
|
event.preventDefault(),
|
|
);
|
|
this.$transcribe.addEventListener('contextmenu', (event) =>
|
|
event.preventDefault(),
|
|
);
|
|
this.document.addEventListener('keydown', (event) =>
|
|
this._handleKeyDown(event),
|
|
);
|
|
this.document.addEventListener('keyup', (event) =>
|
|
this._handleKeyUp(event),
|
|
);
|
|
|
|
// Compose field.
|
|
this.$textInput.addEventListener('keydown', (event) => {
|
|
if (event.code === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault();
|
|
this.handlers.onComposeSubmit();
|
|
}
|
|
});
|
|
this.$textInput.addEventListener('input', () => {
|
|
this._resizeTextarea();
|
|
this.handlers.onComposeInput();
|
|
});
|
|
|
|
// Attachments.
|
|
this.$attach.addEventListener('click', () => this.$fileInput.click());
|
|
this.$fileInput.addEventListener('change', (event) => {
|
|
this.handlers.onAttachFiles(event.target.files);
|
|
this.$fileInput.value = '';
|
|
});
|
|
|
|
// Chat scroll.
|
|
this.$chat.addEventListener('scroll', () => this._handleChatScroll(), {
|
|
passive: true,
|
|
});
|
|
|
|
// Settings modal.
|
|
this.$settings.addEventListener('click', () => this._openSettings());
|
|
this.$settingsClose.addEventListener('click', () => this._closeSettings());
|
|
this.$settingsOverlay.addEventListener('click', (event) => {
|
|
if (event.target === this.$settingsOverlay) this._closeSettings();
|
|
});
|
|
this.document.addEventListener('keydown', (event) => {
|
|
if (
|
|
event.key === 'Escape' &&
|
|
this.$settingsOverlay.classList.contains('open')
|
|
) {
|
|
event.preventDefault();
|
|
this._closeSettings();
|
|
}
|
|
});
|
|
this.$settingsTabs.forEach(($tab) => {
|
|
$tab.addEventListener('click', () => this._switchTab($tab.dataset.tab));
|
|
});
|
|
this.$saveSystemMessage.addEventListener('click', () =>
|
|
this._saveSystemMessage(),
|
|
);
|
|
this.$model.addEventListener('change', () => {
|
|
const { provider, model } = this.getSelectedModel();
|
|
this.handlers.onModelChange({ provider, model });
|
|
});
|
|
this.$voice.addEventListener('change', () =>
|
|
this.handlers.onVoiceChange(this.$voice.value),
|
|
);
|
|
|
|
// Conversations modal. The toolbar button opens it through the
|
|
// data-action route; these listeners close it and report the
|
|
// search text and the selected row.
|
|
this.$conversationsClose.addEventListener('click', () =>
|
|
this.closeConversations(),
|
|
);
|
|
this.$conversationsOverlay.addEventListener('click', (event) => {
|
|
if (event.target === this.$conversationsOverlay) {
|
|
this.closeConversations();
|
|
}
|
|
});
|
|
this.document.addEventListener('keydown', (event) => {
|
|
if (
|
|
event.key === 'Escape' &&
|
|
this.$conversationsOverlay.classList.contains('open')
|
|
) {
|
|
event.preventDefault();
|
|
this.closeConversations();
|
|
}
|
|
});
|
|
this.$conversationsSearch.addEventListener('input', () =>
|
|
this.handlers.onConversationSearch(this.$conversationsSearch.value),
|
|
);
|
|
this.$conversationNew.addEventListener('click', () =>
|
|
this.handlers.onNewConversation(),
|
|
);
|
|
this.$conversationsList.addEventListener('click', (event) => {
|
|
const $item = event.target.closest('[data-conversation-id]');
|
|
if (!$item) return;
|
|
const id = $item.dataset.conversationId;
|
|
const $rename = event.target.closest('.conversations__item-rename');
|
|
if ($rename) {
|
|
this._clearConfirm();
|
|
this.handlers.onRenameConversation(id);
|
|
return;
|
|
}
|
|
const $delete = event.target.closest('.conversations__item-delete');
|
|
if ($delete) {
|
|
// Deleting is irreversible: the second press on the same
|
|
// button deletes, any other click cancels the request.
|
|
this._confirm(`conversation:${id}`, $delete, 'pending', () =>
|
|
this.handlers.onDeleteConversation(id),
|
|
);
|
|
return;
|
|
}
|
|
this._clearConfirm();
|
|
this.handlers.onSelectConversation(id);
|
|
});
|
|
|
|
// Compose settings modal.
|
|
this.$composeSettings.addEventListener('click', () =>
|
|
this.handlers.onComposeSettings(),
|
|
);
|
|
this.$composeSettingsClose.addEventListener('click', () =>
|
|
this._closeComposeSettings(),
|
|
);
|
|
this.$composeSettingsOverlay.addEventListener('click', (event) => {
|
|
if (event.target === this.$composeSettingsOverlay) {
|
|
this._closeComposeSettings();
|
|
}
|
|
});
|
|
}
|
|
|
|
_handleClick(event) {
|
|
const $target = event.target.closest('[data-action]');
|
|
const action = $target?.dataset.action;
|
|
|
|
// A click outside any action button cancels every pending
|
|
// confirmation.
|
|
if (!action) {
|
|
this._clearConfirm();
|
|
return;
|
|
}
|
|
|
|
const $message = $target.closest('.message');
|
|
const messageId = $message?.dataset.messageId || $message?.dataset.id;
|
|
|
|
switch (action) {
|
|
case 'send':
|
|
if (this.$send.classList.contains('loading')) {
|
|
this._confirm('stop', $target, 'pending', () =>
|
|
this.handlers.onStop(),
|
|
);
|
|
} else {
|
|
this._clearConfirm();
|
|
this.handlers.onComposeSubmit();
|
|
}
|
|
break;
|
|
case 'reset':
|
|
this._confirm('reset', $target, 'footer__toolbar-btn--pending', () =>
|
|
this.handlers.onReset(),
|
|
);
|
|
break;
|
|
case 'delete':
|
|
// Error bubbles are display-only: remove without a request.
|
|
if (
|
|
!$message ||
|
|
(!$message.dataset.id && !$message.dataset.messageId)
|
|
) {
|
|
this._clearConfirm();
|
|
$message?.remove();
|
|
break;
|
|
}
|
|
this._confirm(
|
|
`delete:${messageId}:${$message.dataset.field ?? ''}` +
|
|
`:${$message.dataset.toolCallId ?? ''}`,
|
|
$target,
|
|
'pending',
|
|
() =>
|
|
this.handlers.onDelete({
|
|
id: messageId,
|
|
field: $message.dataset.field || null,
|
|
toolCallId: $message.dataset.toolCallId || null,
|
|
}),
|
|
);
|
|
break;
|
|
case 'approve':
|
|
case 'deny': {
|
|
this._clearConfirm();
|
|
const execution = this.pendingApprovals.find(
|
|
({ messageId: mid, toolCallId }) =>
|
|
mid === $message?.dataset.parentId &&
|
|
toolCallId === $message?.dataset.toolCallId,
|
|
);
|
|
if (!execution) break;
|
|
if (action === 'approve') {
|
|
this.handlers.onApprove(execution);
|
|
} else {
|
|
this.handlers.onDeny(execution);
|
|
}
|
|
break;
|
|
}
|
|
case 'edit':
|
|
this._clearConfirm();
|
|
this.handlers.onEdit({
|
|
id: messageId,
|
|
field: $message?.dataset.field || null,
|
|
toolCallId: $message?.dataset.toolCallId || null,
|
|
});
|
|
break;
|
|
case 'play':
|
|
this._clearConfirm();
|
|
if (messageId) this.handlers.onPlay(messageId);
|
|
break;
|
|
case 'copy': {
|
|
this._clearConfirm();
|
|
const text = $message?.dataset.copyText || '';
|
|
this._copyToClipboard($target, text);
|
|
break;
|
|
}
|
|
case 'inspect': {
|
|
this._clearConfirm();
|
|
const isOpen = $message.classList.toggle('message--debug-open');
|
|
$target.setAttribute('aria-expanded', String(isOpen));
|
|
break;
|
|
}
|
|
case 'scroll-up':
|
|
this._clearConfirm();
|
|
this.setAutoScroll(false);
|
|
this.$chat.scrollTop = 0;
|
|
break;
|
|
case 'scroll-down':
|
|
// Set scrollTop first, so the scroll listener sees the
|
|
// bottom position and does not turn following off again.
|
|
this._clearConfirm();
|
|
this.$chat.scrollTop = this.$chat.scrollHeight;
|
|
this.setAutoScroll(true);
|
|
break;
|
|
case 'mute':
|
|
this._clearConfirm();
|
|
this.handlers.onToggleMute();
|
|
break;
|
|
case 'location':
|
|
this._clearConfirm();
|
|
this.handlers.onLocationToggle();
|
|
break;
|
|
case 'conversations':
|
|
this._clearConfirm();
|
|
this.openConversations();
|
|
break;
|
|
case 'settings':
|
|
this._clearConfirm();
|
|
this._openSettings();
|
|
break;
|
|
case 'compose-settings':
|
|
this._clearConfirm();
|
|
this.handlers.onComposeSettings();
|
|
break;
|
|
case 'attach':
|
|
// Handled by the listener on the button itself.
|
|
break;
|
|
case 'ptt':
|
|
case 'transcribe':
|
|
// Handled by press-and-hold.
|
|
break;
|
|
default:
|
|
this._clearConfirm();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* _confirm runs action on the second press of the same control.
|
|
* The first press marks the control; any other click clears the
|
|
* mark. key tells the presses apart.
|
|
*/
|
|
_confirm(key, $target, className, action) {
|
|
if (
|
|
this.$pendingConfirm?.key === key &&
|
|
this.$pendingConfirm?.$target === $target
|
|
) {
|
|
this._clearConfirm();
|
|
action();
|
|
return;
|
|
}
|
|
this._clearConfirm();
|
|
this.$pendingConfirm = { key, $target };
|
|
$target.classList.add(className);
|
|
}
|
|
|
|
_clearConfirm() {
|
|
const pending = this.$pendingConfirm;
|
|
if (!pending) return;
|
|
pending.$target.classList.remove('pending', 'footer__toolbar-btn--pending');
|
|
this.$pendingConfirm = null;
|
|
}
|
|
|
|
_handlePressStart(event) {
|
|
const $target = event.target.closest('[data-action]');
|
|
const action = $target?.dataset.action;
|
|
if (action !== 'ptt' && action !== 'transcribe') return;
|
|
|
|
// Let the document-level click handler ignore these; touch and
|
|
// mouse both arrive as presses here.
|
|
if (event.type === 'touchstart') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
} else if (event.sourceCapabilities?.firesTouchEvents) {
|
|
return;
|
|
} else {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
|
|
if (action === 'ptt') this.handlers.onPushToTalkStart();
|
|
else this.handlers.onTranscribeStart();
|
|
}
|
|
|
|
_handlePressEnd() {
|
|
// The handlers ignore the end of a press that never started,
|
|
// so both controls can share the release listeners.
|
|
this.handlers.onPushToTalkEnd();
|
|
this.handlers.onTranscribeEnd();
|
|
}
|
|
|
|
_handleKeyDown(event) {
|
|
if (event.code !== 'Space' || event.repeat) return;
|
|
if (this._isFormField(event.target)) return;
|
|
event.preventDefault();
|
|
this.handlers.onPushToTalkStart();
|
|
}
|
|
|
|
_handleKeyUp(event) {
|
|
if (event.code !== 'Space') return;
|
|
if (this._isFormField(event.target)) return;
|
|
event.preventDefault();
|
|
this.handlers.onPushToTalkEnd();
|
|
}
|
|
|
|
_isFormField(target) {
|
|
return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName);
|
|
}
|
|
|
|
_resizeTextarea() {
|
|
const textarea = this.$textInput;
|
|
textarea.style.height = 'auto';
|
|
const maxHeight = parseFloat(getComputedStyle(textarea).maxHeight);
|
|
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
|
|
}
|
|
|
|
_handleChatScroll() {
|
|
// Disable-only: scrolling away from the bottom turns following
|
|
// off. Being at the bottom does not turn it back on; only the
|
|
// scroll-down button does.
|
|
const { scrollHeight, scrollTop, clientHeight } = this.$chat;
|
|
if (scrollHeight - scrollTop - clientHeight > 100) {
|
|
this.setAutoScroll(false);
|
|
}
|
|
}
|
|
|
|
// ====================
|
|
// PRIVATE: MODALS
|
|
// ====================
|
|
|
|
_openSettings() {
|
|
this.$settingsOverlay.classList.add('open');
|
|
this.handlers.onSettingsOpen();
|
|
this._switchTab('model-voice');
|
|
this.$settingsClose.focus();
|
|
}
|
|
|
|
_closeSettings() {
|
|
this.$settingsOverlay.classList.remove('open');
|
|
this.$settings.focus();
|
|
}
|
|
|
|
_switchTab(name) {
|
|
this.$settingsTabs.forEach(($tab) => {
|
|
const isActive = $tab.dataset.tab === name;
|
|
$tab.classList.toggle('active', isActive);
|
|
$tab.setAttribute('aria-selected', String(isActive));
|
|
});
|
|
this.$settingsPanels.forEach(($panel) => {
|
|
const isActive = $panel.dataset.tabPanel === name;
|
|
$panel.classList.toggle('active', isActive);
|
|
$panel.hidden = !isActive;
|
|
});
|
|
}
|
|
|
|
_saveSystemMessage() {
|
|
this.handlers.onSaveSystemMessage(this.$systemMessageInput.value);
|
|
|
|
// Brief visual feedback on the save button.
|
|
this.$saveSystemMessage.replaceChildren(this._icon('check'));
|
|
this.$saveSystemMessage.classList.add('settings-save-btn--success');
|
|
setTimeout(() => {
|
|
this.$saveSystemMessage.textContent = 'Save';
|
|
this.$saveSystemMessage.classList.remove('settings-save-btn--success');
|
|
}, 1500);
|
|
}
|
|
|
|
_closeComposeSettings() {
|
|
this.$composeSettingsOverlay.classList.remove('open');
|
|
this.$composeSettings.focus();
|
|
const budget = this.$reasoningBudgetInput.value.trim();
|
|
this.handlers.onComposeSettingsChange({
|
|
maxIterations: Number(this.$maxTurnsInput.value),
|
|
reasoningBudget: budget === '' ? null : Number(budget),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* _openModal shows one edit modal and resolves with the value
|
|
* read by readValue when saved. Cancel, Escape, and a click
|
|
* outside resolve with null. A readValue that throws keeps the
|
|
* modal open and marks the error.
|
|
*/
|
|
_openModal($modal, readValue) {
|
|
return new Promise((resolve) => {
|
|
const $overlay = this.document.createElement('div');
|
|
$overlay.className = 'modal-overlay';
|
|
$overlay.appendChild($modal);
|
|
this.document.body.appendChild($overlay);
|
|
|
|
const $save = $modal.querySelector('.modal__save');
|
|
const close = (value) => {
|
|
$overlay.remove();
|
|
this.document.removeEventListener('keydown', onKey);
|
|
resolve(value);
|
|
};
|
|
const onKey = (event) => {
|
|
if (event.key === 'Escape') close(null);
|
|
};
|
|
|
|
$modal
|
|
.querySelector('.modal__close')
|
|
.addEventListener('click', () => close(null));
|
|
$overlay.addEventListener('click', (event) => {
|
|
if (event.target === $overlay) close(null);
|
|
});
|
|
this.document.addEventListener('keydown', onKey);
|
|
|
|
$save.addEventListener('click', () => {
|
|
let value;
|
|
try {
|
|
value = readValue($modal);
|
|
} catch (error) {
|
|
console.error('invalid input:', error);
|
|
for (const $field of $modal.querySelectorAll(
|
|
'.modal__textarea, .modal__input',
|
|
)) {
|
|
$field.classList.add('modal__textarea--error');
|
|
}
|
|
$save.classList.add('modal__save--error');
|
|
setTimeout(() => {
|
|
for (const $field of $modal.querySelectorAll(
|
|
'.modal__textarea, .modal__input',
|
|
)) {
|
|
$field.classList.remove('modal__textarea--error');
|
|
}
|
|
$save.classList.remove('modal__save--error');
|
|
}, 500);
|
|
return;
|
|
}
|
|
close(value);
|
|
});
|
|
|
|
$modal.querySelector('textarea, input')?.focus();
|
|
});
|
|
}
|
|
|
|
// ====================
|
|
// PRIVATE: RENDERING
|
|
// ====================
|
|
|
|
_renderUserMessage(row) {
|
|
const $bubble =
|
|
this.$tplUserMessage.content.cloneNode(true).firstElementChild;
|
|
$bubble.dataset.id = row.id;
|
|
const text = extractText(row.content?.content);
|
|
$bubble.querySelector('.message__content').textContent = text;
|
|
$bubble.dataset.copyText = text;
|
|
this._renderImagePills($bubble, row);
|
|
this._populateDebug($bubble.querySelector('.message__debug-list'), row);
|
|
this.$chat.insertBefore($bubble, this.$chatLoading);
|
|
this.scrollToBottom();
|
|
}
|
|
|
|
_renderAssistantMessage(row, results) {
|
|
const content = row.content;
|
|
if (
|
|
(content?.reasoning_content || content?.reasoning) &&
|
|
!this._hasBubble(`${row.id}/reasoning`)
|
|
) {
|
|
this._renderReasoningMessage(row);
|
|
}
|
|
const text = content?.refusal || extractText(content?.content);
|
|
if (text && !this._hasBubble(row.id)) {
|
|
this._renderTextMessage(row, text);
|
|
}
|
|
for (const call of content?.tool_calls ?? []) {
|
|
if (this._findToolBubble(row.id, call.id)) continue;
|
|
this._renderToolBubble(row, call, results.get(call.id) ?? null);
|
|
}
|
|
}
|
|
|
|
_renderTextMessage(row, text) {
|
|
const $bubble =
|
|
this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
|
|
$bubble.dataset.id = row.id;
|
|
$bubble.querySelector('.message__content').innerHTML =
|
|
this._renderMarkdown(text);
|
|
$bubble.dataset.voice = row.voice || '';
|
|
$bubble.dataset.copyText = text;
|
|
this._populateDebug($bubble.querySelector('.message__debug-list'), row);
|
|
this.$chat.insertBefore($bubble, this.$chatLoading);
|
|
this.scrollToBottom();
|
|
}
|
|
|
|
_renderReasoningMessage(row) {
|
|
const $bubble =
|
|
this.$tplReasoningMessage.content.cloneNode(true).firstElementChild;
|
|
$bubble.dataset.id = `${row.id}/reasoning`;
|
|
$bubble.dataset.parentId = row.id;
|
|
$bubble.dataset.field = 'reasoning';
|
|
$bubble.dataset.messageId = row.id;
|
|
const reasoning = row.content?.reasoning_content || '';
|
|
$bubble.querySelector('.message__content').textContent = reasoning;
|
|
$bubble.dataset.copyText = reasoning;
|
|
this._populateDebug($bubble.querySelector('.message__debug-list'), row);
|
|
this.$chat.insertBefore($bubble, this.$chatLoading);
|
|
this.scrollToBottom();
|
|
}
|
|
|
|
/**
|
|
* _renderToolBubble renders the bubble for one tool call of an
|
|
* assistant message. result is the saved tool result row, when
|
|
* one exists. parentRow links the bubble to the assistant
|
|
* message; without it the bubble belongs to a result whose
|
|
* assistant message is unknown, shows output only, and reports
|
|
* no actions that need the parent.
|
|
*/
|
|
_renderToolBubble(parentRow, call, result) {
|
|
const $bubble =
|
|
this.$tplToolMessage.content.cloneNode(true).firstElementChild;
|
|
const callId = call?.id ?? result?.content?.tool_call_id ?? '';
|
|
$bubble.dataset.id = result?.id ?? `${parentRow?.id ?? ''}/${callId}`;
|
|
if (parentRow) {
|
|
$bubble.dataset.parentId = parentRow.id;
|
|
$bubble.dataset.messageId = parentRow.id;
|
|
$bubble.dataset.field = 'tool';
|
|
}
|
|
if (callId) $bubble.dataset.toolCallId = callId;
|
|
$bubble.querySelector('.message__collapsible-label').textContent =
|
|
call?.function?.name || result?.content?.name || 'Tool call';
|
|
|
|
const $args = $bubble.querySelector('[data-args]');
|
|
if (call) {
|
|
$args.textContent = this._prettyJSON(call.function?.arguments);
|
|
} else {
|
|
// A result without its assistant message shows only output.
|
|
$args.closest('.message__tool-section').remove();
|
|
}
|
|
|
|
const copyParts = [];
|
|
if (call) {
|
|
copyParts.push(
|
|
`${call.function?.name || 'unknown'}(${call.function?.arguments || '{}'})`,
|
|
);
|
|
}
|
|
copyParts.push(extractText(result?.content?.content) || '');
|
|
$bubble.dataset.copyText = copyParts.filter(Boolean).join('\n\n');
|
|
|
|
if (result) this._fillToolOutput($bubble, result);
|
|
this._populateDebug($bubble.querySelector('.message__debug-list'), {
|
|
...result,
|
|
voice: parentRow?.voice,
|
|
});
|
|
this.$chat.insertBefore($bubble, this.$chatLoading);
|
|
this._applyApproval($bubble);
|
|
this.scrollToBottom();
|
|
}
|
|
|
|
/** _renderOrphanToolResult renders a result row without parent. */
|
|
_renderOrphanToolResult(row) {
|
|
this._renderToolBubble(null, null, row);
|
|
}
|
|
|
|
/**
|
|
* _fillToolOutput fills the output section of a tool bubble with
|
|
* the text of its saved result row.
|
|
*/
|
|
_fillToolOutput($bubble, result) {
|
|
const output = this._prettyJSON(extractText(result.content?.content));
|
|
$bubble.dataset.id = result.id;
|
|
$bubble.querySelector('.message__tool-section--output').hidden = false;
|
|
$bubble.querySelector('[data-output]').textContent = output;
|
|
// Keep the copy text in step with the output now shown.
|
|
const label = $bubble.querySelector(
|
|
'.message__collapsible-label',
|
|
).textContent;
|
|
const args = $bubble.querySelector('[data-args]')?.textContent.trim();
|
|
$bubble.dataset.copyText = [`${label}(${args || '{}'})`, output]
|
|
.filter(Boolean)
|
|
.join('\n\n');
|
|
}
|
|
|
|
_renderSystemMessage(row) {
|
|
// A saved system row replaces the no-conversation placeholder.
|
|
this.$chat.querySelector('[data-id="system"]')?.remove();
|
|
const $bubble =
|
|
this.$tplSystemMessage.content.cloneNode(true).firstElementChild;
|
|
$bubble.dataset.id = row.id;
|
|
const text = extractText(row.content?.content);
|
|
$bubble.querySelector('.message__content').textContent = text;
|
|
$bubble.dataset.copyText = text;
|
|
this.$chat.insertBefore($bubble, this.$chatLoading);
|
|
}
|
|
|
|
_createDraftReasoning(draftId) {
|
|
const $bubble =
|
|
this.$tplReasoningMessage.content.cloneNode(true).firstElementChild;
|
|
$bubble.dataset.id = `${draftId}/reasoning`;
|
|
$bubble.dataset.parentId = draftId;
|
|
$bubble.classList.add('message--streaming');
|
|
$bubble.querySelector('.message__collapsible').open = true;
|
|
for (const $button of $bubble.querySelectorAll('.message__action-btn')) {
|
|
$button.remove();
|
|
}
|
|
this.$chat.insertBefore($bubble, this.$chatLoading);
|
|
return $bubble;
|
|
}
|
|
|
|
_createDraftText(draftId) {
|
|
const $bubble =
|
|
this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
|
|
$bubble.dataset.id = draftId;
|
|
$bubble.classList.add('message--streaming');
|
|
for (const $button of $bubble.querySelectorAll('.message__action-btn')) {
|
|
$button.remove();
|
|
}
|
|
this.$chat.insertBefore($bubble, this.$chatLoading);
|
|
return $bubble;
|
|
}
|
|
|
|
_createDraftToolBubble(draftId, callId) {
|
|
const $bubble =
|
|
this.$tplToolMessage.content.cloneNode(true).firstElementChild;
|
|
$bubble.dataset.id = `${draftId}/${callId}`;
|
|
$bubble.dataset.parentId = draftId;
|
|
$bubble.dataset.toolCallId = callId;
|
|
$bubble.classList.add('message--streaming');
|
|
$bubble.querySelector('.message__collapsible').open = true;
|
|
$bubble.querySelector('.message__actions').remove();
|
|
this.$chat.insertBefore($bubble, this.$chatLoading);
|
|
return $bubble;
|
|
}
|
|
|
|
/**
|
|
* _findToolBubble finds the bubble of one call. callId may be a
|
|
* '#index' key while a draft's call has no ID yet.
|
|
*/
|
|
_findToolBubble(parentId, callId) {
|
|
if (!parentId || !callId) return null;
|
|
return this.$chat.querySelector(
|
|
`[data-parent-id="${CSS.escape(parentId)}"]` +
|
|
`[data-tool-call-id="${CSS.escape(callId)}"]`,
|
|
);
|
|
}
|
|
|
|
_renderImagePills($bubble, row) {
|
|
const parts = Array.isArray(row.content?.content)
|
|
? row.content.content.filter((part) => part.type === 'image_url')
|
|
: [];
|
|
if (parts.length === 0) return;
|
|
const $wrapper = this.document.createElement('div');
|
|
$wrapper.className = 'message__image-pills';
|
|
for (const part of parts) {
|
|
const $pill =
|
|
this.$tplImagePill.content.cloneNode(true).firstElementChild;
|
|
// The server does not store the original file name with the
|
|
// image; name it by its data format instead.
|
|
const format = part.image_url?.url?.match(/^data:image\/(\w+)/)?.[1];
|
|
$pill.querySelector('.message__image-pill-name').textContent = format
|
|
? `Image (${format})`
|
|
: 'Image';
|
|
$wrapper.appendChild($pill);
|
|
}
|
|
$bubble
|
|
.querySelector('.message__body')
|
|
.insertBefore($wrapper, $bubble.querySelector('.message__actions'));
|
|
}
|
|
|
|
/**
|
|
* _populateDebug fills the inspect panel of a bubble from the
|
|
* message's inference metadata and the voice used for playback.
|
|
* Missing metadata shows as a single 'No data' row.
|
|
*/
|
|
_populateDebug($list, row) {
|
|
const meta = { ...row?.metadata, voice: row?.voice };
|
|
if (meta.voice) this._appendDebugRow($list, 'Voice', meta.voice);
|
|
const usage = meta.usage;
|
|
if (usage) {
|
|
this._appendDebugRow($list, 'Prompt tokens', String(usage.prompt_tokens));
|
|
this._appendDebugRow(
|
|
$list,
|
|
'Completion tokens',
|
|
String(usage.completion_tokens),
|
|
);
|
|
this._appendDebugRow($list, 'Total tokens', String(usage.total_tokens));
|
|
if (usage.completion_tokens_details?.reasoning_tokens > 0) {
|
|
this._appendDebugRow(
|
|
$list,
|
|
'Reasoning tokens',
|
|
String(usage.completion_tokens_details.reasoning_tokens),
|
|
);
|
|
}
|
|
if (usage.prompt_tokens_details?.cached_tokens > 0) {
|
|
this._appendDebugRow(
|
|
$list,
|
|
'Cached tokens',
|
|
String(usage.prompt_tokens_details.cached_tokens),
|
|
);
|
|
}
|
|
}
|
|
const timings = meta.timings;
|
|
if (timings) {
|
|
if (timings.prompt_ms > 0) {
|
|
this._appendDebugRow(
|
|
$list,
|
|
'Prompt eval',
|
|
`${timings.prompt_ms.toFixed(1)} ms ` +
|
|
`(${timings.prompt_per_second.toFixed(0)} tok/s)`,
|
|
);
|
|
}
|
|
if (timings.predicted_ms > 0) {
|
|
this._appendDebugRow(
|
|
$list,
|
|
'Predicted eval',
|
|
`${timings.predicted_ms.toFixed(1)} ms ` +
|
|
`(${timings.predicted_per_second.toFixed(0)} tok/s)`,
|
|
);
|
|
}
|
|
}
|
|
if ($list.children.length === 0) {
|
|
this._appendDebugRow($list, 'No data', '');
|
|
}
|
|
}
|
|
|
|
_appendDebugRow($list, label, value) {
|
|
const $row = this.$tplDebugRow.content.cloneNode(true);
|
|
$row.querySelector('dt').textContent = label;
|
|
$row.querySelector('dd').textContent = value;
|
|
$list.appendChild($row);
|
|
}
|
|
|
|
/**
|
|
* _applyApproval shows or hides the approve and deny buttons of
|
|
* one bubble, according to the pending approval set.
|
|
*/
|
|
_applyApproval($bubble) {
|
|
const $approval = $bubble.querySelector('.message__approval');
|
|
if (!$approval) return;
|
|
$approval.hidden = !this.pendingApprovals.some(
|
|
({ messageId, toolCallId }) =>
|
|
messageId === $bubble.dataset.parentId &&
|
|
toolCallId === $bubble.dataset.toolCallId,
|
|
);
|
|
}
|
|
|
|
/** _prettyJSON renders JSON text indented, or raw on failure. */
|
|
_prettyJSON(text) {
|
|
try {
|
|
return JSON.stringify(JSON.parse(text || 'null'), null, 2);
|
|
} catch {
|
|
return text || '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* _renderMarkdown converts Markdown text to sanitized HTML. It
|
|
* wraps tables in a scrollable container for small screens, and
|
|
* falls back to escaped plain text when the libraries are
|
|
* unavailable.
|
|
*/
|
|
_renderMarkdown(text) {
|
|
if (!text) return '';
|
|
try {
|
|
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
|
const container = this.document.createElement('div');
|
|
container.innerHTML = marked.parse(text);
|
|
for (const table of container.querySelectorAll('table')) {
|
|
const wrapper = this.document.createElement('div');
|
|
wrapper.className = 'message__table-scroll';
|
|
table.replaceWith(wrapper);
|
|
wrapper.appendChild(table);
|
|
}
|
|
return DOMPurify.sanitize(container.innerHTML);
|
|
}
|
|
} catch (error) {
|
|
console.error('failed to render markdown:', error);
|
|
}
|
|
const div = this.document.createElement('div');
|
|
div.textContent = text;
|
|
div.style.whiteSpace = 'pre-wrap';
|
|
return div.innerHTML;
|
|
}
|
|
|
|
_copyToClipboard($button, text) {
|
|
navigator.clipboard.writeText(text).then(
|
|
() => {
|
|
$button.replaceChildren(this._icon('check'));
|
|
$button.classList.add('message__action-btn--success');
|
|
setTimeout(() => {
|
|
$button.replaceChildren(this._icon('copy'));
|
|
$button.classList.remove('message__action-btn--success');
|
|
}, 1500);
|
|
},
|
|
(error) => console.error('failed to copy:', error),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* _initViewport keeps the page inside the visual viewport on
|
|
* mobile, where the on-screen keyboard resizes the window.
|
|
*/
|
|
_initViewport() {
|
|
if (!window.visualViewport) return;
|
|
const container = this.document.querySelector('.container');
|
|
if (!container) return;
|
|
const handler = () => {
|
|
const { height } = window.visualViewport;
|
|
if (height > 0) container.style.height = `${height}px`;
|
|
};
|
|
window.visualViewport.addEventListener('resize', handler);
|
|
handler();
|
|
}
|
|
|
|
_icon(name) {
|
|
const $svg = this.document.createElementNS(
|
|
'http://www.w3.org/2000/svg',
|
|
'svg',
|
|
);
|
|
$svg.setAttribute('class', 'icon');
|
|
const $use = this.document.createElementNS(
|
|
'http://www.w3.org/2000/svg',
|
|
'use',
|
|
);
|
|
$use.setAttribute('href', `${ICONS_URL}#${name}`);
|
|
$svg.appendChild($use);
|
|
return $svg;
|
|
}
|
|
}
|
|
|
|
// Configure marked to highlight code blocks with highlight.js.
|
|
// marked v8+ removed the highlight option from setOptions; use the
|
|
// marked-highlight extension instead.
|
|
if (
|
|
typeof marked !== 'undefined' &&
|
|
typeof markedHighlight !== 'undefined' &&
|
|
typeof hljs !== 'undefined'
|
|
) {
|
|
marked.use(
|
|
markedHighlight.markedHighlight({
|
|
langPrefix: 'hljs language-',
|
|
highlight(code, lang) {
|
|
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
|
|
return hljs.highlight(code, { language }).value;
|
|
},
|
|
}),
|
|
);
|
|
}
|