Refactor UI

This commit is contained in:
dwrz
2026-07-04 12:44:59 +00:00
parent eeb7d6f504
commit 4a2ba2fa54
16 changed files with 3163 additions and 3142 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;
}
}

View File

@@ -128,4 +128,10 @@
<symbol id="edit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
</symbol>
<symbol id="content" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"/>
<path d="M7 11h10"/>
<path d="M7 15h6"/>
<path d="M7 7h8"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -127,104 +127,6 @@ body {
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* ==================== */
/* Collapsible */
/* ==================== */
.collapsible {
border-bottom: 1px dashed var(--color-border-light);
}
.collapsible:last-child {
border-bottom: none;
}
.collapsible__content {
padding: var(--s-2) var(--s-1);
}
.collapsible__summary::before {
content: "▶";
font-size: 0.625em;
color: var(--color-text-muted);
transition: transform 0.15s ease;
}
.collapsible[open] .collapsible__summary::before {
transform: rotate(90deg);
}
.collapsible__content > pre {
margin: 0;
font-family: var(--font-mono);
font-size: var(--s-1);
white-space: pre-wrap;
word-break: break-word;
}
.collapsible__label {
font-size: var(--s-1);
color: var(--color-text-muted);
}
.collapsible__pre {
margin: 0;
padding: var(--s-2);
font-family: var(--font-mono);
font-size: var(--s-1);
background: var(--color-bg);
border-radius: var(--radius);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
.collapsible__section {
margin-bottom: var(--s-2);
}
.collapsible__section:last-child {
margin-bottom: 0;
}
.collapsible__section-label {
font-size: var(--s-2);
font-weight: 500;
color: var(--color-text-muted);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.collapsible__summary {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-2) var(--s-1);
cursor: pointer;
user-select: none;
list-style: none;
}
.collapsible__summary::-webkit-details-marker {
display: none;
}
.collapsible__summary .icon {
width: var(--icon-size);
height: var(--icon-size);
color: var(--color-text-muted);
}
.collapsible--reasoning .collapsible__summary .icon {
color: var(--color-purple);
}
.collapsible--tool .collapsible__summary .icon {
color: var(--color-orange);
}
/* ==================== */
/* Compose */
/* ==================== */
@@ -1104,6 +1006,41 @@ body {
outline: none;
}
/* Collapsible sections within the tool-call edit modal only.
The textarea inside keeps its own border and focus styles so it looks
identical to the bare textarea in single-section modals. */
.modal__collapsible + .modal__collapsible {
margin-top: var(--s-1);
}
.modal__collapsible-summary {
display: flex;
align-items: center;
gap: var(--s-2);
padding: 0 0 var(--s-1) 0;
cursor: pointer;
user-select: none;
list-style: none;
font-size: var(--s-1);
font-weight: 500;
color: var(--color-text-muted);
}
.modal__collapsible-summary::-webkit-details-marker {
display: none;
}
.modal__collapsible-summary::before {
content: "▶";
font-size: 0.625em;
color: var(--color-text-muted);
transition: transform 0.15s ease;
}
.modal__collapsible[open] > .modal__collapsible-summary::before {
transform: rotate(90deg);
}
.modal__textarea:focus {
border-color: var(--color-primary);
}
@@ -1182,3 +1119,89 @@ body {
font-size: var(--s0);
}
}
/* ==================== */
/* Reasoning & Tool Bubbles */
/* ==================== */
.message--reasoning .message__icon {
color: var(--color-primary);
}
.message--reasoning .message__icon--reasoning {
color: var(--color-purple);
}
.message--tool .message__icon {
color: var(--color-primary);
}
.message--tool .message__icon--tool {
color: var(--color-orange);
}
.message__collapsible {
border-bottom: none;
}
.message__collapsible-summary {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-2) var(--s-1);
cursor: pointer;
user-select: none;
list-style: none;
}
.message__collapsible-summary::-webkit-details-marker {
display: none;
}
.message__collapsible-summary::before {
content: "▶";
font-size: 0.625em;
color: var(--color-text-muted);
transition: transform 0.15s ease;
}
.message__collapsible[open] > .message__collapsible-summary::before {
transform: rotate(90deg);
}
.message__collapsible-label {
font-size: var(--s-1);
color: var(--color-text-muted);
}
/* .message__content inherits white-space: pre-wrap for plain text messages
(to preserve newlines from LLM output). For tool messages, the content is
structured HTML with newlines between child elements; pre-wrap renders those
as visible blank lines, causing large vertical gaps. Reset to normal. */
.message--tool .message__content {
white-space: normal;
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.message__tool-section-label {
font-size: var(--s-2);
font-weight: 500;
color: var(--color-text-muted);
margin: 0 0 var(--s-2);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.message__tool-pre {
margin: 0;
padding: var(--s-2);
font-family: var(--font-mono);
font-size: var(--s-1);
background: var(--color-bg);
border-radius: var(--radius);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,685 @@
/**
* Renderer creates and updates message DOM elements from templates.
* Handles streaming bubbles, attachments, and play-button state.
* Takes the conversation tree as a dependency.
*/
import { extractText } from './conversation.js';
const ICONS_URL = '/static/icons.svg';
export class Renderer {
/**
* @param {Object} options
* @param {Document} options.document
* @param {import('./conversation.js').ConversationTree} options.conversation
*/
constructor({ document, conversation }) {
this.document = document;
this.conversation = conversation;
// DOM Elements
this.$chat = document.getElementById('chat');
this.$chatLoading = document.getElementById('chat-loading');
this.$attachments = document.getElementById('attachments');
this.$scroll = document.getElementById('scroll');
this.$voice = document.getElementById('voice');
// Auto-scroll: enabled by default, disabled when user scrolls up.
this.isAutoScrollEnabled = true;
this._lastScrollTop = 0;
// Templates
this.$tplAssistantMessage = document.getElementById(
'tpl-assistant-message',
);
this.$tplAttachmentChip = document.getElementById('tpl-attachment-chip');
this.$tplDebugRow = document.getElementById('tpl-debug-row');
this.$tplErrorMessage = document.getElementById('tpl-error-message');
this.$tplUserMessage = document.getElementById('tpl-user-message');
this.$tplReasoningMessage = document.getElementById(
'tpl-reasoning-message',
);
this.$tplToolMessage = document.getElementById('tpl-tool-message');
}
// --- Full / incremental render ---
/**
* renderMessages renders messages to the chat container.
* Messages already in the DOM (matched by data-id) are skipped.
* @param {Object[]} messages
* @param {Object} [meta]
*/
renderMessages(messages, meta = {}) {
const toolResultMap = new Map();
for (const msg of messages) {
if (msg.role === 'tool' && msg.tool_call_id) {
toolResultMap.set(msg.tool_call_id, msg);
}
}
for (const msg of messages) {
if (msg.role === 'user') {
if (msg.id && this.$chat.querySelector(`[data-id="${msg.id}"]`)) {
continue;
}
this._renderUserMessage(msg, msg.meta ?? meta);
continue;
}
if (msg.role !== 'assistant') continue;
const hasContent =
msg.content || msg.reasoning_content || msg.tool_calls?.length;
if (!hasContent) continue;
const assistantId = msg.id;
const bubbleMeta = msg.meta ?? meta;
if (msg.reasoning_content) {
const reasoningId = `${assistantId}/reasoning`;
if (!this.$chat.querySelector(`[data-id="${reasoningId}"]`)) {
this._renderReasoningMessage(msg, bubbleMeta);
}
}
const textContent = extractText(msg.content);
if (textContent) {
if (
!assistantId ||
!this.$chat.querySelector(`[data-id="${assistantId}"]`)
) {
this._renderAssistantMessage(msg, bubbleMeta);
}
}
if (msg.tool_calls?.length > 0) {
for (const toolCall of msg.tool_calls) {
const result = toolResultMap.get(toolCall.id);
const toolBubbleId = result?.id || toolCall.id;
if (!this.$chat.querySelector(`[data-id="${toolBubbleId}"]`)) {
this._renderToolMessage(msg, toolCall, result, bubbleMeta);
}
}
}
}
}
/**
* reRenderActivePath re-renders the entire active conversation path.
*/
reRenderActivePath() {
const $loading = this.$chatLoading;
this.$chat.innerHTML = '';
this.$chat.appendChild($loading);
this._renderTree(this.conversation.rootId);
this.scrollToBottom();
}
/**
* reRenderMessage replaces the DOM element(s) for a message.
* @param {Object} message
*/
reRenderMessage(message) {
if (message.role === 'user') {
const $old = this.$chat.querySelector(`[data-id="${message.id}"]`);
if (!$old) return;
const $msg =
this.$tplUserMessage.content.cloneNode(true).firstElementChild;
if (message.id) $msg.dataset.id = message.id;
const content = extractText(message.content);
$msg.querySelector('.message__content').textContent = content;
const $dl = $msg.querySelector('.message__debug-list');
if (message.meta?.language)
this._appendDebugRow($dl, 'Language', message.meta.language);
if (message.meta?.voice)
this._appendDebugRow($dl, 'Voice', message.meta.voice);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
$old.replaceWith($msg);
} else if (message.role === 'assistant') {
const $textBubble = this.$chat.querySelector(`[data-id="${message.id}"]`);
const $reasoningBubble = this.$chat.querySelector(
`[data-id="${message.id}/reasoning"]`,
);
const $toolBubbles = this.$chat.querySelectorAll(
`[data-parent-id="${message.id}"]`,
);
let $reference = $reasoningBubble || $textBubble;
if (!$reference && $toolBubbles.length > 0) {
$reference = $toolBubbles[0];
}
if (!$reference) return;
const $toRemove = [];
if ($reasoningBubble) $toRemove.push($reasoningBubble);
if ($textBubble) $toRemove.push($textBubble);
for (const $tool of $toolBubbles) {
$toRemove.push($tool);
}
const toolResultMap = new Map();
if (message.tool_calls?.length > 0) {
const toolCallIds = new Set(message.tool_calls.map((tc) => tc.id));
for (const result of this.conversation.messagesMap.values()) {
if (result.role === 'tool' && toolCallIds.has(result.tool_call_id)) {
toolResultMap.set(result.tool_call_id, result);
}
}
}
const bubbleMeta = message.meta ?? {};
for (const $el of $toRemove) {
$el.remove();
}
if (message.reasoning_content) {
this._renderReasoningMessage(message, bubbleMeta);
}
const textContent = extractText(message.content);
if (textContent) {
this._renderAssistantMessage(message, bubbleMeta);
}
if (message.tool_calls?.length > 0) {
for (const toolCall of message.tool_calls) {
const result = toolResultMap.get(toolCall.id);
this._renderToolMessage(message, toolCall, result, bubbleMeta);
}
}
}
}
// --- Streaming bubbles ---
/**
* createStreamingReasoning creates a streaming reasoning bubble.
* @param {string} assistantId
* @param {Object} [meta]
* @returns {HTMLElement}
*/
createStreamingReasoning(assistantId, meta = null) {
const $msg =
this.$tplReasoningMessage.content.cloneNode(true).firstElementChild;
$msg.dataset.id = `${assistantId}/reasoning`;
$msg.dataset.parentId = assistantId;
$msg.dataset.field = 'reasoning';
$msg.classList.add('message--streaming');
$msg.querySelector('.message__collapsible').open = true;
const $dl = $msg.querySelector('.message__debug-list');
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
// Remove action buttons during streaming.
$msg.querySelector('[data-action="delete"]').remove();
$msg.querySelector('[data-action="edit"]').remove();
$msg.querySelector('[data-action="copy"]').remove();
this.$chat.insertBefore($msg, this.$chatLoading);
this.scrollToBottom();
return $msg;
}
/**
* createStreamingText creates a streaming text content bubble.
* @param {Object} message
* @param {Object} [meta]
* @returns {HTMLElement}
*/
createStreamingText(message, meta = null) {
const $msg =
this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
if (message.id) $msg.dataset.id = message.id;
$msg.classList.add('message--streaming');
const $dl = $msg.querySelector('.message__debug-list');
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
// Remove action buttons during streaming.
$msg.querySelector('[data-action="play"]').remove();
$msg.querySelector('[data-action="copy"]').remove();
$msg.querySelector('[data-action="delete"]').remove();
$msg.querySelector('[data-action="edit"]').remove();
this.$chat.insertBefore($msg, this.$chatLoading);
this.scrollToBottom();
return $msg;
}
/**
* appendDelta appends text to a streaming assistant message.
* @param {HTMLElement} $msg
* @param {string} delta
*/
appendDelta($msg, delta) {
const $content = $msg.querySelector('.message__content');
$content.textContent += delta;
this.scrollToBottom();
}
/**
* appendReasoningDelta appends reasoning text to a streaming bubble.
* @param {HTMLElement} $msg
* @param {string} delta
*/
appendReasoningDelta($msg, delta) {
const $content = $msg.querySelector('.message__content');
$content.textContent += delta;
this.scrollToBottom();
}
/**
* finalizeStreamingReasoning replaces a streaming reasoning bubble
* with a fully bound one.
* @param {HTMLElement} $streaming
* @param {Object} message
* @param {Object} [meta]
* @returns {HTMLElement|null}
*/
finalizeStreamingReasoning($streaming, message, meta = null) {
if (!$streaming) return null;
const $msg =
this.$tplReasoningMessage.content.cloneNode(true).firstElementChild;
$msg.dataset.id = `${message.id}/reasoning`;
$msg.dataset.parentId = message.id;
$msg.dataset.field = 'reasoning';
$msg.dataset.messageId = message.id;
$msg.querySelector('.message__content').textContent =
message.reasoning_content || '';
$msg.querySelector('.message__collapsible').open = false;
const $dl = $msg.querySelector('.message__debug-list');
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
$msg.dataset.copyText = message.reasoning_content || '';
$streaming.replaceWith($msg);
this.scrollToBottom();
return $msg;
}
/**
* finalizeStreamingText replaces a streaming text bubble with a
* fully bound one.
* @param {HTMLElement} $streaming
* @param {Object} message
* @param {Object} [meta]
* @returns {HTMLElement|null}
*/
finalizeStreamingText($streaming, message, meta = null) {
if (!$streaming) return null;
const $msg =
this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
if (message.id) $msg.dataset.id = message.id;
const $content = $msg.querySelector('.message__content');
const text = extractText(message.content);
if (text) {
$content.textContent = text;
} else {
$content.remove();
}
const $dl = $msg.querySelector('.message__debug-list');
this._populateDebugPanel($dl, meta);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
const voice = meta?.voice || this.$voice.value || '';
$msg.dataset.voice = voice;
$msg.dataset.copyText = text || '';
$streaming.replaceWith($msg);
this.scrollToBottom();
return $msg;
}
// --- Attachments ---
/**
* renderAttachments renders the attachment chips in the footer.
* @param {File[]} attachments
* @param {number} attachments.length
* @param {Function} onRemove - Called with the index to remove.
*/
renderAttachments(attachments, onRemove) {
this.$attachments.innerHTML = '';
for (let i = 0; i < attachments.length; i++) {
const file = attachments[i];
const $chip =
this.$tplAttachmentChip.content.cloneNode(true).firstElementChild;
const $name = $chip.querySelector('.compose__attachment-name');
$name.textContent = file.name;
$name.title = file.name;
const $remove = $chip.querySelector('.compose__attachment-remove');
$remove.setAttribute('aria-label', `Remove ${file.name}`);
$remove.addEventListener('click', () => onRemove(i));
this.$attachments.appendChild($chip);
}
}
// --- Play button ---
/**
* setPlayButton updates the play/stop button icon for a message.
* @param {string} messageId
* @param {boolean} playing
*/
setPlayButton(messageId, playing) {
const $msg = this.$chat.querySelector(`[data-id="${messageId}"]`);
if (!$msg) return;
const $btn = $msg.querySelector('[data-action="play"]');
if (!$btn) return;
if (playing) {
$btn.classList.add('playing');
$btn.innerHTML = `<svg class="icon"><use href="${ICONS_URL}#stop"></use></svg>`;
$btn.setAttribute('aria-label', 'Stop');
} else {
$btn.classList.remove('playing');
$btn.innerHTML = `<svg class="icon"><use href="${ICONS_URL}#volume"></use></svg>`;
$btn.setAttribute('aria-label', 'Play');
}
}
// --- Error ---
/**
* renderError renders an error message in the chat.
* @param {string} message
*/
renderError(message) {
const $msg =
this.$tplErrorMessage.content.cloneNode(true).firstElementChild;
$msg.querySelector('.message__content').textContent = message;
this.$chat.insertBefore($msg, this.$chatLoading);
this.scrollToBottom();
}
// --- Scroll ---
scrollToBottom() {
if (this.isAutoScrollEnabled) {
this.$chat.scrollTop = this.$chat.scrollHeight;
}
}
isAtBottom() {
const { scrollTop, scrollHeight, clientHeight } = this.$chat;
return scrollHeight - clientHeight - scrollTop < 10;
}
updateScrollIcon() {
const atBottom = this.isAtBottom();
this.$scroll.classList.toggle('scrolled-up', !atBottom);
this.$scroll.setAttribute(
'aria-label',
atBottom ? 'Scroll to top' : 'Scroll to bottom',
);
}
handleChatScroll() {
const { scrollTop, scrollHeight, clientHeight } = this.$chat;
const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
const isAtBottom = distanceFromBottom < 10;
if (scrollTop < this._lastScrollTop && !isAtBottom) {
this.isAutoScrollEnabled = false;
} else if (isAtBottom) {
this.isAutoScrollEnabled = true;
}
this._lastScrollTop = scrollTop;
}
setAutoScroll(enabled) {
this.isAutoScrollEnabled = enabled;
}
clearChat() {
const $loading = this.$chatLoading;
this.$chat.innerHTML = '';
this.$chat.appendChild($loading);
}
// --- Private ---
_renderTree(messageId) {
const messages = [];
this._collectMessages(messageId, messages);
this.renderMessages(messages);
}
_collectMessages(messageId, messages) {
if (!messageId) return;
const msg = this.conversation.get(messageId);
if (!msg) return;
messages.push(msg);
if (msg.children) {
for (const childId of msg.children) {
this._collectMessages(childId, messages);
}
}
}
_renderUserMessage(message, meta = null) {
const content = (() => {
if (typeof message.content === 'string') {
return message.content;
}
if (Array.isArray(message.content)) {
return message.content
.filter((p) => p.type === 'text')
.map((p) => p.text)
.join('\n');
}
return '';
})();
const $msg = this.$tplUserMessage.content.cloneNode(true).firstElementChild;
if (message.id) $msg.dataset.id = message.id;
$msg.querySelector('.message__content').textContent = content;
$msg.dataset.copyText = content;
const $dl = $msg.querySelector('.message__debug-list');
if (meta?.language) this._appendDebugRow($dl, 'Language', meta.language);
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
this.$chat.insertBefore($msg, this.$chatLoading);
this.scrollToBottom();
}
_renderAssistantMessage(message, meta = null) {
const $msg =
this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
if (message.id) $msg.dataset.id = message.id;
const $content = $msg.querySelector('.message__content');
const text = extractText(message.content);
if (text) {
$content.textContent = text;
} else {
$content.remove();
}
const $dl = $msg.querySelector('.message__debug-list');
this._populateDebugPanel($dl, meta);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
const voice = meta?.voice || this.$voice.value || '';
$msg.dataset.voice = voice;
$msg.dataset.copyText = text || '';
this.$chat.insertBefore($msg, this.$chatLoading);
this.scrollToBottom();
}
_renderReasoningMessage(message, meta = null) {
const $msg =
this.$tplReasoningMessage.content.cloneNode(true).firstElementChild;
$msg.dataset.id = `${message.id}/reasoning`;
$msg.dataset.parentId = message.id;
$msg.dataset.field = 'reasoning';
$msg.dataset.messageId = message.id;
$msg.querySelector('.message__content').textContent =
message.reasoning_content || '';
$msg.dataset.copyText = message.reasoning_content || '';
const $dl = $msg.querySelector('.message__debug-list');
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
this.$chat.insertBefore($msg, this.$chatLoading);
this.scrollToBottom();
}
_renderToolMessage(assistantMessage, toolCall, result = null, meta = null) {
const $msg = this.$tplToolMessage.content.cloneNode(true).firstElementChild;
const toolBubbleId = result?.id || toolCall.id;
$msg.dataset.id = toolBubbleId;
$msg.dataset.parentId = assistantMessage.id;
$msg.dataset.field = 'tool';
$msg.dataset.messageId = assistantMessage.id;
$msg.dataset.toolCallId = toolCall.id;
$msg.querySelector('.message__collapsible-label').textContent =
toolCall.function?.name || 'Tool Call';
const $args = $msg.querySelector('[data-args]');
try {
const args = JSON.parse(toolCall.function?.arguments || '{}');
$args.textContent = JSON.stringify(args, null, 2);
} catch {
$args.textContent = toolCall.function?.arguments || '';
}
if (result) {
const $outputSection = $msg.querySelector(
'.message__tool-section--output',
);
$outputSection.hidden = false;
const $output = $msg.querySelector('[data-output]');
try {
const output = JSON.parse(extractText(result.content) || '{}');
$output.textContent = JSON.stringify(output, null, 2);
} catch {
$output.textContent = extractText(result.content) || '';
}
}
// Set copy text.
const copyParts = [];
const name = toolCall.function?.name || 'unknown';
const args = toolCall.function?.arguments || '{}';
copyParts.push(`${name}(${args})`);
if (result) {
copyParts.push(extractText(result.content) || '');
}
$msg.dataset.copyText = copyParts.join('\n\n');
const $dl = $msg.querySelector('.message__debug-list');
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
this.$chat.insertBefore($msg, this.$chatLoading);
this.scrollToBottom();
}
_populateDebugPanel($dl, meta) {
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
if (meta?.usage) {
const u = meta.usage;
this._appendDebugRow($dl, 'Prompt tokens', String(u.prompt_tokens));
this._appendDebugRow(
$dl,
'Completion tokens',
String(u.completion_tokens),
);
this._appendDebugRow($dl, 'Total tokens', String(u.total_tokens));
if (u.completion_tokens_details?.reasoning_tokens > 0)
this._appendDebugRow(
$dl,
'Reasoning tokens',
String(u.completion_tokens_details.reasoning_tokens),
);
if (u.prompt_tokens_details?.cached_tokens > 0)
this._appendDebugRow(
$dl,
'Cached tokens',
String(u.prompt_tokens_details.cached_tokens),
);
}
if (meta?.timings) {
const t = meta.timings;
if (t.prompt_ms > 0)
this._appendDebugRow(
$dl,
'Prompt eval',
`${t.prompt_ms.toFixed(1)} ms (${t.prompt_per_second.toFixed(0)} tok/s)`,
);
if (t.predicted_ms > 0)
this._appendDebugRow(
$dl,
'Predicted eval',
`${t.predicted_ms.toFixed(1)} ms (${t.predicted_per_second.toFixed(0)} tok/s)`,
);
}
}
_appendDebugRow($dl, label, value) {
const $row = this.$tplDebugRow.content.cloneNode(true);
$row.querySelector('dt').textContent = label;
$row.querySelector('dd').textContent = value;
$dl.appendChild($row);
}
_icon(name) {
const svg = this.document.createElementNS(
'http://www.w3.org/2000/svg',
'svg',
);
svg.setAttribute('class', 'icon');
const use = this.document.createElementNS(
'http://www.w3.org/2000/svg',
'use',
);
use.setAttribute('href', `${ICONS_URL}#${name}`);
svg.appendChild(use);
return svg;
}
copyToClipboard($button, text) {
navigator.clipboard.writeText(text).then(
() => {
$button.replaceChildren(this._icon('check'));
$button.classList.add('message__action-btn--success');
setTimeout(() => {
$button.replaceChildren(this._icon('copy'));
$button.classList.remove('message__action-btn--success');
}, 1500);
},
(e) => console.error('failed to copy:', e),
);
}
}

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,207 @@
/**
* 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 a fresh MediaRecorder; start() begins
* capturing (synchronous); stop() resolves the audio blob and
* releases the stream. A fresh stream is acquired per recording and
* torn down on every stop() so iOS/WebKit never reuses a capture
* track that TTS playback or an audio-session interruption has
* quietly killed.
*/
/**
* 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 a fresh
* MediaRecorder. Always starts clean so iOS never reuses a dead
* capture track. Must be called before start.
*/
async acquire() {
this._release();
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
const [track] = stream.getAudioTracks();
if (track?.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.
* @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, releasing the stream. 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') {
this._release();
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._release();
resolve(blob);
},
{ once: true },
);
mr.addEventListener(
'error',
(event) => {
this._release();
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,200 @@
/**
* 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;
}
/**
* 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();
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) {
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',
}),
});
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) {
console.error('TTS synthesis failed:', e);
this._cleanupAudio();
}
}
/**
* _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();
}
}