Refactor UI
This commit is contained in:
273
internal/service/static/conversation.js
Normal file
273
internal/service/static/conversation.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
213
internal/service/static/edit.js
Normal file
213
internal/service/static/edit.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
<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"/>
|
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
|
||||||
</symbol>
|
</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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 8.0 KiB |
@@ -127,104 +127,6 @@ body {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.8s linear infinite;
|
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 */
|
/* Compose */
|
||||||
/* ==================== */
|
/* ==================== */
|
||||||
@@ -1104,6 +1006,41 @@ body {
|
|||||||
outline: none;
|
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 {
|
.modal__textarea:focus {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
@@ -1182,3 +1119,89 @@ body {
|
|||||||
font-size: var(--s0);
|
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
685
internal/service/static/render.js
Normal file
685
internal/service/static/render.js
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
/**
|
||||||
|
* Renderer creates and updates message DOM elements from templates.
|
||||||
|
* Handles streaming bubbles, attachments, and play-button state.
|
||||||
|
* Takes the conversation tree as a dependency.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { extractText } from './conversation.js';
|
||||||
|
|
||||||
|
const ICONS_URL = '/static/icons.svg';
|
||||||
|
|
||||||
|
export class Renderer {
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {Document} options.document
|
||||||
|
* @param {import('./conversation.js').ConversationTree} options.conversation
|
||||||
|
*/
|
||||||
|
constructor({ document, conversation }) {
|
||||||
|
this.document = document;
|
||||||
|
this.conversation = conversation;
|
||||||
|
|
||||||
|
// DOM Elements
|
||||||
|
this.$chat = document.getElementById('chat');
|
||||||
|
this.$chatLoading = document.getElementById('chat-loading');
|
||||||
|
this.$attachments = document.getElementById('attachments');
|
||||||
|
this.$scroll = document.getElementById('scroll');
|
||||||
|
this.$voice = document.getElementById('voice');
|
||||||
|
|
||||||
|
// Auto-scroll: enabled by default, disabled when user scrolls up.
|
||||||
|
this.isAutoScrollEnabled = true;
|
||||||
|
this._lastScrollTop = 0;
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
this.$tplAssistantMessage = document.getElementById(
|
||||||
|
'tpl-assistant-message',
|
||||||
|
);
|
||||||
|
this.$tplAttachmentChip = document.getElementById('tpl-attachment-chip');
|
||||||
|
this.$tplDebugRow = document.getElementById('tpl-debug-row');
|
||||||
|
this.$tplErrorMessage = document.getElementById('tpl-error-message');
|
||||||
|
this.$tplUserMessage = document.getElementById('tpl-user-message');
|
||||||
|
this.$tplReasoningMessage = document.getElementById(
|
||||||
|
'tpl-reasoning-message',
|
||||||
|
);
|
||||||
|
this.$tplToolMessage = document.getElementById('tpl-tool-message');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Full / incremental render ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* renderMessages renders messages to the chat container.
|
||||||
|
* Messages already in the DOM (matched by data-id) are skipped.
|
||||||
|
* @param {Object[]} messages
|
||||||
|
* @param {Object} [meta]
|
||||||
|
*/
|
||||||
|
renderMessages(messages, meta = {}) {
|
||||||
|
const toolResultMap = new Map();
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.role === 'tool' && msg.tool_call_id) {
|
||||||
|
toolResultMap.set(msg.tool_call_id, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.role === 'user') {
|
||||||
|
if (msg.id && this.$chat.querySelector(`[data-id="${msg.id}"]`)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this._renderUserMessage(msg, msg.meta ?? meta);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (msg.role !== 'assistant') continue;
|
||||||
|
|
||||||
|
const hasContent =
|
||||||
|
msg.content || msg.reasoning_content || msg.tool_calls?.length;
|
||||||
|
if (!hasContent) continue;
|
||||||
|
|
||||||
|
const assistantId = msg.id;
|
||||||
|
const bubbleMeta = msg.meta ?? meta;
|
||||||
|
|
||||||
|
if (msg.reasoning_content) {
|
||||||
|
const reasoningId = `${assistantId}/reasoning`;
|
||||||
|
if (!this.$chat.querySelector(`[data-id="${reasoningId}"]`)) {
|
||||||
|
this._renderReasoningMessage(msg, bubbleMeta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const textContent = extractText(msg.content);
|
||||||
|
if (textContent) {
|
||||||
|
if (
|
||||||
|
!assistantId ||
|
||||||
|
!this.$chat.querySelector(`[data-id="${assistantId}"]`)
|
||||||
|
) {
|
||||||
|
this._renderAssistantMessage(msg, bubbleMeta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.tool_calls?.length > 0) {
|
||||||
|
for (const toolCall of msg.tool_calls) {
|
||||||
|
const result = toolResultMap.get(toolCall.id);
|
||||||
|
const toolBubbleId = result?.id || toolCall.id;
|
||||||
|
if (!this.$chat.querySelector(`[data-id="${toolBubbleId}"]`)) {
|
||||||
|
this._renderToolMessage(msg, toolCall, result, bubbleMeta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reRenderActivePath re-renders the entire active conversation path.
|
||||||
|
*/
|
||||||
|
reRenderActivePath() {
|
||||||
|
const $loading = this.$chatLoading;
|
||||||
|
this.$chat.innerHTML = '';
|
||||||
|
this.$chat.appendChild($loading);
|
||||||
|
this._renderTree(this.conversation.rootId);
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reRenderMessage replaces the DOM element(s) for a message.
|
||||||
|
* @param {Object} message
|
||||||
|
*/
|
||||||
|
reRenderMessage(message) {
|
||||||
|
if (message.role === 'user') {
|
||||||
|
const $old = this.$chat.querySelector(`[data-id="${message.id}"]`);
|
||||||
|
if (!$old) return;
|
||||||
|
const $msg =
|
||||||
|
this.$tplUserMessage.content.cloneNode(true).firstElementChild;
|
||||||
|
if (message.id) $msg.dataset.id = message.id;
|
||||||
|
|
||||||
|
const content = extractText(message.content);
|
||||||
|
$msg.querySelector('.message__content').textContent = content;
|
||||||
|
|
||||||
|
const $dl = $msg.querySelector('.message__debug-list');
|
||||||
|
if (message.meta?.language)
|
||||||
|
this._appendDebugRow($dl, 'Language', message.meta.language);
|
||||||
|
if (message.meta?.voice)
|
||||||
|
this._appendDebugRow($dl, 'Voice', message.meta.voice);
|
||||||
|
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
|
||||||
|
|
||||||
|
$old.replaceWith($msg);
|
||||||
|
} else if (message.role === 'assistant') {
|
||||||
|
const $textBubble = this.$chat.querySelector(`[data-id="${message.id}"]`);
|
||||||
|
const $reasoningBubble = this.$chat.querySelector(
|
||||||
|
`[data-id="${message.id}/reasoning"]`,
|
||||||
|
);
|
||||||
|
const $toolBubbles = this.$chat.querySelectorAll(
|
||||||
|
`[data-parent-id="${message.id}"]`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let $reference = $reasoningBubble || $textBubble;
|
||||||
|
if (!$reference && $toolBubbles.length > 0) {
|
||||||
|
$reference = $toolBubbles[0];
|
||||||
|
}
|
||||||
|
if (!$reference) return;
|
||||||
|
|
||||||
|
const $toRemove = [];
|
||||||
|
if ($reasoningBubble) $toRemove.push($reasoningBubble);
|
||||||
|
if ($textBubble) $toRemove.push($textBubble);
|
||||||
|
for (const $tool of $toolBubbles) {
|
||||||
|
$toRemove.push($tool);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolResultMap = new Map();
|
||||||
|
if (message.tool_calls?.length > 0) {
|
||||||
|
const toolCallIds = new Set(message.tool_calls.map((tc) => tc.id));
|
||||||
|
for (const result of this.conversation.messagesMap.values()) {
|
||||||
|
if (result.role === 'tool' && toolCallIds.has(result.tool_call_id)) {
|
||||||
|
toolResultMap.set(result.tool_call_id, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bubbleMeta = message.meta ?? {};
|
||||||
|
|
||||||
|
for (const $el of $toRemove) {
|
||||||
|
$el.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.reasoning_content) {
|
||||||
|
this._renderReasoningMessage(message, bubbleMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textContent = extractText(message.content);
|
||||||
|
if (textContent) {
|
||||||
|
this._renderAssistantMessage(message, bubbleMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.tool_calls?.length > 0) {
|
||||||
|
for (const toolCall of message.tool_calls) {
|
||||||
|
const result = toolResultMap.get(toolCall.id);
|
||||||
|
this._renderToolMessage(message, toolCall, result, bubbleMeta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Streaming bubbles ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createStreamingReasoning creates a streaming reasoning bubble.
|
||||||
|
* @param {string} assistantId
|
||||||
|
* @param {Object} [meta]
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
createStreamingReasoning(assistantId, meta = null) {
|
||||||
|
const $msg =
|
||||||
|
this.$tplReasoningMessage.content.cloneNode(true).firstElementChild;
|
||||||
|
$msg.dataset.id = `${assistantId}/reasoning`;
|
||||||
|
$msg.dataset.parentId = assistantId;
|
||||||
|
$msg.dataset.field = 'reasoning';
|
||||||
|
$msg.classList.add('message--streaming');
|
||||||
|
|
||||||
|
$msg.querySelector('.message__collapsible').open = true;
|
||||||
|
|
||||||
|
const $dl = $msg.querySelector('.message__debug-list');
|
||||||
|
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
|
||||||
|
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
|
||||||
|
|
||||||
|
// Remove action buttons during streaming.
|
||||||
|
$msg.querySelector('[data-action="delete"]').remove();
|
||||||
|
$msg.querySelector('[data-action="edit"]').remove();
|
||||||
|
$msg.querySelector('[data-action="copy"]').remove();
|
||||||
|
|
||||||
|
this.$chat.insertBefore($msg, this.$chatLoading);
|
||||||
|
this.scrollToBottom();
|
||||||
|
return $msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createStreamingText creates a streaming text content bubble.
|
||||||
|
* @param {Object} message
|
||||||
|
* @param {Object} [meta]
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
createStreamingText(message, meta = null) {
|
||||||
|
const $msg =
|
||||||
|
this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
|
||||||
|
if (message.id) $msg.dataset.id = message.id;
|
||||||
|
$msg.classList.add('message--streaming');
|
||||||
|
|
||||||
|
const $dl = $msg.querySelector('.message__debug-list');
|
||||||
|
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
|
||||||
|
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
|
||||||
|
|
||||||
|
// Remove action buttons during streaming.
|
||||||
|
$msg.querySelector('[data-action="play"]').remove();
|
||||||
|
$msg.querySelector('[data-action="copy"]').remove();
|
||||||
|
$msg.querySelector('[data-action="delete"]').remove();
|
||||||
|
$msg.querySelector('[data-action="edit"]').remove();
|
||||||
|
|
||||||
|
this.$chat.insertBefore($msg, this.$chatLoading);
|
||||||
|
this.scrollToBottom();
|
||||||
|
return $msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* appendDelta appends text to a streaming assistant message.
|
||||||
|
* @param {HTMLElement} $msg
|
||||||
|
* @param {string} delta
|
||||||
|
*/
|
||||||
|
appendDelta($msg, delta) {
|
||||||
|
const $content = $msg.querySelector('.message__content');
|
||||||
|
$content.textContent += delta;
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* appendReasoningDelta appends reasoning text to a streaming bubble.
|
||||||
|
* @param {HTMLElement} $msg
|
||||||
|
* @param {string} delta
|
||||||
|
*/
|
||||||
|
appendReasoningDelta($msg, delta) {
|
||||||
|
const $content = $msg.querySelector('.message__content');
|
||||||
|
$content.textContent += delta;
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* finalizeStreamingReasoning replaces a streaming reasoning bubble
|
||||||
|
* with a fully bound one.
|
||||||
|
* @param {HTMLElement} $streaming
|
||||||
|
* @param {Object} message
|
||||||
|
* @param {Object} [meta]
|
||||||
|
* @returns {HTMLElement|null}
|
||||||
|
*/
|
||||||
|
finalizeStreamingReasoning($streaming, message, meta = null) {
|
||||||
|
if (!$streaming) return null;
|
||||||
|
|
||||||
|
const $msg =
|
||||||
|
this.$tplReasoningMessage.content.cloneNode(true).firstElementChild;
|
||||||
|
$msg.dataset.id = `${message.id}/reasoning`;
|
||||||
|
$msg.dataset.parentId = message.id;
|
||||||
|
$msg.dataset.field = 'reasoning';
|
||||||
|
$msg.dataset.messageId = message.id;
|
||||||
|
|
||||||
|
$msg.querySelector('.message__content').textContent =
|
||||||
|
message.reasoning_content || '';
|
||||||
|
$msg.querySelector('.message__collapsible').open = false;
|
||||||
|
|
||||||
|
const $dl = $msg.querySelector('.message__debug-list');
|
||||||
|
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
|
||||||
|
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
|
||||||
|
|
||||||
|
$msg.dataset.copyText = message.reasoning_content || '';
|
||||||
|
|
||||||
|
$streaming.replaceWith($msg);
|
||||||
|
this.scrollToBottom();
|
||||||
|
return $msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* finalizeStreamingText replaces a streaming text bubble with a
|
||||||
|
* fully bound one.
|
||||||
|
* @param {HTMLElement} $streaming
|
||||||
|
* @param {Object} message
|
||||||
|
* @param {Object} [meta]
|
||||||
|
* @returns {HTMLElement|null}
|
||||||
|
*/
|
||||||
|
finalizeStreamingText($streaming, message, meta = null) {
|
||||||
|
if (!$streaming) return null;
|
||||||
|
|
||||||
|
const $msg =
|
||||||
|
this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
|
||||||
|
if (message.id) $msg.dataset.id = message.id;
|
||||||
|
|
||||||
|
const $content = $msg.querySelector('.message__content');
|
||||||
|
const text = extractText(message.content);
|
||||||
|
if (text) {
|
||||||
|
$content.textContent = text;
|
||||||
|
} else {
|
||||||
|
$content.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const $dl = $msg.querySelector('.message__debug-list');
|
||||||
|
this._populateDebugPanel($dl, meta);
|
||||||
|
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
|
||||||
|
|
||||||
|
const voice = meta?.voice || this.$voice.value || '';
|
||||||
|
$msg.dataset.voice = voice;
|
||||||
|
$msg.dataset.copyText = text || '';
|
||||||
|
|
||||||
|
$streaming.replaceWith($msg);
|
||||||
|
this.scrollToBottom();
|
||||||
|
return $msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Attachments ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* renderAttachments renders the attachment chips in the footer.
|
||||||
|
* @param {File[]} attachments
|
||||||
|
* @param {number} attachments.length
|
||||||
|
* @param {Function} onRemove - Called with the index to remove.
|
||||||
|
*/
|
||||||
|
renderAttachments(attachments, onRemove) {
|
||||||
|
this.$attachments.innerHTML = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < attachments.length; i++) {
|
||||||
|
const file = attachments[i];
|
||||||
|
const $chip =
|
||||||
|
this.$tplAttachmentChip.content.cloneNode(true).firstElementChild;
|
||||||
|
|
||||||
|
const $name = $chip.querySelector('.compose__attachment-name');
|
||||||
|
$name.textContent = file.name;
|
||||||
|
$name.title = file.name;
|
||||||
|
|
||||||
|
const $remove = $chip.querySelector('.compose__attachment-remove');
|
||||||
|
$remove.setAttribute('aria-label', `Remove ${file.name}`);
|
||||||
|
$remove.addEventListener('click', () => onRemove(i));
|
||||||
|
|
||||||
|
this.$attachments.appendChild($chip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Play button ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* setPlayButton updates the play/stop button icon for a message.
|
||||||
|
* @param {string} messageId
|
||||||
|
* @param {boolean} playing
|
||||||
|
*/
|
||||||
|
setPlayButton(messageId, playing) {
|
||||||
|
const $msg = this.$chat.querySelector(`[data-id="${messageId}"]`);
|
||||||
|
if (!$msg) return;
|
||||||
|
const $btn = $msg.querySelector('[data-action="play"]');
|
||||||
|
if (!$btn) return;
|
||||||
|
|
||||||
|
if (playing) {
|
||||||
|
$btn.classList.add('playing');
|
||||||
|
$btn.innerHTML = `<svg class="icon"><use href="${ICONS_URL}#stop"></use></svg>`;
|
||||||
|
$btn.setAttribute('aria-label', 'Stop');
|
||||||
|
} else {
|
||||||
|
$btn.classList.remove('playing');
|
||||||
|
$btn.innerHTML = `<svg class="icon"><use href="${ICONS_URL}#volume"></use></svg>`;
|
||||||
|
$btn.setAttribute('aria-label', 'Play');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Error ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* renderError renders an error message in the chat.
|
||||||
|
* @param {string} message
|
||||||
|
*/
|
||||||
|
renderError(message) {
|
||||||
|
const $msg =
|
||||||
|
this.$tplErrorMessage.content.cloneNode(true).firstElementChild;
|
||||||
|
$msg.querySelector('.message__content').textContent = message;
|
||||||
|
this.$chat.insertBefore($msg, this.$chatLoading);
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scroll ---
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
if (this.isAutoScrollEnabled) {
|
||||||
|
this.$chat.scrollTop = this.$chat.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isAtBottom() {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = this.$chat;
|
||||||
|
return scrollHeight - clientHeight - scrollTop < 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateScrollIcon() {
|
||||||
|
const atBottom = this.isAtBottom();
|
||||||
|
this.$scroll.classList.toggle('scrolled-up', !atBottom);
|
||||||
|
this.$scroll.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
atBottom ? 'Scroll to top' : 'Scroll to bottom',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChatScroll() {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = this.$chat;
|
||||||
|
const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
|
||||||
|
const isAtBottom = distanceFromBottom < 10;
|
||||||
|
|
||||||
|
if (scrollTop < this._lastScrollTop && !isAtBottom) {
|
||||||
|
this.isAutoScrollEnabled = false;
|
||||||
|
} else if (isAtBottom) {
|
||||||
|
this.isAutoScrollEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastScrollTop = scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAutoScroll(enabled) {
|
||||||
|
this.isAutoScrollEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearChat() {
|
||||||
|
const $loading = this.$chatLoading;
|
||||||
|
this.$chat.innerHTML = '';
|
||||||
|
this.$chat.appendChild($loading);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private ---
|
||||||
|
|
||||||
|
_renderTree(messageId) {
|
||||||
|
const messages = [];
|
||||||
|
this._collectMessages(messageId, messages);
|
||||||
|
this.renderMessages(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
_collectMessages(messageId, messages) {
|
||||||
|
if (!messageId) return;
|
||||||
|
const msg = this.conversation.get(messageId);
|
||||||
|
if (!msg) return;
|
||||||
|
messages.push(msg);
|
||||||
|
if (msg.children) {
|
||||||
|
for (const childId of msg.children) {
|
||||||
|
this._collectMessages(childId, messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderUserMessage(message, meta = null) {
|
||||||
|
const content = (() => {
|
||||||
|
if (typeof message.content === 'string') {
|
||||||
|
return message.content;
|
||||||
|
}
|
||||||
|
if (Array.isArray(message.content)) {
|
||||||
|
return message.content
|
||||||
|
.filter((p) => p.type === 'text')
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const $msg = this.$tplUserMessage.content.cloneNode(true).firstElementChild;
|
||||||
|
if (message.id) $msg.dataset.id = message.id;
|
||||||
|
|
||||||
|
$msg.querySelector('.message__content').textContent = content;
|
||||||
|
$msg.dataset.copyText = content;
|
||||||
|
|
||||||
|
const $dl = $msg.querySelector('.message__debug-list');
|
||||||
|
if (meta?.language) this._appendDebugRow($dl, 'Language', meta.language);
|
||||||
|
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
|
||||||
|
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
|
||||||
|
|
||||||
|
this.$chat.insertBefore($msg, this.$chatLoading);
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderAssistantMessage(message, meta = null) {
|
||||||
|
const $msg =
|
||||||
|
this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
|
||||||
|
if (message.id) $msg.dataset.id = message.id;
|
||||||
|
|
||||||
|
const $content = $msg.querySelector('.message__content');
|
||||||
|
const text = extractText(message.content);
|
||||||
|
if (text) {
|
||||||
|
$content.textContent = text;
|
||||||
|
} else {
|
||||||
|
$content.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const $dl = $msg.querySelector('.message__debug-list');
|
||||||
|
this._populateDebugPanel($dl, meta);
|
||||||
|
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
|
||||||
|
|
||||||
|
const voice = meta?.voice || this.$voice.value || '';
|
||||||
|
$msg.dataset.voice = voice;
|
||||||
|
$msg.dataset.copyText = text || '';
|
||||||
|
|
||||||
|
this.$chat.insertBefore($msg, this.$chatLoading);
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderReasoningMessage(message, meta = null) {
|
||||||
|
const $msg =
|
||||||
|
this.$tplReasoningMessage.content.cloneNode(true).firstElementChild;
|
||||||
|
$msg.dataset.id = `${message.id}/reasoning`;
|
||||||
|
$msg.dataset.parentId = message.id;
|
||||||
|
$msg.dataset.field = 'reasoning';
|
||||||
|
$msg.dataset.messageId = message.id;
|
||||||
|
|
||||||
|
$msg.querySelector('.message__content').textContent =
|
||||||
|
message.reasoning_content || '';
|
||||||
|
$msg.dataset.copyText = message.reasoning_content || '';
|
||||||
|
|
||||||
|
const $dl = $msg.querySelector('.message__debug-list');
|
||||||
|
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
|
||||||
|
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
|
||||||
|
|
||||||
|
this.$chat.insertBefore($msg, this.$chatLoading);
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderToolMessage(assistantMessage, toolCall, result = null, meta = null) {
|
||||||
|
const $msg = this.$tplToolMessage.content.cloneNode(true).firstElementChild;
|
||||||
|
const toolBubbleId = result?.id || toolCall.id;
|
||||||
|
$msg.dataset.id = toolBubbleId;
|
||||||
|
$msg.dataset.parentId = assistantMessage.id;
|
||||||
|
$msg.dataset.field = 'tool';
|
||||||
|
$msg.dataset.messageId = assistantMessage.id;
|
||||||
|
$msg.dataset.toolCallId = toolCall.id;
|
||||||
|
|
||||||
|
$msg.querySelector('.message__collapsible-label').textContent =
|
||||||
|
toolCall.function?.name || 'Tool Call';
|
||||||
|
|
||||||
|
const $args = $msg.querySelector('[data-args]');
|
||||||
|
try {
|
||||||
|
const args = JSON.parse(toolCall.function?.arguments || '{}');
|
||||||
|
$args.textContent = JSON.stringify(args, null, 2);
|
||||||
|
} catch {
|
||||||
|
$args.textContent = toolCall.function?.arguments || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
const $outputSection = $msg.querySelector(
|
||||||
|
'.message__tool-section--output',
|
||||||
|
);
|
||||||
|
$outputSection.hidden = false;
|
||||||
|
|
||||||
|
const $output = $msg.querySelector('[data-output]');
|
||||||
|
try {
|
||||||
|
const output = JSON.parse(extractText(result.content) || '{}');
|
||||||
|
$output.textContent = JSON.stringify(output, null, 2);
|
||||||
|
} catch {
|
||||||
|
$output.textContent = extractText(result.content) || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set copy text.
|
||||||
|
const copyParts = [];
|
||||||
|
const name = toolCall.function?.name || 'unknown';
|
||||||
|
const args = toolCall.function?.arguments || '{}';
|
||||||
|
copyParts.push(`${name}(${args})`);
|
||||||
|
if (result) {
|
||||||
|
copyParts.push(extractText(result.content) || '');
|
||||||
|
}
|
||||||
|
$msg.dataset.copyText = copyParts.join('\n\n');
|
||||||
|
|
||||||
|
const $dl = $msg.querySelector('.message__debug-list');
|
||||||
|
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
|
||||||
|
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
|
||||||
|
|
||||||
|
this.$chat.insertBefore($msg, this.$chatLoading);
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
_populateDebugPanel($dl, meta) {
|
||||||
|
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
|
||||||
|
if (meta?.usage) {
|
||||||
|
const u = meta.usage;
|
||||||
|
this._appendDebugRow($dl, 'Prompt tokens', String(u.prompt_tokens));
|
||||||
|
this._appendDebugRow(
|
||||||
|
$dl,
|
||||||
|
'Completion tokens',
|
||||||
|
String(u.completion_tokens),
|
||||||
|
);
|
||||||
|
this._appendDebugRow($dl, 'Total tokens', String(u.total_tokens));
|
||||||
|
if (u.completion_tokens_details?.reasoning_tokens > 0)
|
||||||
|
this._appendDebugRow(
|
||||||
|
$dl,
|
||||||
|
'Reasoning tokens',
|
||||||
|
String(u.completion_tokens_details.reasoning_tokens),
|
||||||
|
);
|
||||||
|
if (u.prompt_tokens_details?.cached_tokens > 0)
|
||||||
|
this._appendDebugRow(
|
||||||
|
$dl,
|
||||||
|
'Cached tokens',
|
||||||
|
String(u.prompt_tokens_details.cached_tokens),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (meta?.timings) {
|
||||||
|
const t = meta.timings;
|
||||||
|
if (t.prompt_ms > 0)
|
||||||
|
this._appendDebugRow(
|
||||||
|
$dl,
|
||||||
|
'Prompt eval',
|
||||||
|
`${t.prompt_ms.toFixed(1)} ms (${t.prompt_per_second.toFixed(0)} tok/s)`,
|
||||||
|
);
|
||||||
|
if (t.predicted_ms > 0)
|
||||||
|
this._appendDebugRow(
|
||||||
|
$dl,
|
||||||
|
'Predicted eval',
|
||||||
|
`${t.predicted_ms.toFixed(1)} ms (${t.predicted_per_second.toFixed(0)} tok/s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_appendDebugRow($dl, label, value) {
|
||||||
|
const $row = this.$tplDebugRow.content.cloneNode(true);
|
||||||
|
$row.querySelector('dt').textContent = label;
|
||||||
|
$row.querySelector('dd').textContent = value;
|
||||||
|
$dl.appendChild($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
_icon(name) {
|
||||||
|
const svg = this.document.createElementNS(
|
||||||
|
'http://www.w3.org/2000/svg',
|
||||||
|
'svg',
|
||||||
|
);
|
||||||
|
svg.setAttribute('class', 'icon');
|
||||||
|
|
||||||
|
const use = this.document.createElementNS(
|
||||||
|
'http://www.w3.org/2000/svg',
|
||||||
|
'use',
|
||||||
|
);
|
||||||
|
use.setAttribute('href', `${ICONS_URL}#${name}`);
|
||||||
|
|
||||||
|
svg.appendChild(use);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
copyToClipboard($button, text) {
|
||||||
|
navigator.clipboard.writeText(text).then(
|
||||||
|
() => {
|
||||||
|
$button.replaceChildren(this._icon('check'));
|
||||||
|
$button.classList.add('message__action-btn--success');
|
||||||
|
setTimeout(() => {
|
||||||
|
$button.replaceChildren(this._icon('copy'));
|
||||||
|
$button.classList.remove('message__action-btn--success');
|
||||||
|
}, 1500);
|
||||||
|
},
|
||||||
|
(e) => console.error('failed to copy:', e),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
350
internal/service/static/settings.js
Normal file
350
internal/service/static/settings.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
207
internal/service/static/stt.js
Normal file
207
internal/service/static/stt.js
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
200
internal/service/static/tts.js
Normal file
200
internal/service/static/tts.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
internal/service/static/wakelock.js
Normal file
49
internal/service/static/wakelock.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,5 +3,6 @@
|
|||||||
{{ template "main" . }}
|
{{ template "main" . }}
|
||||||
{{ template "templates" . }}
|
{{ template "templates" . }}
|
||||||
{{ template "modal/settings" . }}
|
{{ template "modal/settings" . }}
|
||||||
|
{{ template "modal/edit" . }}
|
||||||
</body>
|
</body>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="compose__action-btn"
|
class="compose__action-btn"
|
||||||
id="attach"
|
id="attach"
|
||||||
|
data-action="attach"
|
||||||
aria-label="Attach files"
|
aria-label="Attach files"
|
||||||
>
|
>
|
||||||
<svg class="icon"><use href="/static/icons.svg#attach"></use></svg>
|
<svg class="icon"><use href="/static/icons.svg#attach"></use></svg>
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="compose__action-btn"
|
class="compose__action-btn"
|
||||||
id="location"
|
id="location"
|
||||||
|
data-action="location"
|
||||||
aria-label="Include location"
|
aria-label="Include location"
|
||||||
title="Include GPS location"
|
title="Include GPS location"
|
||||||
>
|
>
|
||||||
@@ -34,6 +36,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="compose__action-btn"
|
class="compose__action-btn"
|
||||||
id="transcribe"
|
id="transcribe"
|
||||||
|
data-action="transcribe"
|
||||||
aria-label="Transcribe to text"
|
aria-label="Transcribe to text"
|
||||||
title="Record and transcribe (does not send)"
|
title="Record and transcribe (does not send)"
|
||||||
>
|
>
|
||||||
@@ -43,6 +46,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="compose__action-btn compose__action-btn--record"
|
class="compose__action-btn compose__action-btn--record"
|
||||||
id="ptt"
|
id="ptt"
|
||||||
|
data-action="ptt"
|
||||||
aria-label="Push to talk"
|
aria-label="Push to talk"
|
||||||
>
|
>
|
||||||
<svg class="icon"><use href="/static/icons.svg#mic"></use></svg>
|
<svg class="icon"><use href="/static/icons.svg#mic"></use></svg>
|
||||||
@@ -51,6 +55,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="compose__action-btn compose__action-btn--send"
|
class="compose__action-btn compose__action-btn--send"
|
||||||
id="send"
|
id="send"
|
||||||
|
data-action="send"
|
||||||
aria-label="Send message"
|
aria-label="Send message"
|
||||||
>
|
>
|
||||||
<svg class="icon compose__icon--send"><use href="/static/icons.svg#send"></use></svg>
|
<svg class="icon compose__icon--send"><use href="/static/icons.svg#send"></use></svg>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="footer__toolbar-btn"
|
class="footer__toolbar-btn"
|
||||||
id="settings"
|
id="settings"
|
||||||
|
data-action="settings"
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
>
|
>
|
||||||
<svg class="icon"><use href="/static/icons.svg#settings"></use></svg>
|
<svg class="icon"><use href="/static/icons.svg#settings"></use></svg>
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="footer__toolbar-btn"
|
class="footer__toolbar-btn"
|
||||||
id="reset"
|
id="reset"
|
||||||
|
data-action="reset"
|
||||||
aria-label="Reset conversation"
|
aria-label="Reset conversation"
|
||||||
>
|
>
|
||||||
<svg class="icon"><use href="/static/icons.svg#trash"></use></svg>
|
<svg class="icon"><use href="/static/icons.svg#trash"></use></svg>
|
||||||
@@ -22,6 +24,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="footer__toolbar-btn"
|
class="footer__toolbar-btn"
|
||||||
id="scroll"
|
id="scroll"
|
||||||
|
data-action="scroll"
|
||||||
aria-label="Scroll to top"
|
aria-label="Scroll to top"
|
||||||
>
|
>
|
||||||
<svg class="icon scroll-icon--up"><use href="/static/icons.svg#scroll-up"></use></svg>
|
<svg class="icon scroll-icon--up"><use href="/static/icons.svg#scroll-up"></use></svg>
|
||||||
@@ -31,6 +34,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="footer__toolbar-btn"
|
class="footer__toolbar-btn"
|
||||||
id="mute"
|
id="mute"
|
||||||
|
data-action="mute"
|
||||||
aria-label="Mute"
|
aria-label="Mute"
|
||||||
>
|
>
|
||||||
<svg class="icon"><use href="/static/icons.svg#volume"></use></svg>
|
<svg class="icon"><use href="/static/icons.svg#volume"></use></svg>
|
||||||
|
|||||||
@@ -9,6 +9,6 @@
|
|||||||
<meta name="tts-default-voice" content="{{ .TTSDefaultVoice }}">
|
<meta name="tts-default-voice" content="{{ .TTSDefaultVoice }}">
|
||||||
<title>Odidere</title>
|
<title>Odidere</title>
|
||||||
<link rel="stylesheet" href="/static/main.css">
|
<link rel="stylesheet" href="/static/main.css">
|
||||||
<script defer src="/static/main.js"></script>
|
<script type="module" src="/static/main.js"></script>
|
||||||
</head>
|
</head>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
42
internal/service/templates/static/modal/edit.gohtml
Normal file
42
internal/service/templates/static/modal/edit.gohtml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{{ define "modal/edit" }}
|
||||||
|
<template id="tpl-edit-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal__header">
|
||||||
|
<h2></h2>
|
||||||
|
<button type="button" class="modal__close" aria-label="Cancel">
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#close"></use></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal__body">
|
||||||
|
<textarea class="modal__textarea" id="edit-textarea" rows="10"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="modal__footer">
|
||||||
|
<button type="button" class="modal__save" id="edit-save">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-edit-tool-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal__header">
|
||||||
|
<h2>Edit Tool Call</h2>
|
||||||
|
<button type="button" class="modal__close" aria-label="Cancel">
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#close"></use></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal__body">
|
||||||
|
<details class="modal__collapsible">
|
||||||
|
<summary class="modal__collapsible-summary">Tool Call (JSON)</summary>
|
||||||
|
<textarea class="modal__textarea" id="edit-tool-call" rows="8"></textarea>
|
||||||
|
</details>
|
||||||
|
<details class="modal__collapsible">
|
||||||
|
<summary class="modal__collapsible-summary">Tool Result</summary>
|
||||||
|
<textarea class="modal__textarea" id="edit-tool-result" rows="6"></textarea>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="modal__footer">
|
||||||
|
<button type="button" class="modal__save" id="edit-save">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
{{ end }}
|
||||||
@@ -51,7 +51,13 @@
|
|||||||
<template id="tpl-assistant-message">
|
<template id="tpl-assistant-message">
|
||||||
<div class="message message--assistant">
|
<div class="message message--assistant">
|
||||||
<div class="message__body">
|
<div class="message__body">
|
||||||
<div class="message__content"></div>
|
<details class="message__collapsible" open>
|
||||||
|
<summary class="message__collapsible-summary">
|
||||||
|
<svg class="message__icon" aria-hidden="true"><use href="/static/icons.svg#content"></use></svg>
|
||||||
|
<span class="message__collapsible-label">Content</span>
|
||||||
|
</summary>
|
||||||
|
<div class="message__content"></div>
|
||||||
|
</details>
|
||||||
<div class="message__actions">
|
<div class="message__actions">
|
||||||
<svg class="message__icon" aria-hidden="true"><use href="/static/icons.svg#assistant"></use></svg>
|
<svg class="message__icon" aria-hidden="true"><use href="/static/icons.svg#assistant"></use></svg>
|
||||||
<div class="message__actions-buttons">
|
<div class="message__actions-buttons">
|
||||||
@@ -125,37 +131,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template id="tpl-collapsible">
|
|
||||||
<details class="collapsible">
|
|
||||||
<summary class="collapsible__summary">
|
|
||||||
<span class="collapsible__label"></span>
|
|
||||||
</summary>
|
|
||||||
<div class="collapsible__content">
|
|
||||||
<pre></pre>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template id="tpl-tool-call">
|
|
||||||
<details class="collapsible collapsible--tool">
|
|
||||||
<summary class="collapsible__summary">
|
|
||||||
<svg class="icon"><use href="/static/icons.svg#tool"></use></svg>
|
|
||||||
<span class="collapsible__label"></span>
|
|
||||||
</summary>
|
|
||||||
<div class="collapsible__content">
|
|
||||||
<div class="collapsible__section">
|
|
||||||
<div class="collapsible__section-label">Arguments</div>
|
|
||||||
<pre class="collapsible__pre" data-args></pre>
|
|
||||||
</div>
|
|
||||||
<div class="collapsible__section collapsible__section--output" hidden>
|
|
||||||
<div class="collapsible__section-label">Output</div>
|
|
||||||
<pre class="collapsible__pre" data-output></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template id="tpl-attachment-chip">
|
<template id="tpl-attachment-chip">
|
||||||
<div class="compose__attachment">
|
<div class="compose__attachment">
|
||||||
<span class="compose__attachment-name"></span>
|
<span class="compose__attachment-name"></span>
|
||||||
@@ -173,4 +148,123 @@
|
|||||||
<dt></dt>
|
<dt></dt>
|
||||||
<dd></dd>
|
<dd></dd>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-reasoning-message">
|
||||||
|
<div class="message message--reasoning">
|
||||||
|
<div class="message__body">
|
||||||
|
<details class="message__collapsible">
|
||||||
|
<summary class="message__collapsible-summary">
|
||||||
|
<svg class="message__icon message__icon--reasoning" aria-hidden="true"><use href="/static/icons.svg#reasoning"></use></svg>
|
||||||
|
<span class="message__collapsible-label">Reasoning</span>
|
||||||
|
</summary>
|
||||||
|
<div class="message__content"></div>
|
||||||
|
</details>
|
||||||
|
<div class="message__actions">
|
||||||
|
<svg class="message__icon" aria-hidden="true"><use href="/static/icons.svg#assistant"></use></svg>
|
||||||
|
<div class="message__actions-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message__action-btn message__action-btn--delete"
|
||||||
|
data-action="delete"
|
||||||
|
aria-label="Delete message"
|
||||||
|
>
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#trash"></use></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message__action-btn"
|
||||||
|
data-action="inspect"
|
||||||
|
aria-label="Show details"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#inspect"></use></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message__action-btn"
|
||||||
|
data-action="edit"
|
||||||
|
aria-label="Edit message"
|
||||||
|
>
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#edit"></use></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message__action-btn"
|
||||||
|
data-action="copy"
|
||||||
|
aria-label="Copy to clipboard"
|
||||||
|
>
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#copy"></use></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="message__debug">
|
||||||
|
<dl class="message__debug-list"></dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-tool-message">
|
||||||
|
<div class="message message--tool">
|
||||||
|
<div class="message__body">
|
||||||
|
<details class="message__collapsible">
|
||||||
|
<summary class="message__collapsible-summary">
|
||||||
|
<svg class="message__icon message__icon--tool" aria-hidden="true"><use href="/static/icons.svg#tool"></use></svg>
|
||||||
|
<span class="message__collapsible-label"></span>
|
||||||
|
</summary>
|
||||||
|
<div class="message__content">
|
||||||
|
<div class="message__tool-section">
|
||||||
|
<div class="message__tool-section-label">Arguments</div>
|
||||||
|
<pre class="message__tool-pre" data-args></pre>
|
||||||
|
</div>
|
||||||
|
<div class="message__tool-section message__tool-section--output" hidden>
|
||||||
|
<div class="message__tool-section-label">Output</div>
|
||||||
|
<pre class="message__tool-pre" data-output></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div class="message__actions">
|
||||||
|
<svg class="message__icon" aria-hidden="true"><use href="/static/icons.svg#assistant"></use></svg>
|
||||||
|
<div class="message__actions-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message__action-btn message__action-btn--delete"
|
||||||
|
data-action="delete"
|
||||||
|
aria-label="Delete message"
|
||||||
|
>
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#trash"></use></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message__action-btn"
|
||||||
|
data-action="inspect"
|
||||||
|
aria-label="Show details"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#inspect"></use></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message__action-btn"
|
||||||
|
data-action="edit"
|
||||||
|
aria-label="Edit message"
|
||||||
|
>
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#edit"></use></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message__action-btn"
|
||||||
|
data-action="copy"
|
||||||
|
aria-label="Copy to clipboard"
|
||||||
|
>
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#copy"></use></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="message__debug">
|
||||||
|
<dl class="message__debug-list"></dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
Reference in New Issue
Block a user