Refactor frontend
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+1228
-1311
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user