Add Markdown support

This commit is contained in:
dwrz
2026-07-09 14:47:43 +00:00
parent 98986a884a
commit e0eed6a935
13 changed files with 184 additions and 14 deletions

View File

@@ -0,0 +1,273 @@
/**
* ConversationTree is a pure data model for the conversation message tree.
* It owns the message map, tree structure (parent/children), and localStorage
* persistence. No DOM, no rendering.
*/
const STORAGE_KEY = 'odidere_history';
/**
* extractText extracts plain text from a message content field.
* Content can be a string or an array of content-part objects.
* @param {string|Array} content
* @returns {string}
*/
export function extractText(content) {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.filter((p) => p.type === 'text')
.map((p) => p.text)
.join('\n');
}
return '';
}
export class ConversationTree {
constructor() {
this.messagesMap = new Map();
this.leafId = null;
this.rootId = null;
}
/**
* get returns a message by ID.
* @param {string} id
* @returns {Object|undefined}
*/
get(id) {
return this.messagesMap.get(id);
}
/**
* getActivePath resolves the ordered array of messages from root to
* leaf by walking parent pointers from leafId.
* @returns {Object[]} ordered messages
*/
getActivePath() {
if (!this.leafId) return [];
const path = [];
let current = this.leafId;
while (current) {
const msg = this.messagesMap.get(current);
if (!msg) break;
path.push(msg);
current = msg.parent;
}
path.reverse();
return path;
}
/** @returns {number} number of messages in the tree */
get size() {
return this.messagesMap.size;
}
/**
* append adds messages to the tree. Each new message is a child of
* the previous one (or the current leaf for the first). Only the last
* message receives the metadata. Mutates the passed-in message objects
* in place (adds id, parent, children, meta) and stores the same
* references in messagesMap. Returns the same messages, now enriched.
* @param {Object[]} messages
* @param {Object} [meta]
* @returns {Object[]} the same message objects, now enriched
*/
append(messages, meta = {}) {
let prevId = this.leafId;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
// Generate ID if not present.
if (!msg.id) {
msg.id = crypto.randomUUID();
}
// Attach metadata only to the final message.
if (i === messages.length - 1) {
msg.meta = meta;
}
// Link to the tree.
msg.parent = prevId;
msg.children = [];
if (prevId) {
const parent = this.messagesMap.get(prevId);
if (parent) {
parent.children.push(msg.id);
}
} else {
// First message in the conversation.
this.rootId = msg.id;
}
this.messagesMap.set(msg.id, msg);
this.leafId = msg.id;
prevId = msg.id;
}
this.save();
return messages;
}
/**
* delete removes a message from the tree and reparents its children to
* the grandparent. Also handles tool call / tool result pairing. When
* deleting the root, the first child becomes the new root and any
* remaining children are reparented under it.
* @param {string} messageId
*/
delete(messageId) {
const msg = this.messagesMap.get(messageId);
if (!msg) return;
const parentId = msg.parent; // null if this is the root
const parent = parentId ? this.messagesMap.get(parentId) : null;
// Identify tool result messages that belong to this assistant message.
const idsToDelete = new Set([messageId]);
if (msg.role === 'assistant' && msg.tool_calls?.length > 0) {
const toolCallIds = new Set(msg.tool_calls.map((tc) => tc.id));
for (const [id, m] of this.messagesMap) {
if (m.role === 'tool' && toolCallIds.has(m.tool_call_id)) {
idsToDelete.add(id);
}
}
}
// Collect all children that need reparenting.
const childrenToReparent = [];
for (const deleteId of idsToDelete) {
const delMsg = this.messagesMap.get(deleteId);
if (delMsg?.children) {
childrenToReparent.push(...delMsg.children);
}
}
// Remove deleted messages from the map.
for (const deleteId of idsToDelete) {
this.messagesMap.delete(deleteId);
}
// Remove deleted IDs from parent's children.
if (parent) {
parent.children = parent.children.filter((c) => !idsToDelete.has(c));
}
// Reparent children of deleted messages to the grandparent.
if (parent) {
for (const childId of childrenToReparent) {
const child = this.messagesMap.get(childId);
if (child) {
child.parent = parentId;
if (!parent.children.includes(childId)) {
parent.children.push(childId);
}
}
}
} else if (childrenToReparent.length > 0) {
// Root deletion: first child becomes the new root.
const newRootId = childrenToReparent[0];
const newRoot = this.messagesMap.get(newRootId);
if (newRoot) {
newRoot.parent = null;
for (let i = 1; i < childrenToReparent.length; i++) {
const childId = childrenToReparent[i];
const child = this.messagesMap.get(childId);
if (child) {
child.parent = newRootId;
if (!newRoot.children.includes(childId)) {
newRoot.children.push(childId);
}
}
}
}
this.rootId = newRootId;
} else {
this.rootId = null;
}
// Update leafId if it was deleted.
if (idsToDelete.has(this.leafId)) {
if (parentId) {
this.updateLeafId(parentId);
} else if (childrenToReparent.length > 0) {
this.updateLeafId(childrenToReparent[0]);
} else {
this.leafId = null;
}
}
this.save();
}
/**
* updateLeafId walks the tree from a starting node to find the deepest
* leaf by following the first child at each step.
* @param {string} startId
*/
updateLeafId(startId) {
let current = startId;
while (current) {
const msg = this.messagesMap.get(current);
if (!msg?.children || msg.children.length === 0) break;
current = msg.children[0];
}
this.leafId = current;
}
/**
* load reads chat history from localStorage and populates the map.
*/
load() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return;
const parsed = JSON.parse(stored);
// Ensure messages have parent/children fields (defensive).
for (const msg of parsed.messages) {
if (msg.parent === undefined) msg.parent = null;
if (msg.children === undefined) msg.children = [];
}
this.messagesMap = new Map(parsed.messages.map((m) => [m.id, m]));
this.leafId = parsed.leafId;
this.rootId = parsed.rootId;
} catch (e) {
console.error('failed to load history:', e);
this.messagesMap.clear();
this.leafId = null;
this.rootId = null;
}
}
/**
* save persists the tree to localStorage.
*/
save() {
const messages = Array.from(this.messagesMap.values());
const data = {
messages,
leafId: this.leafId,
rootId: this.rootId,
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) {
console.error('failed to save history:', e);
}
}
/**
* clear wipes the map and localStorage.
*/
clear() {
this.messagesMap.clear();
this.leafId = null;
this.rootId = null;
localStorage.removeItem(STORAGE_KEY);
}
}

View File

@@ -0,0 +1,213 @@
/**
* EditModal opens a modal for editing a message (text, reasoning, or
* tool call). Reads from and writes to the conversation tree. Returns
* a Promise so the App knows when to re-render.
*/
import { extractText } from './conversation.js';
export class EditModal {
/**
* @param {Object} options
* @param {Document} options.document
* @param {import('./conversation.js').ConversationTree} options.conversation
*/
constructor({ document, conversation }) {
this.document = document;
this.conversation = conversation;
}
/**
* open creates and shows the edit modal for a message.
* @param {string} messageId
* @param {string} [field] - Optional: 'reasoning' or 'tool'
* @param {string} [toolCallId] - Optional, for tool edits
* @returns {Promise<boolean>} true if saved, false if cancelled
*/
open(messageId, field = null, toolCallId = null) {
const msg = this.conversation.get(messageId);
if (!msg) return Promise.resolve(false);
return new Promise((resolve) => {
const isToolEdit = field === 'tool';
const tplId = isToolEdit ? 'tpl-edit-tool-modal' : 'tpl-edit-modal';
const $tpl = this.document.getElementById(tplId);
if (!$tpl) return resolve(false);
const $modal = $tpl.content.cloneNode(true).firstElementChild;
const $overlay = this.document.createElement('div');
$overlay.className = 'modal-overlay';
$overlay.appendChild($modal);
if (!isToolEdit) {
$modal.querySelector('h2').textContent =
field === 'reasoning' ? 'Edit Reasoning' : 'Edit Message';
}
const saveBtn = $modal.querySelector('#edit-save');
const closeBtn = $modal.querySelector('.modal__close');
// Populate textarea(s).
if (isToolEdit && toolCallId) {
this._populateToolEdit(msg, toolCallId, $modal);
} else if (field === 'reasoning') {
const textarea = $modal.querySelector('#edit-textarea');
textarea.value = msg.reasoning_content || '';
} else {
const textarea = $modal.querySelector('#edit-textarea');
textarea.value = extractText(msg.content);
}
const close = () => {
$overlay.remove();
this.document.removeEventListener('keydown', handleEscape);
resolve(false);
};
const save = () => {
const saved = this._save(messageId, $modal, field, toolCallId, saveBtn);
if (saved) {
$overlay.remove();
this.document.removeEventListener('keydown', handleEscape);
resolve(true);
}
};
closeBtn.addEventListener('click', close);
saveBtn.addEventListener('click', save);
$overlay.addEventListener('click', (e) => {
if (e.target === $overlay) close();
});
const handleEscape = (e) => {
if (e.key === 'Escape') {
close();
}
};
this.document.addEventListener('keydown', handleEscape);
this.document.body.appendChild($overlay);
const firstTextarea = $modal.querySelector('textarea');
if (firstTextarea) firstTextarea.focus();
});
}
// --- Private ---
_populateToolEdit(msg, toolCallId, $modal) {
const toolCall = msg.tool_calls?.find((tc) => tc.id === toolCallId);
if (!toolCall) return;
const $callTextarea = $modal.querySelector('#edit-tool-call');
const $resultTextarea = $modal.querySelector('#edit-tool-result');
try {
const args = JSON.parse(toolCall.function?.arguments || '{}');
$callTextarea.value = JSON.stringify(
{ name: toolCall.function?.name, arguments: args },
null,
2,
);
} catch {
$callTextarea.value = JSON.stringify(
{
name: toolCall.function?.name,
arguments: toolCall.function?.arguments || '',
},
null,
2,
);
}
for (const [, m] of this.conversation.messagesMap) {
if (m.role === 'tool' && m.tool_call_id === toolCallId) {
$resultTextarea.value = extractText(m.content) || '';
break;
}
}
}
_save(messageId, $modal, field, toolCallId, saveBtn) {
const msg = this.conversation.get(messageId);
if (!msg) return false;
if (field === 'tool' && toolCallId) {
return this._saveToolEdit(msg, toolCallId, $modal, saveBtn);
}
if (field === 'reasoning') {
const textarea = $modal.querySelector('#edit-textarea');
msg.reasoning_content = textarea.value;
this.conversation.save();
return true;
}
// Regular message edit.
const textarea = $modal.querySelector('#edit-textarea');
const newText = textarea.value;
if (Array.isArray(msg.content)) {
const textPart = msg.content.find((p) => p.type === 'text');
if (textPart) {
textPart.text = newText;
} else {
msg.content.unshift({ type: 'text', text: newText });
}
} else {
msg.content = newText;
}
this.conversation.save();
return true;
}
_saveToolEdit(msg, toolCallId, $modal, saveBtn) {
const toolCall = msg.tool_calls?.find((tc) => tc.id === toolCallId);
if (!toolCall) return false;
const $callTextarea = $modal.querySelector('#edit-tool-call');
const $resultTextarea = $modal.querySelector('#edit-tool-result');
let parsedCall;
try {
parsedCall = JSON.parse($callTextarea.value);
} catch {
$callTextarea.classList.add('modal__textarea--error');
saveBtn.classList.add('modal__save--error');
setTimeout(() => {
saveBtn.classList.remove('modal__save--error');
}, 500);
return false;
}
if (parsedCall.name) {
toolCall.function.name = parsedCall.name;
}
if (parsedCall.arguments !== undefined) {
toolCall.function.arguments =
typeof parsedCall.arguments === 'string'
? parsedCall.arguments
: JSON.stringify(parsedCall.arguments);
}
for (const [, m] of this.conversation.messagesMap) {
if (m.role === 'tool' && m.tool_call_id === toolCallId) {
const newText = $resultTextarea.value;
if (Array.isArray(m.content)) {
const textPart = m.content.find((p) => p.type === 'text');
if (textPart) {
textPart.text = newText;
} else {
m.content.unshift({ type: 'text', text: newText });
}
} else {
m.content = newText;
}
break;
}
}
this.conversation.save();
return true;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,807 @@
/**
* 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.$scrollDown = document.getElementById('scroll-down');
this.$voice = document.getElementById('voice');
// Auto-scroll follow state. Following is opt-in, so it starts off; the
// user engages it via the scroll button.
this.isAutoScrollEnabled = false;
// Templates
this.$tplAssistantMessage = document.getElementById(
'tpl-assistant-message',
);
this.$tplAttachmentChip = document.getElementById('tpl-attachment-chip');
this.$tplImagePill = document.getElementById('tpl-image-pill');
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');
this.$tplSystemMessage = document.getElementById('tpl-system-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;
this._renderImagePills($msg, message);
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.innerHTML = this._renderMarkdown(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, requestId = '') {
const $msg =
this.$tplErrorMessage.content.cloneNode(true).firstElementChild;
const $content = $msg.querySelector('.message__content');
if (requestId) {
$content.textContent = `Request: ${requestId}\n${message}`;
$msg.dataset.copyText = `Request: ${requestId}\n${message}`;
} else {
$content.textContent = message;
$msg.dataset.copyText = message;
}
this.$chat.insertBefore($msg, this.$chatLoading);
this.scrollToBottom();
}
// --- Scroll ---
scrollToBottom() {
if (this.isAutoScrollEnabled) {
this.$chat.scrollTop = this.$chat.scrollHeight;
}
}
// 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;
}
// 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);
}
// 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).
handleChatScroll() {
const { scrollHeight, scrollTop, clientHeight } = this.$chat;
if (scrollHeight - scrollTop - clientHeight > 100) {
this.setAutoScroll(false);
}
}
// Set the flag and sync the icon so the two never drift.
setAutoScroll(enabled) {
this.isAutoScrollEnabled = enabled;
this.updateFollowIndicator();
}
clearChat() {
const $loading = this.$chatLoading;
this.$chat.innerHTML = '';
this.$chat.appendChild($loading);
}
// --- Private ---
/**
* _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;
}
_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;
this._renderImagePills($msg, message);
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.innerHTML = this._renderMarkdown(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();
}
/**
* 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);
}
_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'));
}
_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),
);
}
}

View File

@@ -0,0 +1,350 @@
/**
* Settings owns the settings modal DOM, model/voice selects, and system
* message. Fetches models and voices from the API, populates selects,
* and persists selections to localStorage.
*/
const MODELS_ENDPOINT = '/v1/models';
const MODEL_KEY = 'odidere_model';
const PROVIDER_KEY = 'odidere_provider';
const VOICE_KEY = 'odidere_voice';
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
const ICONS_URL = '/static/icons.svg';
export class Settings {
/**
* @param {Object} options
* @param {Document} options.document
* @param {string} options.ttsURL - kokoro-fastapi base URL for voice fetching
*/
constructor({ document, ttsURL }) {
this.document = document;
this.ttsURL = ttsURL;
this.modelContextSizes = new Map();
// DOM Elements
this.$model = document.getElementById('model');
this.$voice = document.getElementById('voice');
this.$settings = document.getElementById('settings');
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.$systemMessageInput = document.getElementById('system-message-input');
this.$saveSystemMessage = document.getElementById('save-system-message');
}
// --- Modal ---
open() {
this.$settingsOverlay.classList.add('open');
this._loadSystemMessage();
this.switchTab('model-voice');
this.$settingsClose.focus();
}
close() {
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;
});
}
// --- Model select ---
async fetchModels() {
try {
const res = await fetch(MODELS_ENDPOINT);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
this.populateModels(
data.providers,
data.default_provider,
data.default_model,
);
} catch (e) {
console.error('failed to fetch models:', e);
this._populateModelsFallback();
}
}
populateModels(providers, defaultProvider, defaultModel) {
this.$model.innerHTML = '';
this.modelContextSizes.clear();
const sortedProviders = Object.keys(providers).sort();
for (const provider of sortedProviders) {
const $optgroup = this.document.createElement('optgroup');
$optgroup.label = provider;
for (const m of providers[provider].sort((a, b) =>
a.model.localeCompare(b.model),
)) {
const $opt = this.document.createElement('option');
$opt.value = m.model;
$opt.dataset.provider = provider;
$opt.dataset.model = m.model;
if (m.context_size) {
this.modelContextSizes.set(m.model, m.context_size);
}
switch (m.status) {
case 'available':
$opt.textContent = m.model;
break;
case 'not_found':
$opt.textContent = `~~${m.model}~~ (not found)`;
$opt.disabled = true;
$opt.classList.add('model-unavailable-not-found');
break;
case 'provider_error':
$opt.textContent = `${m.model} (provider error)`;
$opt.disabled = true;
$opt.classList.add('model-unavailable-error');
break;
}
$optgroup.appendChild($opt);
}
this.$model.appendChild($optgroup);
}
this.loadModel(defaultProvider, defaultModel);
}
loadModel(defaultProvider, defaultModel) {
const storedProvider = localStorage.getItem(PROVIDER_KEY);
const storedModel = localStorage.getItem(MODEL_KEY);
let $opt;
if (storedProvider && storedModel) {
$opt = this.$model.querySelector(
`option[data-provider="${CSS.escape(storedProvider)}"][data-model="${CSS.escape(storedModel)}"]`,
);
}
if (!$opt && defaultProvider && defaultModel) {
$opt = this.$model.querySelector(
`option[data-provider="${CSS.escape(defaultProvider)}"][data-model="${CSS.escape(defaultModel)}"]`,
);
}
if (!$opt && this.$model.options.length > 0) {
for (const opt of this.$model.options) {
if (!opt.disabled) {
$opt = opt;
break;
}
}
}
if ($opt) {
this.$model.value = $opt.value;
}
}
getModel() {
const $opt = this.$model.options[this.$model.selectedIndex];
return {
provider: $opt?.dataset.provider,
model: $opt?.dataset.model,
};
}
getModelContextSize(model) {
return this.modelContextSizes.get(model) ?? null;
}
// --- Voice select ---
async fetchVoices() {
try {
const res = await fetch(`${this.ttsURL}/v1/audio/voices`);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
// Filter out legacy v0 voices.
const filtered = data.voices.filter((v) => {
const id = typeof v === 'string' ? v : v.id;
return !id.includes('_v0');
});
const voiceIds = filtered.map((v) => (typeof v === 'string' ? v : v.id));
this.populateVoiceSelect(voiceIds);
} catch (e) {
console.error('failed to fetch voices:', e);
this.$voice.innerHTML = '';
const $opt = this.document.createElement('option');
$opt.value = '';
$opt.disabled = true;
$opt.selected = true;
$opt.textContent = 'Failed to load';
this.$voice.appendChild($opt);
}
}
populateVoiceSelect(voices) {
const langLabels = {
af: '🇺🇸 American English (F)',
am: '🇺🇸 American English (M)',
bf: '🇬🇧 British English (F)',
bm: '🇬🇧 British English (M)',
jf: '🇯🇵 Japanese (F)',
jm: '🇯🇵 Japanese (M)',
zf: '🇨🇳 Mandarin Chinese (F)',
zm: '🇨🇳 Mandarin Chinese (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)',
pf: '🇧🇷 Brazilian Portuguese (F)',
pm: '🇧🇷 Brazilian Portuguese (M)',
kf: '🇰🇷 Korean (F)',
km: '🇰🇷 Korean (M)',
};
// Group voices by their two-character prefix.
const groups = {};
for (const voice of voices) {
const prefix = voice.substring(0, 2);
if (!groups[prefix]) groups[prefix] = [];
groups[prefix].push(voice);
}
this.$voice.innerHTML = '';
// Add "Auto" option.
const $auto = this.document.createElement('option');
$auto.value = '';
$auto.textContent = '🌐 Auto';
this.$voice.appendChild($auto);
const sortedPrefixes = Object.keys(groups).sort((a, b) => {
if (a[0] !== b[0]) return a[0].localeCompare(b[0]);
return a[1].localeCompare(b[1]);
});
for (const prefix of sortedPrefixes) {
const label = langLabels[prefix] || prefix.toUpperCase();
const $optgroup = this.document.createElement('optgroup');
$optgroup.label = label;
for (const voice of groups[prefix].sort()) {
const name = voice.substring(3).replace(/_/g, ' ');
const displayName = name.charAt(0).toUpperCase() + name.slice(1);
const $opt = this.document.createElement('option');
$opt.value = voice;
$opt.textContent = displayName;
$optgroup.appendChild($opt);
}
this.$voice.appendChild($optgroup);
}
this.loadVoice();
}
loadVoice() {
const stored = localStorage.getItem(VOICE_KEY);
if (stored === null) {
this.$voice.value = '';
return;
}
const escaped = stored === '' ? '' : CSS.escape(stored);
const exists =
stored === '' || this.$voice.querySelector(`option[value="${escaped}"]`);
if (!exists) {
this.$voice.value = '';
return;
}
this.$voice.value = stored;
}
getVoice() {
return this.$voice.value;
}
// --- System message ---
loadSystemMessage() {
const stored = localStorage.getItem(SYSTEM_MESSAGE_KEY);
this.$systemMessageInput.value = stored || '';
}
saveSystemMessage() {
const value = this.$systemMessageInput.value;
localStorage.setItem(SYSTEM_MESSAGE_KEY, 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);
}
// --- Model/voice change persistence ---
saveModelSelection() {
const $opt = this.$model.options[this.$model.selectedIndex];
localStorage.setItem(PROVIDER_KEY, $opt.dataset.provider);
localStorage.setItem(MODEL_KEY, $opt.dataset.model);
}
saveVoiceSelection() {
localStorage.setItem(VOICE_KEY, this.$voice.value);
}
// --- Private ---
_populateModelsFallback() {
this.$model.innerHTML = '';
const $opt = this.document.createElement('option');
$opt.value = '';
$opt.disabled = true;
$opt.selected = true;
$opt.textContent = 'Failed to load';
this.$model.appendChild($opt);
}
_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;
}
}

View File

@@ -0,0 +1,217 @@
/**
* STT records audio via a MediaRecorder, posts it to whisper-server,
* and returns the transcription. No concept of modes — the App
* decides what to do with the result.
*
* Recording lifecycle: acquire() requests microphone access (async,
* may prompt) and creates the MediaRecorder; start() begins capturing
* (synchronous); stop() resolves the audio blob. The stream and
* recorder are reused across recordings to avoid codec warmup delay.
* Before each acquire(), the track state is checked; if iOS/WebKit
* has killed the track due to TTS playback or an audio-session
* interruption, a fresh stream is obtained.
*/
/**
* VOICE_MAP maps whisper language names to default Kokoro voices.
* Used for auto-selecting a voice when the selector is set to "Auto".
*/
export const VOICE_MAP = {
chinese: 'zf_xiaobei',
english: 'af_heart',
french: 'ff_siwis',
hindi: 'hf_alpha',
italian: 'if_sara',
japanese: 'jf_alpha',
korean: 'kf_sarah',
portuguese: 'pf_dora',
spanish: 'ef_dora',
};
export class STT {
/**
* @param {Object} options
* @param {string} options.url - whisper-server base URL
*/
constructor({ url }) {
this.url = url;
this.mediaRecorder = null;
this.audioChunks = [];
this._ext = 'webm';
}
/**
* acquire requests microphone access and creates the MediaRecorder.
* If an existing recorder has a live track, it is reused to avoid
* codec warmup delay. If the track is dead (e.g., killed by iOS
* after TTS playback), a fresh stream and recorder are created.
* Must be called before start.
*/
async acquire() {
// Reuse if the existing track is still live.
const track = this.mediaRecorder?.stream?.getAudioTracks()[0];
if (track?.readyState === 'live') {
this.audioChunks = [];
return;
}
// Track is dead or doesn't exist — acquire fresh.
this._release();
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
const [newTrack] = stream.getAudioTracks();
if (newTrack?.readyState !== 'live') {
for (const t of stream.getTracks()) t.stop();
throw new Error('microphone unavailable');
}
let mimeType = null;
for (const candidate of [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4;codecs=mp4a.40.2',
'audio/mp4',
]) {
if (MediaRecorder.isTypeSupported?.(candidate)) {
mimeType = candidate;
break;
}
}
this._ext = mimeType?.includes('mp4') ? 'm4a' : 'webm';
this.mediaRecorder = mimeType
? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream);
this.audioChunks = [];
this.mediaRecorder.addEventListener('dataavailable', (event) => {
if (event.data.size > 0) {
this.audioChunks.push(event.data);
}
});
}
/**
* start begins recording. Requires acquire() to have completed.
*/
start() {
this.audioChunks = [];
this.mediaRecorder.start();
}
/**
* stop stops recording, transcribes the audio, and returns the result.
* The stream and recorder are kept alive for reuse.
* @returns {Promise<{text: string, detectedLanguage: string}>}
*/
async stop() {
const audioBlob = await this._stopRecording();
if (audioBlob.size === 0) {
return { text: '', detectedLanguage: '' };
}
return this._transcribe(audioBlob);
}
/** @returns {boolean} */
get isRecording() {
return this.mediaRecorder?.state === 'recording';
}
/**
* destroy stops tracks and releases the media stream. Called on page
* teardown.
*/
destroy() {
this._release();
}
/**
* _stopRecording stops the MediaRecorder and resolves with the
* assembled audio blob. Does not release the stream or recorder,
* so they can be reused. Returns an empty blob if there is no
* active recording, so it is safe to call before start has run
* (e.g., if the user released while acquire was still pending).
* @returns {Promise<Blob>}
* @private
*/
_stopRecording() {
return new Promise((resolve, reject) => {
const mr = this.mediaRecorder;
if (!mr || mr.state === 'inactive') {
resolve(new Blob([]));
return;
}
mr.addEventListener(
'stop',
() => {
const type = mr.mimeType;
const blob = new Blob(this.audioChunks, { type });
if (type?.includes('mp4') || type?.includes('m4a')) {
this._ext = 'm4a';
}
this.audioChunks = [];
resolve(blob);
},
{ once: true },
);
mr.addEventListener(
'error',
(event) => {
this.audioChunks = [];
reject(event.error);
},
{ once: true },
);
mr.stop();
});
}
/**
* _release stops all tracks and clears recorder state.
* @private
*/
_release() {
for (const track of this.mediaRecorder?.stream?.getTracks() ?? []) {
track.stop();
}
this.mediaRecorder = null;
this.audioChunks = [];
}
/**
* _transcribe sends an audio blob to whisper-server and returns the
* transcription result.
* @param {Blob} audioBlob
* @returns {Promise<{text: string, detectedLanguage: string}>}
* @private
*/
async _transcribe(audioBlob) {
if (!this.url) {
throw new Error('STT URL not configured');
}
const formData = new FormData();
formData.append('file', audioBlob, `audio.${this._ext}`);
formData.append('response_format', 'verbose_json');
const res = await fetch(`${this.url}/inference`, {
method: 'POST',
body: formData,
});
if (!res.ok) {
const errText = await res.text().catch(() => '');
throw new Error(`STT error ${res.status}: ${errText}`);
}
const data = await res.json();
const text = (data.text || '').trim();
const detectedLanguage = data.detected_language || data.language || '';
return { text, detectedLanguage };
}
}

View File

@@ -0,0 +1,212 @@
/**
* TTSPlayer synthesizes text via kokoro-fastapi, manages a playback
* queue, and plays audio. Extends EventTarget for playback state
* notifications.
*
* Events dispatched:
* 'playbackstart' { detail: { messageId } }
* 'playbackend' { detail: { messageId } }
* 'stopped' (no detail)
*/
export class TTSPlayer extends EventTarget {
/**
* @param {Object} options
* @param {string} options.url - kokoro-fastapi base URL
* @param {string} options.defaultVoice
*/
constructor({ url, defaultVoice }) {
super();
this.url = url;
this.defaultVoice = defaultVoice;
this.playQueue = [];
this._playing = false;
this._currentMessageId = null;
this.currentAudio = null;
this.currentAudioURL = null;
this._muted = false;
this._abortController = null;
}
/**
* enqueue adds a message to the TTS playback queue and starts
* playback if not already playing.
* @param {string} text
* @param {string} voice
* @param {string} messageId
*/
enqueue(text, voice, messageId) {
if (!text || !this.url) return;
this.playQueue.push({ text, voice, messageId });
if (!this._playing) {
this._processQueue();
}
}
/**
* stop stops current audio playback, clears the queue, and resets.
*/
stop() {
this._stopCurrentAudio();
if (this._abortController) {
this._abortController.abort();
this._abortController = null;
}
this.playQueue = [];
this._playing = false;
if (this.currentMessageId) {
this.dispatchEvent(
new CustomEvent('playbackend', {
detail: { messageId: this.currentMessageId },
}),
);
}
this._currentMessageId = null;
this.dispatchEvent(new CustomEvent('stopped'));
}
/**
* setMuted updates the mute state for current and future playback.
* @param {boolean} muted
*/
setMuted(muted) {
this._muted = muted;
if (this.currentAudio) {
this.currentAudio.muted = muted;
}
}
/** @returns {boolean} */
get isPlaying() {
return this._playing;
}
/** @returns {string|null} */
get currentMessageId() {
return this._currentMessageId;
}
// Private implementation
/**
* _processQueue pops the next item and synthesizes/plays it.
* @private
*/
async _processQueue() {
if (this.playQueue.length === 0) {
this._playing = false;
this._currentMessageId = null;
return;
}
this._playing = true;
const item = this.playQueue.shift();
this._currentMessageId = item.messageId;
this.dispatchEvent(
new CustomEvent('playbackstart', {
detail: { messageId: item.messageId },
}),
);
await this._synthesizeAndPlay(item.text, item.voice);
this.dispatchEvent(
new CustomEvent('playbackend', {
detail: { messageId: item.messageId },
}),
);
// Move to next item in queue.
this._processQueue();
}
/**
* _synthesizeAndPlay calls kokoro-fastapi to synthesize text and
* plays the resulting audio.
* @param {string} text
* @param {string} voice
* @private
*/
async _synthesizeAndPlay(text, voice) {
this._abortController = new AbortController();
const { signal } = this._abortController;
try {
this._stopCurrentAudio();
const res = await fetch(`${this.url}/v1/audio/speech`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'tts-1',
input: text,
voice: voice || this.defaultVoice,
response_format: 'wav',
}),
signal,
});
if (!res.ok) {
throw new Error(`TTS error ${res.status}`);
}
const audioBlob = await res.blob();
const audioURL = URL.createObjectURL(audioBlob);
this.currentAudioURL = audioURL;
this.currentAudio = new Audio(audioURL);
this.currentAudio.muted = this._muted;
await new Promise((resolve, reject) => {
this.currentAudio.addEventListener(
'ended',
() => {
this._cleanupAudio();
resolve();
},
{ once: true },
);
this.currentAudio.addEventListener(
'error',
(e) => {
this._cleanupAudio();
reject(e);
},
{ once: true },
);
this.currentAudio.play().catch(reject);
});
} catch (e) {
if (e.name === 'AbortError') return;
console.error('TTS synthesis failed:', e);
this._cleanupAudio();
} finally {
this._abortController = null;
}
}
/**
* _stopCurrentAudio stops and cleans up any playing audio.
* @private
*/
_stopCurrentAudio() {
if (this.currentAudio) {
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
}
this._cleanupAudio();
}
/**
* _cleanupAudio revokes the object URL and clears audio references.
* @private
*/
_cleanupAudio() {
if (this.currentAudioURL) {
URL.revokeObjectURL(this.currentAudioURL);
this.currentAudioURL = null;
}
this.currentAudio = null;
}
}

View File

@@ -0,0 +1,49 @@
/**
* WakeLock manages the screen wake lock to prevent the display from
* dimming during active use (recording or processing).
*/
export class WakeLock {
constructor() {
this.wakeLock = null;
}
/**
* acquire requests a screen wake lock. Idempotent.
*/
async acquire() {
if (this.wakeLock) return;
if (!('wakeLock' in navigator)) return;
try {
this.wakeLock = await navigator.wakeLock.request('screen');
} catch (e) {
console.debug('wake lock acquire failed:', e);
}
}
/**
* release releases the active wake lock. Idempotent.
*/
async release() {
if (!this.wakeLock) return;
try {
await this.wakeLock.release();
} catch (e) {
console.debug('wake lock release failed:', e);
}
this.wakeLock = null;
}
/**
* handleVisibilityChange reacquires the wake lock when the tab
* becomes visible, if the app is currently active.
* @param {boolean} isActive - Whether the app is recording or processing.
*/
async handleVisibilityChange(isActive) {
if (document.visibilityState !== 'visible') return;
if (!isActive) return;
await this.acquire();
}
}