Refactor frontend

This commit is contained in:
dwrz
2026-10-30 22:42:51 +00:00
parent cedb3d8854
commit e1457b5130
9 changed files with 3598 additions and 2973 deletions
+48
View File
@@ -1218,6 +1218,54 @@ body {
word-break: break-word;
}
/* Approve and deny buttons, shown while a tool call waits for a
user decision. */
.message__approval {
display: flex;
gap: var(--s-2);
padding: 0 var(--s-1) var(--s-1);
}
.message__approval[hidden] {
display: none;
}
.message__approval-btn {
flex: 1;
padding: 0.25rem var(--s-1);
font-size: var(--s-1);
font-weight: 500;
color: var(--color-text);
background: var(--color-surface);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius);
cursor: pointer;
}
.message__approval-btn--approve {
color: white;
background: var(--color-green);
border-color: var(--color-green);
}
.message__approval-btn--deny {
color: var(--color-red);
border-color: var(--color-red);
}
@media (hover: hover) {
.message__approval-btn--approve:hover {
background: var(--color-green);
filter: brightness(1.1);
}
.message__approval-btn--deny:hover {
color: white;
background: var(--color-red);
border-color: var(--color-red);
}
}
.message--system .message__icon {
color: var(--color-text-muted);
}
+365
View File
@@ -0,0 +1,365 @@
/**
* API sends the odidere HTTP requests and decodes the server's
* Server-Sent Events (SSE) streams. It is the only module that opens
* network connections. It never reads the DOM and never touches
* localStorage: callers keep what it returns and render it elsewhere.
*
* The two streaming methods, inference and subscribe, are async
* generators. Each yielded value is one decoded SSE event:
* { type, data }. The server terminates a stream with a 'done'
* event. A generator that ends without 'done' reports that delivery
* stopped, not that the work stopped: the server keeps an accepted
* inference running after the client disconnects, and a new
* subscribe call re-attaches to it and replays what was saved.
*
* Every failed request throws ApiError, which carries the HTTP
* status. Status 409 reports either that the conversation is already
* running inference, or that a message changed since the caller last
* read it; the caller should re-sync before it retries.
*/
export class ApiError extends Error {
/**
* @param {number} status - HTTP status code.
* @param {string} body - response body, often empty.
*/
constructor(status, body) {
super(`HTTP ${status}${body ? `: ${body}` : ''}`);
this.name = 'ApiError';
this.status = status;
this.body = body;
}
}
export class Api {
// --- Models and voices ---
/**
* getModels lists the models of every configured provider, each
* with an availability status, and the server's default selection.
* @returns {Promise<{providers: Object, default_provider: string, default_model: string}>}
*/
async getModels() {
return this._json('/models');
}
/**
* getVoices lists the voices of the TTS server. The TTS server is
* a separate service, not part of the odidere API.
* @param {string} url - TTS server base URL.
* @returns {Promise<Object>}
*/
async getVoices(url) {
return this._json(`${url}/v1/audio/voices`);
}
// --- Conversations ---
/**
* createConversation saves a new conversation with its initial
* system message. An empty systemMessage makes the server use its
* configured default.
* @param {{title?: string, systemMessage?: string}} [body]
* @returns {Promise<Object>} the saved conversation row.
*/
async createConversation({ title, systemMessage } = {}) {
const body = {};
if (title !== undefined) body.title = title;
if (systemMessage !== undefined) body.system_message = systemMessage;
return this._json('/conversations', { method: 'POST', body });
}
/**
* deleteConversation removes the conversation, all its messages,
* and its tool execution records. It also cancels an ongoing
* inference. This cannot be undone.
* @param {string} id
*/
async deleteConversation(id) {
await this._request(`/conversations/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
}
/**
* stopConversation asks the server to cancel the conversation's
* ongoing inference. The response acknowledges the request, not
* completed cancellation: the events that follow report the
* outcome of the work that already finished.
* @param {string} id
*/
async stopConversation(id) {
await this._request(`/conversations/${encodeURIComponent(id)}/stop`, {
method: 'POST',
});
}
// --- Streaming ---
/**
* inference starts one inference run and yields its events. The
* optional message is new user input; without it the run continues
* from saved history, which is how a run resumes after a tool
* approval or denial.
*
* The server rejects the request with status 409 while another run
* is reserved or ongoing.
*
* @param {string} id - conversation ID.
* @param {Object} body - request fields; message, provider, model,
* max_iterations, thinking_budget_tokens, stream.
* @param {AbortSignal} [signal] - ends delivery, not the run.
*/
async *inference(id, body, signal) {
yield* this._stream(
`/conversations/${encodeURIComponent(id)}/inference`,
body,
signal,
);
}
/**
* subscribe synchronizes the caller's message cache with the saved
* conversation and yields the events that follow.
*
* manifest lists the message IDs and timestamps the caller already
* holds. The first events are a diff: one message_saved per row
* that is missing or newer than the manifest, then one
* message_deleted per manifest ID that is no longer saved. An
* empty manifest requests every saved row.
*
* When an inference is reserved or ongoing, the stream then follows
* its events and ends with done. When none is, the stream ends
* after the diff without done.
*
* @param {string} id - conversation ID.
* @param {Array<{id: string, updated_at: string}>} manifest
* @param {AbortSignal} [signal]
*/
async *subscribe(id, manifest, signal) {
yield* this._stream(
`/conversations/${encodeURIComponent(id)}/subscribe`,
{ manifest },
signal,
);
}
// --- Message edits ---
/**
* insertMessage saves a new message. For a user or assistant
* message, pass message only. For a tool result, pass the tool
* message and message_id and updated_at of its parent assistant
* message: message_id names the assistant, updated_at is the
* assistant's timestamp from the caller's last read.
* @param {string} id - conversation ID.
* @param {Object} body - message, and for tool results
* message_id and updated_at.
* @returns {Promise<Object>} the saved message row.
*/
async insertMessage(id, body) {
return this._json(`/conversations/${encodeURIComponent(id)}/messages`, {
method: 'POST',
body,
});
}
/**
* updateMessage replaces the model-visible content of one saved
* message. The role may not change. updated_at is the row's
* timestamp from the caller's last read; a mismatch returns 409.
* For a tool result, mid identifies the parent assistant message
* and updated_at is the tool result's timestamp.
* @param {string} id - conversation ID.
* @param {string} mid - message ID.
* @param {Object} body - message and updated_at.
* @returns {Promise<Object>} the saved message row.
*/
async updateMessage(id, mid, body) {
return this._json(
`/conversations/${encodeURIComponent(id)}` +
`/messages/${encodeURIComponent(mid)}`,
{ method: 'PUT', body },
);
}
/**
* deleteMessage removes one saved message. updated_at is the
* row's timestamp from the caller's last read; a mismatch returns
* 409.
* @param {string} id - conversation ID.
* @param {string} mid - message ID.
* @param {string} updatedAt
*/
async deleteMessage(id, mid, updatedAt) {
await this._request(
`/conversations/${encodeURIComponent(id)}` +
`/messages/${encodeURIComponent(mid)}`,
{ method: 'DELETE', body: { updated_at: updatedAt } },
);
}
/**
* updateToolCall replaces one call of a saved assistant message.
* The replacement must keep the call ID: the execution record and
* any saved result are keyed by it. updated_at is the assistant
* message's timestamp from the caller's last read.
* @param {string} id - conversation ID.
* @param {string} mid - assistant message ID.
* @param {string} cid - tool call ID.
* @param {Object} body - call and updated_at.
* @returns {Promise<Object>} the saved assistant message row.
*/
async updateToolCall(id, mid, cid, body) {
return this._json(
`/conversations/${encodeURIComponent(id)}` +
`/messages/${encodeURIComponent(mid)}` +
`/calls/${encodeURIComponent(cid)}`,
{ method: 'PUT', body },
);
}
/**
* deleteToolCall removes the call, its saved result, and its
* execution record together. updated_at is the assistant message's
* timestamp from the caller's last read.
* @param {string} id - conversation ID.
* @param {string} mid - assistant message ID.
* @param {string} cid - tool call ID.
* @param {string} updatedAt
*/
async deleteToolCall(id, mid, cid, updatedAt) {
await this._request(
`/conversations/${encodeURIComponent(id)}` +
`/messages/${encodeURIComponent(mid)}` +
`/calls/${encodeURIComponent(cid)}`,
{ method: 'DELETE', body: { updated_at: updatedAt } },
);
}
// --- Tool approvals ---
/**
* listPendingExecutions lists every tool call that waits for a
* user decision, across all conversations. Filter the result to
* the conversation in view before showing it.
* @returns {Promise<Array<Object>>} execution rows with status
* 'pending'.
*/
async listPendingExecutions() {
return this._json('/executions');
}
/**
* decideExecution records the user's decision for one tool call.
* An approved call runs on the conversation's next inference; a
* denied call does not. The status transition is refused once the
* row has left 'pending'.
* @param {string} executionId
* @param {'approved' | 'denied'} status
*/
async decideExecution(executionId, status) {
await this._request(`/executions/${encodeURIComponent(executionId)}`, {
method: 'PUT',
body: { status },
});
}
// --- Private ---
/**
* _request sends one request and checks the response. It returns
* the open Response and throws ApiError when the server rejects
* the request. Signals from the caller propagate as AbortError.
* @private
*/
async _request(path, { method = 'GET', body, signal } = {}) {
const res = await fetch(path, {
method,
signal,
headers:
body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
throw new ApiError(res.status, (await res.text().catch(() => '')).trim());
}
return res;
}
/**
* _json sends one request and parses its JSON body. A response
* with no body (204 No Content) resolves to null.
* @private
*/
async _json(path, options) {
const res = await this._request(path, options);
return res.status === 204 ? null : res.json();
}
/**
* _stream sends one POST request and yields the response's decoded
* SSE events. See readEvents.
* @private
*/
async *_stream(path, body, signal) {
const res = await this._request(path, {
method: 'POST',
body,
signal,
});
yield* readEvents(res);
}
}
/**
* readEvents decodes one SSE response into { type, data } events.
* It buffers across network chunk boundaries, accepts LF and CRLF
* line endings, joins multi-line data fields, and skips comment
* lines. Events without an event field get type 'message'. A line
* with invalid JSON is logged and skipped; the stream continues.
* @param {Response} response
*/
export async function* readEvents(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let type = 'message';
let data = [];
try {
while (true) {
const result = await reader.read();
buffer += decoder.decode(result.value, { stream: !result.done });
while (true) {
const newline = buffer.indexOf('\n');
if (newline === -1) break;
const line = buffer.slice(0, newline).replace(/\r$/, '');
buffer = buffer.slice(newline + 1);
if (line === '') {
if (data.length) {
const text = data.join('\n');
try {
yield { type, data: JSON.parse(text) };
} catch (error) {
console.error('failed to parse SSE data:', text, error);
}
}
type = 'message';
data = [];
continue;
}
if (line.startsWith(':')) continue;
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
const value =
colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '');
if (field === 'event') type = value;
if (field === 'data') data.push(value);
}
if (result.done) break;
}
} finally {
await reader.cancel().catch(() => { });
reader.releaseLock();
}
}
-273
View File
@@ -1,273 +0,0 @@
/**
* 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
View File
@@ -1,213 +0,0 @@
/**
* EditModal opens a modal for editing a message (text, reasoning, or
* tool call). Reads from and writes to the conversation tree. Returns
* a Promise so the App knows when to re-render.
*/
import { extractText } from './conversation.js';
export class EditModal {
/**
* @param {Object} options
* @param {Document} options.document
* @param {import('./conversation.js').ConversationTree} options.conversation
*/
constructor({ document, conversation }) {
this.document = document;
this.conversation = conversation;
}
/**
* open creates and shows the edit modal for a message.
* @param {string} messageId
* @param {string} [field] - Optional: 'reasoning' or 'tool'
* @param {string} [toolCallId] - Optional, for tool edits
* @returns {Promise<boolean>} true if saved, false if cancelled
*/
open(messageId, field = null, toolCallId = null) {
const msg = this.conversation.get(messageId);
if (!msg) return Promise.resolve(false);
return new Promise((resolve) => {
const isToolEdit = field === 'tool';
const tplId = isToolEdit ? 'tpl-edit-tool-modal' : 'tpl-edit-modal';
const $tpl = this.document.getElementById(tplId);
if (!$tpl) return resolve(false);
const $modal = $tpl.content.cloneNode(true).firstElementChild;
const $overlay = this.document.createElement('div');
$overlay.className = 'modal-overlay';
$overlay.appendChild($modal);
if (!isToolEdit) {
$modal.querySelector('h2').textContent =
field === 'reasoning' ? 'Edit Reasoning' : 'Edit Message';
}
const saveBtn = $modal.querySelector('#edit-save');
const closeBtn = $modal.querySelector('.modal__close');
// Populate textarea(s).
if (isToolEdit && toolCallId) {
this._populateToolEdit(msg, toolCallId, $modal);
} else if (field === 'reasoning') {
const textarea = $modal.querySelector('#edit-textarea');
textarea.value = msg.reasoning_content || '';
} else {
const textarea = $modal.querySelector('#edit-textarea');
textarea.value = extractText(msg.content);
}
const close = () => {
$overlay.remove();
this.document.removeEventListener('keydown', handleEscape);
resolve(false);
};
const save = () => {
const saved = this._save(messageId, $modal, field, toolCallId, saveBtn);
if (saved) {
$overlay.remove();
this.document.removeEventListener('keydown', handleEscape);
resolve(true);
}
};
closeBtn.addEventListener('click', close);
saveBtn.addEventListener('click', save);
$overlay.addEventListener('click', (e) => {
if (e.target === $overlay) close();
});
const handleEscape = (e) => {
if (e.key === 'Escape') {
close();
}
};
this.document.addEventListener('keydown', handleEscape);
this.document.body.appendChild($overlay);
const firstTextarea = $modal.querySelector('textarea');
if (firstTextarea) firstTextarea.focus();
});
}
// --- Private ---
_populateToolEdit(msg, toolCallId, $modal) {
const toolCall = msg.tool_calls?.find((tc) => tc.id === toolCallId);
if (!toolCall) return;
const $callTextarea = $modal.querySelector('#edit-tool-call');
const $resultTextarea = $modal.querySelector('#edit-tool-result');
try {
const args = JSON.parse(toolCall.function?.arguments || '{}');
$callTextarea.value = JSON.stringify(
{ name: toolCall.function?.name, arguments: args },
null,
2,
);
} catch {
$callTextarea.value = JSON.stringify(
{
name: toolCall.function?.name,
arguments: toolCall.function?.arguments || '',
},
null,
2,
);
}
for (const [, m] of this.conversation.messagesMap) {
if (m.role === 'tool' && m.tool_call_id === toolCallId) {
$resultTextarea.value = extractText(m.content) || '';
break;
}
}
}
_save(messageId, $modal, field, toolCallId, saveBtn) {
const msg = this.conversation.get(messageId);
if (!msg) return false;
if (field === 'tool' && toolCallId) {
return this._saveToolEdit(msg, toolCallId, $modal, saveBtn);
}
if (field === 'reasoning') {
const textarea = $modal.querySelector('#edit-textarea');
msg.reasoning_content = textarea.value;
this.conversation.save();
return true;
}
// Regular message edit.
const textarea = $modal.querySelector('#edit-textarea');
const newText = textarea.value;
if (Array.isArray(msg.content)) {
const textPart = msg.content.find((p) => p.type === 'text');
if (textPart) {
textPart.text = newText;
} else {
msg.content.unshift({ type: 'text', text: newText });
}
} else {
msg.content = newText;
}
this.conversation.save();
return true;
}
_saveToolEdit(msg, toolCallId, $modal, saveBtn) {
const toolCall = msg.tool_calls?.find((tc) => tc.id === toolCallId);
if (!toolCall) return false;
const $callTextarea = $modal.querySelector('#edit-tool-call');
const $resultTextarea = $modal.querySelector('#edit-tool-result');
let parsedCall;
try {
parsedCall = JSON.parse($callTextarea.value);
} catch {
$callTextarea.classList.add('modal__textarea--error');
saveBtn.classList.add('modal__save--error');
setTimeout(() => {
saveBtn.classList.remove('modal__save--error');
}, 500);
return false;
}
if (parsedCall.name) {
toolCall.function.name = parsedCall.name;
}
if (parsedCall.arguments !== undefined) {
toolCall.function.arguments =
typeof parsedCall.arguments === 'string'
? parsedCall.arguments
: JSON.stringify(parsedCall.arguments);
}
for (const [, m] of this.conversation.messagesMap) {
if (m.role === 'tool' && m.tool_call_id === toolCallId) {
const newText = $resultTextarea.value;
if (Array.isArray(m.content)) {
const textPart = m.content.find((p) => p.type === 'text');
if (textPart) {
textPart.text = newText;
} else {
m.content.unshift({ type: 'text', text: newText });
}
} else {
m.content = newText;
}
break;
}
}
this.conversation.save();
return true;
}
}
File diff suppressed because it is too large Load Diff
-826
View File
@@ -1,826 +0,0 @@
/**
* Renderer creates and updates message DOM elements from templates.
* Handles streaming bubbles, attachments, and play-button state.
* Takes the conversation tree as a dependency.
*/
import { extractText } from './conversation.js';
const ICONS_URL = '/static/icons.svg';
export class Renderer {
/**
* @param {Object} options
* @param {Document} options.document
* @param {import('./conversation.js').ConversationTree} options.conversation
*/
constructor({ document, conversation }) {
this.document = document;
this.conversation = conversation;
// DOM Elements
this.$chat = document.getElementById('chat');
this.$chatLoading = document.getElementById('chat-loading');
this.$attachments = document.getElementById('attachments');
this.$scrollDown = document.getElementById('scroll-down');
this.$voice = document.getElementById('voice');
// Auto-scroll follow state. Following is opt-in, so it starts off; the
// user engages it via the scroll button.
this.isAutoScrollEnabled = false;
// Templates
this.$tplAssistantMessage = document.getElementById(
'tpl-assistant-message',
);
this.$tplAttachmentChip = document.getElementById('tpl-attachment-chip');
this.$tplImagePill = document.getElementById('tpl-image-pill');
this.$tplDebugRow = document.getElementById('tpl-debug-row');
this.$tplErrorMessage = document.getElementById('tpl-error-message');
this.$tplUserMessage = document.getElementById('tpl-user-message');
this.$tplReasoningMessage = document.getElementById(
'tpl-reasoning-message',
);
this.$tplToolMessage = document.getElementById('tpl-tool-message');
this.$tplSystemMessage = document.getElementById('tpl-system-message');
}
// --- Full / incremental render ---
/**
* renderMessages renders messages to the chat container.
* Messages already in the DOM (matched by data-id) are skipped.
* @param {Object[]} messages
* @param {Object} [meta]
*/
renderMessages(messages, meta = {}) {
const toolResultMap = new Map();
for (const msg of messages) {
if (msg.role === 'tool' && msg.tool_call_id) {
toolResultMap.set(msg.tool_call_id, msg);
}
}
for (const msg of messages) {
if (msg.role === 'user') {
if (msg.id && this.$chat.querySelector(`[data-id="${msg.id}"]`)) {
continue;
}
this._renderUserMessage(msg, msg.meta ?? meta);
continue;
}
if (msg.role !== 'assistant') continue;
const hasContent =
msg.content || msg.reasoning_content || msg.tool_calls?.length;
if (!hasContent) continue;
const assistantId = msg.id;
const bubbleMeta = msg.meta ?? meta;
if (msg.reasoning_content) {
const reasoningId = `${assistantId}/reasoning`;
if (!this.$chat.querySelector(`[data-id="${reasoningId}"]`)) {
this._renderReasoningMessage(msg, bubbleMeta);
}
}
const textContent = extractText(msg.content);
if (textContent) {
if (
!assistantId ||
!this.$chat.querySelector(`[data-id="${assistantId}"]`)
) {
this._renderAssistantMessage(msg, bubbleMeta);
}
}
if (msg.tool_calls?.length > 0) {
for (const toolCall of msg.tool_calls) {
const result = toolResultMap.get(toolCall.id);
const toolBubbleId = result?.id || toolCall.id;
if (!this.$chat.querySelector(`[data-id="${toolBubbleId}"]`)) {
this._renderToolMessage(msg, toolCall, result, bubbleMeta);
}
}
}
}
}
/**
* reRenderActivePath re-renders the entire active conversation path.
*/
reRenderActivePath() {
const $loading = this.$chatLoading;
this.$chat.innerHTML = '';
this.$chat.appendChild($loading);
this._renderTree(this.conversation.rootId);
this.scrollToBottom();
}
/**
* reRenderMessage replaces the DOM element(s) for a message.
* @param {Object} message
*/
reRenderMessage(message) {
if (message.role === 'user') {
const $old = this.$chat.querySelector(`[data-id="${message.id}"]`);
if (!$old) return;
const $msg =
this.$tplUserMessage.content.cloneNode(true).firstElementChild;
if (message.id) $msg.dataset.id = message.id;
const content = extractText(message.content);
$msg.querySelector('.message__content').textContent = content;
this._renderImagePills($msg, message);
const $dl = $msg.querySelector('.message__debug-list');
if (message.meta?.language)
this._appendDebugRow($dl, 'Language', message.meta.language);
if (message.meta?.voice)
this._appendDebugRow($dl, 'Voice', message.meta.voice);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
$old.replaceWith($msg);
} else if (message.role === 'assistant') {
const $textBubble = this.$chat.querySelector(`[data-id="${message.id}"]`);
const $reasoningBubble = this.$chat.querySelector(
`[data-id="${message.id}/reasoning"]`,
);
const $toolBubbles = this.$chat.querySelectorAll(
`[data-parent-id="${message.id}"]`,
);
let $reference = $reasoningBubble || $textBubble;
if (!$reference && $toolBubbles.length > 0) {
$reference = $toolBubbles[0];
}
if (!$reference) return;
const $toRemove = [];
if ($reasoningBubble) $toRemove.push($reasoningBubble);
if ($textBubble) $toRemove.push($textBubble);
for (const $tool of $toolBubbles) {
$toRemove.push($tool);
}
const toolResultMap = new Map();
if (message.tool_calls?.length > 0) {
const toolCallIds = new Set(message.tool_calls.map((tc) => tc.id));
for (const result of this.conversation.messagesMap.values()) {
if (result.role === 'tool' && toolCallIds.has(result.tool_call_id)) {
toolResultMap.set(result.tool_call_id, result);
}
}
}
const bubbleMeta = message.meta ?? {};
for (const $el of $toRemove) {
$el.remove();
}
if (message.reasoning_content) {
this._renderReasoningMessage(message, bubbleMeta);
}
const textContent = extractText(message.content);
if (textContent) {
this._renderAssistantMessage(message, bubbleMeta);
}
if (message.tool_calls?.length > 0) {
for (const toolCall of message.tool_calls) {
const result = toolResultMap.get(toolCall.id);
this._renderToolMessage(message, toolCall, result, bubbleMeta);
}
}
}
}
// --- Streaming bubbles ---
/**
* createStreamingReasoning creates a streaming reasoning bubble.
* @param {string} assistantId
* @param {Object} [meta]
* @returns {HTMLElement}
*/
createStreamingReasoning(assistantId, meta = null) {
const $msg =
this.$tplReasoningMessage.content.cloneNode(true).firstElementChild;
$msg.dataset.id = `${assistantId}/reasoning`;
$msg.dataset.parentId = assistantId;
$msg.dataset.field = 'reasoning';
$msg.classList.add('message--streaming');
$msg.querySelector('.message__collapsible').open = true;
const $dl = $msg.querySelector('.message__debug-list');
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
// Remove action buttons during streaming.
$msg.querySelector('[data-action="delete"]').remove();
$msg.querySelector('[data-action="edit"]').remove();
$msg.querySelector('[data-action="copy"]').remove();
this.$chat.insertBefore($msg, this.$chatLoading);
this.scrollToBottom();
return $msg;
}
/**
* createStreamingText creates a streaming text content bubble.
* @param {Object} message
* @param {Object} [meta]
* @returns {HTMLElement}
*/
createStreamingText(message, meta = null) {
const $msg =
this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
if (message.id) $msg.dataset.id = message.id;
$msg.classList.add('message--streaming');
const $dl = $msg.querySelector('.message__debug-list');
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
// Remove action buttons during streaming.
$msg.querySelector('[data-action="play"]').remove();
$msg.querySelector('[data-action="copy"]').remove();
$msg.querySelector('[data-action="delete"]').remove();
$msg.querySelector('[data-action="edit"]').remove();
this.$chat.insertBefore($msg, this.$chatLoading);
this.scrollToBottom();
return $msg;
}
/**
* appendDelta appends text to a streaming assistant message.
* @param {HTMLElement} $msg
* @param {string} delta
*/
appendDelta($msg, delta) {
const $content = $msg.querySelector('.message__content');
$content.textContent += delta;
this.scrollToBottom();
}
/**
* appendReasoningDelta appends reasoning text to a streaming bubble.
* @param {HTMLElement} $msg
* @param {string} delta
*/
appendReasoningDelta($msg, delta) {
const $content = $msg.querySelector('.message__content');
$content.textContent += delta;
this.scrollToBottom();
}
/**
* finalizeStreamingReasoning replaces a streaming reasoning bubble
* with a fully bound one.
* @param {HTMLElement} $streaming
* @param {Object} message
* @param {Object} [meta]
* @returns {HTMLElement|null}
*/
finalizeStreamingReasoning($streaming, message, meta = null) {
if (!$streaming) return null;
const $msg =
this.$tplReasoningMessage.content.cloneNode(true).firstElementChild;
$msg.dataset.id = `${message.id}/reasoning`;
$msg.dataset.parentId = message.id;
$msg.dataset.field = 'reasoning';
$msg.dataset.messageId = message.id;
$msg.querySelector('.message__content').textContent =
message.reasoning_content || '';
$msg.querySelector('.message__collapsible').open = false;
const $dl = $msg.querySelector('.message__debug-list');
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
$msg.dataset.copyText = message.reasoning_content || '';
$streaming.replaceWith($msg);
this.scrollToBottom();
return $msg;
}
/**
* finalizeStreamingText replaces a streaming text bubble with a
* fully bound one.
* @param {HTMLElement} $streaming
* @param {Object} message
* @param {Object} [meta]
* @returns {HTMLElement|null}
*/
finalizeStreamingText($streaming, message, meta = null) {
if (!$streaming) return null;
const $msg =
this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
if (message.id) $msg.dataset.id = message.id;
const $content = $msg.querySelector('.message__content');
const text = extractText(message.content);
if (text) {
$content.innerHTML = this._renderMarkdown(text);
} else {
$content.remove();
}
const $dl = $msg.querySelector('.message__debug-list');
this._populateDebugPanel($dl, meta);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
const voice = meta?.voice || this.$voice.value || '';
$msg.dataset.voice = voice;
$msg.dataset.copyText = text || '';
$streaming.replaceWith($msg);
this.scrollToBottom();
return $msg;
}
// --- Attachments ---
/**
* renderAttachments renders the attachment chips in the footer.
* @param {File[]} attachments
* @param {number} attachments.length
* @param {Function} onRemove - Called with the index to remove.
*/
renderAttachments(attachments, onRemove) {
this.$attachments.innerHTML = '';
for (let i = 0; i < attachments.length; i++) {
const file = attachments[i];
const $chip =
this.$tplAttachmentChip.content.cloneNode(true).firstElementChild;
const $name = $chip.querySelector('.compose__attachment-name');
$name.textContent = file.name;
$name.title = file.name;
const $remove = $chip.querySelector('.compose__attachment-remove');
$remove.setAttribute('aria-label', `Remove ${file.name}`);
$remove.addEventListener('click', () => onRemove(i));
this.$attachments.appendChild($chip);
}
}
// --- Play button ---
/**
* setPlayButton updates the play/stop button icon for a message.
* @param {string} messageId
* @param {boolean} playing
*/
setPlayButton(messageId, playing) {
const $msg = this.$chat.querySelector(`[data-id="${messageId}"]`);
if (!$msg) return;
const $btn = $msg.querySelector('[data-action="play"]');
if (!$btn) return;
if (playing) {
$btn.classList.add('playing');
$btn.innerHTML = `<svg class="icon"><use href="${ICONS_URL}#stop"></use></svg>`;
$btn.setAttribute('aria-label', 'Stop');
} else {
$btn.classList.remove('playing');
$btn.innerHTML = `<svg class="icon"><use href="${ICONS_URL}#volume"></use></svg>`;
$btn.setAttribute('aria-label', 'Play');
}
}
// --- Error ---
/**
* renderError renders an error message in the chat.
* @param {string} message
*/
renderError(message, requestId = '') {
const $msg =
this.$tplErrorMessage.content.cloneNode(true).firstElementChild;
const $content = $msg.querySelector('.message__content');
if (requestId) {
$content.textContent = `Request: ${requestId}\n${message}`;
$msg.dataset.copyText = `Request: ${requestId}\n${message}`;
} else {
$content.textContent = message;
$msg.dataset.copyText = message;
}
this.$chat.insertBefore($msg, this.$chatLoading);
this.scrollToBottom();
}
// --- Scroll ---
scrollToBottom() {
if (this.isAutoScrollEnabled) {
this.$chat.scrollTop = this.$chat.scrollHeight;
}
}
// Anchor the last user message to the top of the chat viewport. Uses direct
// scrollTop math (not scrollIntoView) to avoid moving ancestors/footer on
// iOS. No-op if the message is already fully visible.
scrollToLastUserMessage() {
const messages = this.$chat.querySelectorAll('.message--user');
const $last = messages[messages.length - 1];
if (!$last) return;
const { scrollTop, clientHeight } = this.$chat;
const top = $last.offsetTop - this.$chat.offsetTop;
const bottom = top + $last.offsetHeight;
const fullyVisible = top >= scrollTop && bottom <= scrollTop + clientHeight;
if (fullyVisible) return;
this.$chat.scrollTop = top;
}
// Icon and label are driven by follow state, not scroll position, so they
// never drift from isAutoScrollEnabled.
updateFollowIndicator() {
this.$scrollDown.classList.toggle('following', this.isAutoScrollEnabled);
}
// Position-based, disable-only. A user scroll-up past the threshold stops
// following. Never re-enables (a programmatic scroll-to-bottom lands at ~0px,
// so it cannot false-trigger; being near the bottom cannot re-enable).
handleChatScroll() {
const { scrollHeight, scrollTop, clientHeight } = this.$chat;
if (scrollHeight - scrollTop - clientHeight > 100) {
this.setAutoScroll(false);
}
}
// Set the flag and sync the icon so the two never drift.
setAutoScroll(enabled) {
this.isAutoScrollEnabled = enabled;
this.updateFollowIndicator();
}
clearChat() {
const $loading = this.$chatLoading;
this.$chat.innerHTML = '';
this.$chat.appendChild($loading);
}
// --- Private ---
/**
* _renderMarkdown converts Markdown text to safe HTML.
* Uses marked for parsing and DOMPurify for sanitization.
* Falls back to plain text if libraries are unavailable.
* @param {string} text
* @returns {string}
*/
_renderMarkdown(text) {
if (!text) return '';
try {
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
const html = marked.parse(text);
// Wrap tables in a scrollable container for mobile.
const container = document.createElement('div');
container.innerHTML = html;
for (const table of container.querySelectorAll('table')) {
const wrapper = document.createElement('div');
wrapper.className = 'message__table-scroll';
table.replaceWith(wrapper);
wrapper.appendChild(table);
}
return DOMPurify.sanitize(container.innerHTML);
}
} catch (e) {
console.error('failed to render markdown:', e);
}
// Fallback: escape and return plain text
const div = document.createElement('div');
div.textContent = text;
div.style.whiteSpace = 'pre-wrap';
return div.innerHTML;
}
_renderTree(messageId) {
const messages = [];
this._collectMessages(messageId, messages);
this.renderMessages(messages);
}
_collectMessages(messageId, messages) {
if (!messageId) return;
const msg = this.conversation.get(messageId);
if (!msg) return;
messages.push(msg);
if (msg.children) {
for (const childId of msg.children) {
this._collectMessages(childId, messages);
}
}
}
_renderUserMessage(message, meta = null) {
const content = (() => {
if (typeof message.content === 'string') {
return message.content;
}
if (Array.isArray(message.content)) {
return message.content
.filter((p) => p.type === 'text')
.map((p) => p.text)
.join('\n');
}
return '';
})();
const $msg = this.$tplUserMessage.content.cloneNode(true).firstElementChild;
if (message.id) $msg.dataset.id = message.id;
$msg.querySelector('.message__content').textContent = content;
$msg.dataset.copyText = content;
this._renderImagePills($msg, message);
const $dl = $msg.querySelector('.message__debug-list');
if (meta?.language) this._appendDebugRow($dl, 'Language', meta.language);
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
this.$chat.insertBefore($msg, this.$chatLoading);
this.scrollToBottom();
}
_renderAssistantMessage(message, meta = null) {
const $msg =
this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
if (message.id) $msg.dataset.id = message.id;
const $content = $msg.querySelector('.message__content');
const text = extractText(message.content);
if (text) {
$content.innerHTML = this._renderMarkdown(text);
} else {
$content.remove();
}
const $dl = $msg.querySelector('.message__debug-list');
this._populateDebugPanel($dl, meta);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
const voice = meta?.voice || this.$voice.value || '';
$msg.dataset.voice = voice;
$msg.dataset.copyText = text || '';
this.$chat.insertBefore($msg, this.$chatLoading);
this.scrollToBottom();
}
_renderReasoningMessage(message, meta = null) {
const $msg =
this.$tplReasoningMessage.content.cloneNode(true).firstElementChild;
$msg.dataset.id = `${message.id}/reasoning`;
$msg.dataset.parentId = message.id;
$msg.dataset.field = 'reasoning';
$msg.dataset.messageId = message.id;
$msg.querySelector('.message__content').textContent =
message.reasoning_content || '';
$msg.dataset.copyText = message.reasoning_content || '';
const $dl = $msg.querySelector('.message__debug-list');
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
this.$chat.insertBefore($msg, this.$chatLoading);
this.scrollToBottom();
}
_renderToolMessage(assistantMessage, toolCall, result = null, meta = null) {
const $msg = this.$tplToolMessage.content.cloneNode(true).firstElementChild;
const toolBubbleId = result?.id || toolCall.id;
$msg.dataset.id = toolBubbleId;
$msg.dataset.parentId = assistantMessage.id;
$msg.dataset.field = 'tool';
$msg.dataset.messageId = assistantMessage.id;
$msg.dataset.toolCallId = toolCall.id;
$msg.querySelector('.message__collapsible-label').textContent =
toolCall.function?.name || 'Tool Call';
const $args = $msg.querySelector('[data-args]');
try {
const args = JSON.parse(toolCall.function?.arguments || '{}');
$args.textContent = JSON.stringify(args, null, 2);
} catch {
$args.textContent = toolCall.function?.arguments || '';
}
if (result) {
const $outputSection = $msg.querySelector(
'.message__tool-section--output',
);
$outputSection.hidden = false;
const $output = $msg.querySelector('[data-output]');
try {
const output = JSON.parse(extractText(result.content) || '{}');
$output.textContent = JSON.stringify(output, null, 2);
} catch {
$output.textContent = extractText(result.content) || '';
}
}
// Set copy text.
const copyParts = [];
const name = toolCall.function?.name || 'unknown';
const args = toolCall.function?.arguments || '{}';
copyParts.push(`${name}(${args})`);
if (result) {
copyParts.push(extractText(result.content) || '');
}
$msg.dataset.copyText = copyParts.join('\n\n');
const $dl = $msg.querySelector('.message__debug-list');
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', '');
this.$chat.insertBefore($msg, this.$chatLoading);
this.scrollToBottom();
}
/**
* renderSystemMessage renders the system message block.
* @param {string} content
*/
renderSystemMessage(content) {
this._renderSystemMessage(content);
}
/**
* reRenderSystemMessage replaces the system message block in the DOM.
* @param {string} content
*/
reRenderSystemMessage(content) {
const $old = this.$chat.querySelector('[data-id="system"]');
if (!$old) return;
const $msg =
this.$tplSystemMessage.content.cloneNode(true).firstElementChild;
$msg.dataset.id = 'system';
const $content = $msg.querySelector('.message__content');
if (content?.trim()) {
$content.textContent = content;
$msg.dataset.copyText = content;
} else {
$content.innerHTML =
'<em class="message__placeholder">System Default</em>';
$msg.dataset.copyText = '';
}
$old.replaceWith($msg);
}
_renderSystemMessage(content, _meta = null) {
const $msg =
this.$tplSystemMessage.content.cloneNode(true).firstElementChild;
$msg.dataset.id = 'system';
const $content = $msg.querySelector('.message__content');
if (content?.trim()) {
$content.textContent = content;
$msg.dataset.copyText = content;
} else {
$content.innerHTML =
'<em class="message__placeholder">System Default</em>';
$msg.dataset.copyText = '';
}
this.$chat.insertBefore($msg, this.$chatLoading);
}
_renderImagePills($msg, message) {
if (!Array.isArray(message.content)) return;
const imageParts = message.content.filter((p) => p.type === 'image_url');
if (imageParts.length === 0) return;
const $wrapper = document.createElement('div');
$wrapper.className = 'message__image-pills';
for (const part of imageParts) {
const $pill = this.$tplImagePill.content.cloneNode(true);
$pill.querySelector('.message__image-pill-name').textContent =
part.filename || 'Image';
$wrapper.appendChild($pill.firstElementChild);
}
$msg
.querySelector('.message__body')
.insertBefore($wrapper, $msg.querySelector('.message__actions'));
}
_populateDebugPanel($dl, meta) {
if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice);
if (meta?.usage) {
const u = meta.usage;
this._appendDebugRow($dl, 'Prompt tokens', String(u.prompt_tokens));
this._appendDebugRow(
$dl,
'Completion tokens',
String(u.completion_tokens),
);
this._appendDebugRow($dl, 'Total tokens', String(u.total_tokens));
if (u.completion_tokens_details?.reasoning_tokens > 0)
this._appendDebugRow(
$dl,
'Reasoning tokens',
String(u.completion_tokens_details.reasoning_tokens),
);
if (u.prompt_tokens_details?.cached_tokens > 0)
this._appendDebugRow(
$dl,
'Cached tokens',
String(u.prompt_tokens_details.cached_tokens),
);
}
if (meta?.timings) {
const t = meta.timings;
if (t.prompt_ms > 0)
this._appendDebugRow(
$dl,
'Prompt eval',
`${t.prompt_ms.toFixed(1)} ms (${t.prompt_per_second.toFixed(0)} tok/s)`,
);
if (t.predicted_ms > 0)
this._appendDebugRow(
$dl,
'Predicted eval',
`${t.predicted_ms.toFixed(1)} ms (${t.predicted_per_second.toFixed(0)} tok/s)`,
);
}
}
_appendDebugRow($dl, label, value) {
const $row = this.$tplDebugRow.content.cloneNode(true);
$row.querySelector('dt').textContent = label;
$row.querySelector('dd').textContent = value;
$dl.appendChild($row);
}
_icon(name) {
const svg = this.document.createElementNS(
'http://www.w3.org/2000/svg',
'svg',
);
svg.setAttribute('class', 'icon');
const use = this.document.createElementNS(
'http://www.w3.org/2000/svg',
'use',
);
use.setAttribute('href', `${ICONS_URL}#${name}`);
svg.appendChild(use);
return svg;
}
copyToClipboard($button, text) {
navigator.clipboard.writeText(text).then(
() => {
$button.replaceChildren(this._icon('check'));
$button.classList.add('message__action-btn--success');
setTimeout(() => {
$button.replaceChildren(this._icon('copy'));
$button.classList.remove('message__action-btn--success');
}, 1500);
},
(e) => console.error('failed to copy:', e),
);
}
}
// Configure marked to use highlight.js for syntax highlighting.
// marked v8+ removed the highlight option from setOptions; use the
// marked-highlight extension instead.
if (
typeof marked !== 'undefined' &&
typeof markedHighlight !== 'undefined' &&
typeof hljs !== 'undefined'
) {
marked.use(
markedHighlight.markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
},
}),
);
}
-350
View File
@@ -1,350 +0,0 @@
/**
* 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;
}
}
+184
View File
@@ -0,0 +1,184 @@
/**
* Storage reads and writes the browser's local cache and the saved
* client settings. It is the only module that touches localStorage.
* It does no rendering and sends no requests: main.js decides what
* to cache and when, and Storage only stores and returns it.
*
* Keys and their contents:
* - odidere_conversation: the selected conversation ID and its saved
* message rows, for offline viewing and a fast start. The server
* owns the history; this entry is a copy, replaced by every sync.
* - odidere_system_message: the system message used when the next
* conversation is created. It does not change a conversation that
* already exists.
* - odidere_provider, odidere_model, odidere_voice: selections.
* - odidere_max_turns, odidere_reasoning_budget: compose settings.
* - odidere_mute: whether speech playback is muted.
*
* The browser-only history that older versions kept under
* odidere_history is not read and is left untouched.
*/
const CONVERSATION_KEY = 'odidere_conversation';
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
const PROVIDER_KEY = 'odidere_provider';
const MODEL_KEY = 'odidere_model';
const VOICE_KEY = 'odidere_voice';
const MAX_TURNS_KEY = 'odidere_max_turns';
const REASONING_BUDGET_KEY = 'odidere_reasoning_budget';
const MUTE_KEY = 'odidere_mute';
export class Storage {
/**
* loadConversation returns the cached selection: the conversation
* ID and its message rows in saved order. Rows are stored under
* their message IDs; the array order is the saved order.
* @returns {{id: string, rows: Array<Object>} | null}
*/
loadConversation() {
try {
const cached = JSON.parse(localStorage.getItem(CONVERSATION_KEY));
if (!cached?.id || !Array.isArray(cached.rows)) return null;
return { id: cached.id, rows: cached.rows };
} catch (error) {
console.error('failed to load conversation cache:', error);
return null;
}
}
/**
* saveConversation caches the selected conversation and its rows.
* The server owns the history; this is a local copy for offline
* viewing. Streaming drafts are never passed to this method.
* @param {string} id
* @param {Array<Object>} rows
*/
saveConversation(id, rows) {
try {
localStorage.setItem(CONVERSATION_KEY, JSON.stringify({ id, rows }));
} catch (error) {
console.error('failed to cache conversation:', error);
}
}
/**
* clearConversation drops the cached selection. It does not
* change or delete the conversation on the server.
*/
clearConversation() {
try {
localStorage.removeItem(CONVERSATION_KEY);
} catch (error) {
console.error('failed to clear conversation cache:', error);
}
}
// --- System message ---
/** @returns {string} the saved default system message, or '' */
getSystemMessage() {
return localStorage.getItem(SYSTEM_MESSAGE_KEY) || '';
}
/**
* setSystemMessage stores the default system message for the next
* conversation. It does not change any existing conversation.
* @param {string} value
*/
setSystemMessage(value) {
localStorage.setItem(SYSTEM_MESSAGE_KEY, value);
}
// --- Model and voice ---
/** @returns {{provider: string | null, model: string | null}} */
getModelSelection() {
return {
provider: localStorage.getItem(PROVIDER_KEY),
model: localStorage.getItem(MODEL_KEY),
};
}
/**
* setModelSelection stores the selected provider and model.
* @param {{provider: string, model: string}} selection
*/
setModelSelection({ provider, model }) {
localStorage.setItem(PROVIDER_KEY, provider);
localStorage.setItem(MODEL_KEY, model);
}
/** @returns {string} the selected voice, or '' for automatic */
getVoice() {
return localStorage.getItem(VOICE_KEY) || '';
}
/**
* setVoice stores the selected voice. '' selects the default.
* @param {string} voice
*/
setVoice(voice) {
localStorage.setItem(VOICE_KEY, voice);
}
// --- Compose settings ---
/**
* getMaxIterations returns the agent loop limit. Zero means no
* limit: the model replies without tool calls or the run fails.
* @returns {number}
*/
getMaxIterations() {
const stored = Number(localStorage.getItem(MAX_TURNS_KEY));
return Number.isFinite(stored) && stored > 0 ? stored : 0;
}
/**
* setMaxIterations stores the agent loop limit. Zero means no
* limit.
* @param {number} value
*/
setMaxIterations(value) {
localStorage.setItem(MAX_TURNS_KEY, String(value));
}
/**
* getReasoningBudget returns the llama.cpp reasoning token budget,
* or null when unset. -1 means unlimited and 0 means off.
* @returns {number | null}
*/
getReasoningBudget() {
const stored = localStorage.getItem(REASONING_BUDGET_KEY);
if (stored === null) return null;
const value = Number(stored);
return Number.isFinite(value) ? value : null;
}
/**
* setReasoningBudget stores the reasoning token budget. null
* removes the setting and leaves the choice to the server.
* @param {number | null} value
*/
setReasoningBudget(value) {
if (value === null) {
localStorage.removeItem(REASONING_BUDGET_KEY);
} else {
localStorage.setItem(REASONING_BUDGET_KEY, String(value));
}
}
// --- Mute ---
/** @returns {boolean} whether speech playback is muted */
getMuted() {
return localStorage.getItem(MUTE_KEY) === 'true';
}
/**
* setMuted stores the speech mute state.
* @param {boolean} muted
*/
setMuted(muted) {
localStorage.setItem(MUTE_KEY, String(muted));
}
}
File diff suppressed because it is too large Load Diff