Files
odidere/internal/service/static/js/main.js
T

1605 lines
50 KiB
JavaScript
Raw Normal View History

2026-07-04 12:44:59 +00:00
/**
2026-10-30 22:42:51 +00:00
* Odidere is the composition root. It owns the in-memory state: the
* selected conversation, the cache of its saved messages, and the
* unfinished streamed output. It wires the other modules together:
* api.js sends every request, storage.js holds the local cache and
* settings, ui.js renders and reports user actions, and the voice
* modules (stt.js, tts.js, wakelock.js) record, speak, and keep the
* screen on.
*
* Two event streams feed the same handler: the response of the
* inference request, and the subscription used while idle. Both
* deliver saved messages, streaming fragments, and a terminal done
* event. Saved rows enter the cache and localStorage; streamed
* fragments build drafts, which render live and are discarded when
* their stream ends without a save.
2026-07-04 12:44:59 +00:00
*/
2026-10-30 22:42:51 +00:00
import { Api, ApiError } from './api.js';
import { Storage } from './storage.js';
2026-07-04 12:44:59 +00:00
import { STT, VOICE_MAP } from './stt.js';
import { TTSPlayer } from './tts.js';
2026-10-30 23:06:09 +00:00
import { conversationLabel, extractText, UI } from './ui.js';
2026-07-04 12:44:59 +00:00
import { WakeLock } from './wakelock.js';
2026-10-30 22:42:51 +00:00
// The tool result saved when the user denies a tool call. A denied
// call with no result makes every later run refuse the whole batch
// with 'will not retry'; the note gives the model an answer.
const DENIAL_NOTE = 'The user denied this tool call.';
2026-07-04 12:44:59 +00:00
2026-10-30 22:42:51 +00:00
// Coarse accuracy is sufficient for chat context. The 30 s cache
// avoids a fresh GPS fix on repeated taps.
const GEOLOCATION_OPTIONS = {
2026-07-05 01:56:14 +00:00
enableHighAccuracy: false,
timeout: 20_000,
maximumAge: 30_000,
};
2026-02-13 15:03:02 +00:00
class Odidere {
constructor({ document }) {
2026-10-30 22:42:51 +00:00
this.api = new Api();
this.storage = new Storage();
this.ui = new UI({
document,
handlers: {
onComposeInput: () => this.updateSendEnabled(),
onComposeSettings: () =>
this.ui.openComposeSettings({
maxIterations: this.storage.getMaxIterations(),
reasoningBudget: this.storage.getReasoningBudget(),
}),
onComposeSettingsChange: ({ maxIterations, reasoningBudget }) => {
this.storage.setMaxIterations(maxIterations);
this.storage.setReasoningBudget(
Number.isFinite(reasoningBudget) ? reasoningBudget : null,
);
},
onComposeSubmit: () => this.submit(),
onModelChange: ({ provider, model }) => {
this.storage.setModelSelection({ provider, model });
this.ui.updateContext(
this.totalTokens,
this.ui.getSelectedModel().contextSize,
);
},
onVoiceChange: (voice) => {
this.voice = voice;
this.storage.setVoice(voice);
},
onSettingsOpen: () =>
this.ui.setSystemMessageDraft(this.storage.getSystemMessage()),
onSaveSystemMessage: (text) => this.storage.setSystemMessage(text),
2026-10-31 12:13:36 +00:00
onConversationsOpen: () => {
this.conversationQuery = '';
this.loadConversations();
},
onConversationSearch: (query) => {
this.conversationQuery = query;
this.filterConversations(query);
},
2026-10-30 23:06:09 +00:00
onSelectConversation: (id) => this.switchConversation(id),
2026-10-31 12:13:36 +00:00
onRenameConversation: (id) => this.renameConversation(id),
onDeleteConversation: (id) => this.deleteConversation(id),
2026-10-31 17:20:37 +00:00
onNewConversation: () => this.newConversation(),
2026-10-30 22:42:51 +00:00
onPushToTalkStart: () => this.handlePushToTalkStart(),
onPushToTalkEnd: () => this.handlePushToTalkEnd(),
onTranscribeStart: () => this.handleTranscribeStart(),
onTranscribeEnd: () => this.handleTranscribeEnd(),
onAttachFiles: (files) => this.handleAttachments(files),
onLocationToggle: () => this.toggleLocation(),
onToggleMute: () => this.toggleMute(),
onPlay: (id) => this.play(id),
onEdit: (target) => this.editTarget(target),
onDelete: (target) => this.deleteTarget(target),
onApprove: (execution) => this.approve(execution),
onDeny: (execution) => this.deny(execution),
onReset: () => this.reset(),
onStop: () => this.stop(),
},
});
2026-02-13 15:03:02 +00:00
2026-07-04 12:44:59 +00:00
this.stt = new STT({
url: document.querySelector('meta[name="stt-url"]')?.content || '',
});
this.tts = new TTSPlayer({
url: document.querySelector('meta[name="tts-url"]')?.content || '',
defaultVoice:
document.querySelector('meta[name="tts-default-voice"]')?.content || '',
});
this.wakeLock = new WakeLock();
2026-10-30 22:42:51 +00:00
// The playback buttons follow the TTS queue.
this.tts.addEventListener('playbackstart', (event) =>
this.ui.setPlayButton(event.detail.messageId, true),
2026-07-04 12:44:59 +00:00
);
2026-10-30 22:42:51 +00:00
this.tts.addEventListener('playbackend', (event) =>
this.ui.setPlayButton(event.detail.messageId, false),
2026-07-04 12:44:59 +00:00
);
2026-10-30 22:42:51 +00:00
// --- In-memory state ---
this.conversationId = null;
2026-10-30 23:06:09 +00:00
// The rows the conversation picker shows, fetched on open.
this.conversations = [];
2026-10-31 12:13:36 +00:00
// The picker's current search text. A rename or delete keeps
// it, so the list re-renders with the same filter.
this.conversationQuery = '';
2026-10-30 22:42:51 +00:00
this.messages = new Map();
this.drafts = new Map();
this.busy = false;
2026-07-04 12:44:59 +00:00
this.attachments = [];
this.locationData = null;
2026-10-30 22:42:51 +00:00
this.totalTokens = 0;
this.voice = this.storage.getVoice();
this.lastVoice = '';
this.isRecording = false;
this.isTranscribing = false;
this.isMuted = this.storage.getMuted();
2026-06-13 18:55:16 +00:00
2026-10-30 22:42:51 +00:00
// One controller per open stream. The sync controller is
// aborted whenever a run starts; the run controller is aborted
// only by reset, which deletes the conversation.
this.runController = null;
this.syncController = null;
// The promise of the run in flight, so reset can await it.
this.currentRun = null;
2026-05-28 00:53:08 +00:00
2026-10-30 22:42:51 +00:00
this.init().catch((error) =>
this.ui.renderError(`Startup failed: ${error.message}`),
);
2026-02-13 15:03:02 +00:00
}
// ====================
2026-07-04 12:44:59 +00:00
// LIFECYCLE
2026-02-13 15:03:02 +00:00
// ====================
2026-07-04 12:44:59 +00:00
2026-10-30 22:42:51 +00:00
async init() {
const cached = this.storage.loadConversation();
if (cached) {
this.conversationId = cached.id;
for (const row of cached.rows) this.messages.set(row.id, row);
this.ui.renderConversation(this.orderedRows());
} else {
this.ui.renderSystemDefault(this.storage.getSystemMessage());
2026-02-13 15:03:02 +00:00
}
2026-10-30 22:42:51 +00:00
if (this.isMuted) this.tts.setMuted(true);
this.ui.setMuted(this.isMuted);
this.updateSendEnabled();
this.loadModels();
this.loadVoices();
this.bindLifecycle();
if (this.conversationId) await this.sync();
2026-02-13 15:03:02 +00:00
}
2026-10-30 22:42:51 +00:00
/** Stop every activity. Called on page teardown. */
2026-07-04 12:44:59 +00:00
destroy() {
this.stt.destroy();
this.tts.stop();
2026-10-30 22:42:51 +00:00
this.runController?.abort();
this.syncController?.abort();
2026-07-04 12:44:59 +00:00
this.wakeLock.release();
}
2026-02-13 15:03:02 +00:00
2026-10-30 22:42:51 +00:00
/** React to connectivity and focus, which end or miss streams. */
bindLifecycle() {
document.addEventListener('visibilitychange', () => {
this.wakeLock.handleVisibilityChange(this.isRecording || this.busy);
if (!document.hidden) this.sync();
});
window.addEventListener('online', () => this.sync());
}
// ====================
// STATE
// ====================
2026-07-05 14:14:04 +00:00
/**
2026-10-30 22:42:51 +00:00
* orderedRows returns the cached rows in the order they were
* saved. The server sends rows in saved order on every stream,
* and the map keeps that order through updates.
* @returns {Array<Object>}
2026-07-05 14:14:04 +00:00
*/
2026-10-30 22:42:51 +00:00
orderedRows() {
return [...this.messages.values()];
2026-02-13 15:03:02 +00:00
}
2026-10-30 22:42:51 +00:00
/**
* isOrdered reports whether the cached rows are still in saved
* order. A row another client inserted or deleted can shift the
* sequences of rows the sync diff does not re-send, because their
* updated_at did not change; the check catches that.
*/
isOrdered() {
let previous = -Infinity;
for (const row of this.messages.values()) {
if (row.sequence <= previous) return false;
previous = row.sequence;
}
return true;
}
2026-02-13 15:03:02 +00:00
2026-10-30 22:42:51 +00:00
/**
* cacheRow stores one saved row and persists the cache. voice
* tags the row for the play button; it is client state, not part
* of the saved message.
* @param {Object} row - a message row from a stream or a mutation.
* @param {string} [voice]
* @param {boolean} [persist] - set false to persist once, after a
* batch of rows.
*/
cacheRow(row, voice = '', persist = true) {
const previous = this.messages.get(row.id);
this.messages.set(row.id, {
id: row.id,
role: row.role,
sequence: row.sequence,
updated_at: row.updated_at,
content: row.content,
metadata: row.metadata ?? null,
voice: voice || previous?.voice || '',
2026-05-28 00:53:08 +00:00
});
2026-10-30 22:42:51 +00:00
if (persist) this.persistCache();
}
persistCache() {
if (this.conversationId) {
this.storage.saveConversation(this.conversationId, this.orderedRows());
}
}
/**
* resultsFor returns the saved tool results of one assistant
* message: the tool rows between it and the next assistant
* message whose calls they answer.
* @param {Object} row - an assistant message row.
* @returns {Array<Object>}
*/
resultsFor(row) {
if (row.role !== 'assistant') return [];
const callIds = new Set(
(row.content?.tool_calls ?? []).map((call) => call.id),
);
const results = [];
let after = false;
for (const candidate of this.orderedRows()) {
if (candidate.id === row.id) {
after = true;
continue;
2026-05-28 00:53:08 +00:00
}
2026-10-30 22:42:51 +00:00
if (!after) continue;
if (candidate.role === 'assistant') break;
if (
candidate.role === 'tool' &&
callIds.has(candidate.content?.tool_call_id)
) {
results.push(candidate);
}
}
return results;
}
2026-06-09 23:17:55 +00:00
2026-10-30 22:42:51 +00:00
/**
* findResultRow returns the saved result of one call, or null.
* @param {Object} row - the assistant message row.
* @param {string} callId
*/
findResultRow(row, callId) {
for (const candidate of this.resultsFor(row)) {
if (candidate.content?.tool_call_id === callId) return candidate;
}
return null;
}
2026-07-05 17:55:07 +00:00
2026-10-30 22:42:51 +00:00
/**
* findParentRow returns the assistant message row that asked for
* one tool result, or null when no cached assistant has the call.
* @param {Object} resultRow - a tool result row.
*/
findParentRow(resultRow) {
const callId = resultRow.content?.tool_call_id;
for (const row of this.orderedRows()) {
if (
row.role === 'assistant' &&
(row.content?.tool_calls ?? []).some((call) => call.id === callId)
) {
return row;
}
}
return null;
2026-02-13 15:03:02 +00:00
}
2026-06-12 19:26:30 +00:00
// ====================
2026-10-30 22:42:51 +00:00
// EVENT HANDLING
2026-06-13 18:55:16 +00:00
// ====================
2026-06-12 19:26:30 +00:00
2026-10-30 22:42:51 +00:00
/**
* handleEvent processes one stream event. It returns true after
* a done event, which ends a stream.
*
* voice tags assistant messages of this run for the play button.
* It is empty for events of another client's run, whose voice is
* not known here.
*
* @param {string} type
* @param {Object} data
* @param {string} [voice]
* @returns {boolean} true when the stream is finished.
*/
handleEvent(type, data, voice = '') {
switch (type) {
case 'message_saved': {
const row = data.message;
const existed = this.messages.has(row.id);
this.cacheRow(row, voice);
this.applySavedRow(row, existed, voice);
this.trackUsage(row.metadata?.usage);
return false;
}
case 'message_deleted': {
const row = this.messages.get(data.message_id);
this.messages.delete(data.message_id);
this.persistCache();
if (!row) return false;
if (row.role === 'tool') {
this.ui.clearToolOutput(row);
} else {
this.ui.removeMessageBubbles(data.message_id);
}
return false;
}
case 'assistant_update': {
const draft = this.applyChunk(data.message_id, data.chunk);
this.ui.renderDraft(draft);
this.trackUsage(data.chunk?.usage);
return false;
}
case 'done':
// An error here ended the run; the saved rows it produced
// are already in the cache.
if (data.error) this.ui.renderError(data.error);
return true;
default:
return false;
}
}
2026-02-13 15:03:02 +00:00
2026-10-30 22:42:51 +00:00
/**
* applySavedRow renders one saved row. A row that updates an
* existing one re-renders the whole chat, because the bubble for
* the old content is already on screen.
* @param {Object} row
* @param {boolean} existed - whether the row was already cached.
* @param {string} voice - the voice of this run, '' when unknown.
*/
applySavedRow(row, existed, voice = '') {
const wasDraft = this.drafts.delete(row.id);
if (row.role === 'tool') {
this.ui.attachToolResult(
this.findParentRow(row),
row.content?.tool_call_id,
row,
);
2026-07-04 12:44:59 +00:00
return;
2026-02-13 15:03:02 +00:00
}
2026-10-30 22:42:51 +00:00
if (existed || wasDraft) {
if (wasDraft) {
this.ui.replaceDraftWithSaved(row.id, row, this.resultsFor(row));
2026-06-12 19:26:30 +00:00
} else {
2026-10-30 22:42:51 +00:00
this.ui.renderConversation(this.orderedRows());
2026-06-12 19:26:30 +00:00
}
2026-10-30 22:42:51 +00:00
} else {
this.ui.renderMessages([row, ...this.resultsFor(row)]);
if (row.role === 'user') {
// Anchor the new user message, so the reply grows below
// it; while following, stay at the bottom instead.
if (this.ui.isAutoScrollEnabled) {
this.ui.scrollToBottom();
2026-07-04 12:44:59 +00:00
} else {
2026-10-30 22:42:51 +00:00
this.ui.scrollToLastUserMessage();
2026-07-04 12:44:59 +00:00
}
}
}
2026-10-30 22:42:51 +00:00
if (row.role === 'assistant') {
const text = extractText(row.content?.content);
const cached = this.messages.get(row.id);
if (text) {
this.tts.enqueue(text, cached?.voice || voice, row.id);
}
}
2026-02-13 15:03:02 +00:00
}
/**
2026-10-30 22:42:51 +00:00
* applyChunk accumulates one streaming fragment into the draft of
* its assistant message and returns the draft. Choices and
* tool-call indices belong to stream assembly; saved rows are
* ordered by the server.
* @param {string} draftId - the message ID the server allocated
* before the first fragment.
* @param {Object} chunk - a provider stream chunk.
2026-02-13 15:03:02 +00:00
*/
2026-10-30 22:42:51 +00:00
applyChunk(draftId, chunk) {
let draft = this.drafts.get(draftId);
if (!draft) {
draft = {
id: draftId,
reasoning: '',
text: '',
refusal: '',
toolCalls: [],
2026-07-05 14:14:04 +00:00
};
2026-10-30 22:42:51 +00:00
this.drafts.set(draftId, draft);
}
for (const choice of chunk?.choices ?? []) {
const delta = choice.delta ?? {};
draft.text += delta.content ?? '';
draft.refusal += delta.refusal ?? '';
// Providers spell the reasoning field two ways; both carry
// fragments of the same stream.
draft.reasoning +=
(delta.reasoning_content ?? '') + (delta.reasoning ?? '');
for (const fragment of delta.tool_calls ?? []) {
const index = fragment.index ?? 0;
let call = draft.toolCalls[index];
if (!call) {
call = {
id: '',
type: 'function',
function: { name: '', arguments: '' },
};
draft.toolCalls[index] = call;
2026-07-04 12:44:59 +00:00
}
2026-10-30 22:42:51 +00:00
if (fragment.id) call.id = fragment.id;
if (fragment.type) call.type = fragment.type;
call.function.name += fragment.function?.name ?? '';
call.function.arguments += fragment.function?.arguments ?? '';
2026-06-30 11:44:19 +00:00
}
2026-10-30 22:42:51 +00:00
}
return draft;
}
/** Discard every draft; none of them was saved. */
clearDrafts() {
for (const id of this.drafts.keys()) this.ui.removeDraft(id);
this.drafts.clear();
}
/**
* trackUsage keeps the newest reported token total. Streaming
* usage reports replace earlier ones; they are not increments.
* @param {Object} [usage]
*/
trackUsage(usage) {
if (usage?.total_tokens == null) return;
this.totalTokens = usage.total_tokens;
this.ui.updateContext(
this.totalTokens,
this.ui.getSelectedModel().contextSize,
);
}
// ====================
// SEND
// ====================
/**
* submit sends the compose text. Called by the send button and
* the Enter key.
*/
async submit() {
if (this.busy || this.isRecording || this.isTranscribing) return;
const text = this.ui.takeComposeText();
await this.send({ text, voice: this.resolveVoice() });
}
/**
* send starts one run. With input, the run saves it first; with
* none, it continues the saved history, which is how a run
* resumes after an approval or denial.
*
* The compose text is taken before the request and restored when
* the request fails before a stream opens, so nothing the user
* typed is lost. Attachments and location stay until then too.
*
* @param {Object} [options]
* @param {string} [options.text] - new user input, may be empty.
* @param {string} [options.voice] - voice for the replies.
*/
async send({ text = '', voice = this.voice } = {}) {
if (this.busy) return;
const hasInput =
Boolean(text) ||
this.attachments.length > 0 ||
this.locationData !== null;
if (!hasInput && this.messages.size === 0) return;
const run = this.run({ text, voice });
this.currentRun = run;
try {
await run;
} finally {
if (this.currentRun === run) this.currentRun = null;
}
}
/**
* run performs one inference request and consumes its stream.
* Only send starts a run; it awaits this promise.
*/
async run({ text, voice }) {
this.tts.stop();
this.lastVoice = voice;
// End the idle subscription; the run's stream takes over.
this.syncController?.abort();
let message;
const hasInput =
Boolean(text) ||
this.attachments.length > 0 ||
this.locationData !== null;
if (hasInput) {
try {
message = await this.buildMessage(text);
} catch (error) {
this.ui.appendComposeText(text);
this.ui.renderError(`Failed to build message: ${error.message}`);
return;
}
}
if (!this.conversationId) {
try {
const conversation = await this.api.createConversation({
systemMessage: this.storage.getSystemMessage() || undefined,
});
this.conversationId = conversation.id;
this.persistCache();
} catch (error) {
this.ui.appendComposeText(text);
this.ui.renderError(`Failed to create conversation: ${error.message}`);
return;
}
}
const controller = new AbortController();
this.runController = controller;
this.busy = true;
this.ui.setBusy(true);
this.ui.setPendingApprovals([]);
// Keep the screen on while the run streams.
this.wakeLock.acquire();
// Consume the input once the server accepted the request: an
// earlier failure keeps it for a retry.
let consumed = false;
const consumeInput = () => {
if (consumed) return;
consumed = true;
this.attachments = [];
this.locationData = null;
this.renderAttachments();
this.ui.setLocationActive(false);
this.updateSendEnabled();
};
2026-07-05 14:14:04 +00:00
try {
2026-10-30 22:42:51 +00:00
const body = { stream: true };
if (message) body.message = message;
const { provider, model } = this.ui.getSelectedModel();
if (provider) body.provider = provider;
if (model) body.model = model;
const maxIterations = this.storage.getMaxIterations();
if (maxIterations > 0) body.max_iterations = maxIterations;
const budget = this.storage.getReasoningBudget();
if (budget !== null) body.thinking_budget_tokens = budget;
for await (const { type, data } of this.api.inference(
this.conversationId,
body,
controller.signal,
)) {
consumeInput();
if (this.handleEvent(type, data, voice)) break;
}
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
2026-11-01 13:33:38 +00:00
if (hasInput) {
this.ui.renderError(
'This conversation is already working in another session.',
);
this.ui.appendComposeText(text);
}
2026-10-30 22:42:51 +00:00
} else if (error.name !== 'AbortError') {
console.error('inference failed:', error);
this.ui.renderError(`Inference failed: ${error.message}`);
this.ui.appendComposeText(text);
}
// An AbortError ends delivery only; reset deleted the
// conversation and will clear the local state.
2026-07-05 14:14:04 +00:00
} finally {
2026-10-30 22:42:51 +00:00
if (this.runController === controller) this.runController = null;
this.clearDrafts();
this.busy = false;
this.ui.setBusy(false);
this.updateSendEnabled();
this.wakeLock.release();
if (this.conversationId && !controller.signal.aborted) {
await this.sync();
2026-07-04 12:44:59 +00:00
}
}
}
2026-10-30 22:42:51 +00:00
/**
* buildMessage builds the user message from text, attachments,
* and location. Text files are appended to the text; images
* become data-URL parts.
* @param {string} text
*/
async buildMessage(text) {
2026-02-13 15:03:02 +00:00
const textParts = [];
const imageParts = [];
2026-10-30 22:42:51 +00:00
for (const file of this.attachments) {
2026-02-13 15:03:02 +00:00
if (file.type.startsWith('image/')) {
2026-07-04 12:44:59 +00:00
const buffer = await file.arrayBuffer();
const base64 = new Uint8Array(buffer).toBase64();
2026-02-13 15:03:02 +00:00
imageParts.push({
2026-10-30 22:42:51 +00:00
type: 'image_url',
image_url: { url: `data:${file.type};base64,${base64}` },
2026-02-13 15:03:02 +00:00
});
} else {
const content = await file.text();
textParts.push(`=== File: ${file.name} ===\n\n${content}`);
}
}
2026-10-30 22:42:51 +00:00
if (text) textParts.push(text);
2026-06-29 18:13:57 +00:00
if (this.locationData) {
2026-07-04 12:44:59 +00:00
textParts.push(JSON.stringify(this.locationData, null, 2));
2026-06-29 18:13:57 +00:00
}
2026-10-30 22:42:51 +00:00
const combined = textParts.join('\n\n');
2026-02-13 15:03:02 +00:00
if (imageParts.length === 0) {
2026-10-30 22:42:51 +00:00
return { role: 'user', content: combined };
2026-02-13 15:03:02 +00:00
}
const parts = [];
2026-10-30 22:42:51 +00:00
if (combined) parts.push({ type: 'text', text: combined });
2026-02-13 15:03:02 +00:00
parts.push(...imageParts);
2026-10-30 22:42:51 +00:00
return { role: 'user', content: parts };
}
2026-02-13 15:03:02 +00:00
2026-10-30 22:42:51 +00:00
/**
* resolveVoice picks the voice for replies: the selection, or the
* default voice of the transcribed language when the selection
* is automatic.
* @param {string} [detectedLanguage]
*/
resolveVoice(detectedLanguage = '') {
if (this.voice) return this.voice;
return detectedLanguage ? (VOICE_MAP[detectedLanguage] ?? '') : '';
2026-02-13 15:03:02 +00:00
}
2026-07-04 12:44:59 +00:00
// ====================
2026-10-30 22:42:51 +00:00
// STOP AND RESET
2026-07-04 12:44:59 +00:00
// ====================
2026-10-30 22:42:51 +00:00
/**
* stop asks the server to cancel the ongoing inference. The open
* stream keeps delivering events until done, so the busy state
* clears in the run's finally block.
*/
async stop() {
if (!this.busy || !this.conversationId) return;
try {
await this.api.stopConversation(this.conversationId);
} catch (error) {
this.ui.renderError(`Stop was not confirmed: ${error.message}`);
2026-07-04 12:44:59 +00:00
}
2026-10-30 22:42:51 +00:00
}
/**
* reset deletes the selected conversation permanently, with all
* its messages and tool execution records, and starts fresh. The
* two-press confirmation in the UI is the only guard.
*/
async reset() {
this.tts.stop();
const id = this.conversationId;
2026-10-31 17:20:37 +00:00
await this.detachConversation();
2026-10-30 22:42:51 +00:00
if (id) {
try {
await this.api.deleteConversation(id);
} catch (error) {
// The conversation survives on the server, but the local
// selection is still cleared.
this.ui.renderError(`Failed to delete conversation: ${error.message}`);
}
}
2026-10-31 12:13:36 +00:00
this.clearLocalConversation();
}
/**
* clearLocalConversation drops every local trace of the selected
* conversation: the cached messages, the drafts, the storage, and
* the chat view. It sends no request. reset and the picker's
* delete call it once the conversation is gone on the server.
*/
clearLocalConversation() {
2026-10-30 22:42:51 +00:00
this.messages.clear();
this.drafts.clear();
this.storage.clearConversation();
this.totalTokens = 0;
this.lastVoice = '';
this.ui.setAutoScroll(false);
this.ui.clearChat();
this.ui.setPendingApprovals([]);
this.ui.renderSystemDefault(this.storage.getSystemMessage());
this.ui.updateContext(0, null);
this.updateSendEnabled();
2026-07-04 12:44:59 +00:00
}
2026-10-31 17:20:37 +00:00
/**
* detachConversation unselects the conversation, aborts any open
* sync or inference stream, and waits for an in-flight run to
* finish its cleanup.
*
* It does not stop the run on the server; it only ends local
* stream delivery.
*
* conversationId is cleared before the aborts so that the ending
* streams do not persist the cache, open a new sync stream, or
* fetch approvals for the old conversation. Awaiting currentRun
* lets run's finally block clear busy and drafts before the caller
* clears local state or loads another conversation.
*/
async detachConversation() {
this.conversationId = null;
this.syncController?.abort();
this.runController?.abort();
if (this.currentRun) {
try {
await this.currentRun;
} catch {}
}
}
2026-10-30 23:06:09 +00:00
// ====================
// CONVERSATIONS
// ====================
/**
* loadConversations fetches the list for the picker and renders
* it. The server sends the rows most recently updated first, so
* the picker shows them in that order without sorting.
*/
async loadConversations() {
try {
this.conversations = await this.api.listConversations();
} catch (error) {
this.ui.renderError(`Failed to list conversations: ${error.message}`);
this.ui.closeConversations();
return;
}
this.ui.renderConversationList(this.conversations, {
currentId: this.conversationId,
});
}
/**
* filterConversations renders the conversations whose label
* matches the search text. The label is the title, or the
* creation date when the conversation has none.
* @param {string} query
*/
filterConversations(query) {
const needle = query.trim().toLowerCase();
const rows = needle
? this.conversations.filter((conversation) =>
conversationLabel(conversation).toLowerCase().includes(needle),
)
: this.conversations;
this.ui.renderConversationList(rows, {
currentId: this.conversationId,
});
}
/**
* switchConversation selects another conversation. The current
* one stays on the server; only the local view changes.
*
* The target is read first, so a conversation that another client
* deleted does not replace the one in view.
*
* A run in progress is detached, not stopped: the server keeps an
* accepted inference running after the client disconnects, and a
* later switch back re-attaches to it and replays what it saved.
*
* @param {string} id
*/
async switchConversation(id) {
this.ui.closeConversations();
if (id === this.conversationId) return;
try {
await this.api.getConversation(id);
} catch (error) {
this.ui.renderError(`Failed to open conversation: ${error.message}`);
return;
}
this.tts.stop();
// End delivery of the current streams. An accepted inference
// keeps running on the server; see the comment above.
this.syncController?.abort();
if (this.busy) {
this.runController?.abort();
if (this.currentRun) {
try {
await this.currentRun;
2026-10-31 12:13:36 +00:00
} catch {}
2026-10-30 23:06:09 +00:00
}
}
// Drop the local state of the old selection. resync reads and
// renders the whole history of the new selection and caches it.
this.messages.clear();
this.clearDrafts();
this.totalTokens = 0;
this.lastVoice = '';
this.storage.clearConversation();
this.ui.setAutoScroll(false);
this.ui.clearChat();
this.ui.setPendingApprovals([]);
this.ui.updateContext(0, null);
this.conversationId = id;
await this.resync();
this.updateSendEnabled();
}
2026-10-31 12:13:36 +00:00
/**
* renameConversation asks the user for a new title, saves it, and
* re-renders the picker with the current search filter. The saved
* row comes from the server, so the list shows exactly what was
* stored.
* @param {string} id
*/
async renameConversation(id) {
const row = this.conversations.find((c) => c.id === id);
if (!row) return;
const title = await this.ui.editConversationTitle(row.title ?? '');
if (title == null) return;
try {
const saved = await this.api.renameConversation(id, title);
row.title = saved.title;
} catch (error) {
this.ui.renderError(`Failed to rename conversation: ${error.message}`);
return;
}
this.filterConversations(this.conversationQuery);
}
/**
* deleteConversation removes one conversation from the server and
* from the picker. The two-press confirmation in the UI is the
* only guard. Deleting the conversation in view also clears the
* local state, like reset does.
* @param {string} id
*/
async deleteConversation(id) {
try {
await this.api.deleteConversation(id);
} catch (error) {
this.ui.renderError(`Failed to delete conversation: ${error.message}`);
return;
}
this.conversations = this.conversations.filter((c) => c.id !== id);
if (id === this.conversationId) {
2026-10-31 17:20:37 +00:00
// The server-side delete cancels an ongoing inference.
2026-10-31 12:13:36 +00:00
this.tts.stop();
2026-10-31 17:20:37 +00:00
await this.detachConversation();
2026-10-31 12:13:36 +00:00
this.clearLocalConversation();
}
this.filterConversations(this.conversationQuery);
}
2026-10-31 17:20:37 +00:00
/**
* newConversation closes the picker and clears the view for a
* new conversation. It sends no request: send() creates the
* conversation when the next message goes out, so an abandoned
* click leaves no empty conversation on the server. A run in
* flight on the old conversation keeps running there.
*/
async newConversation() {
this.ui.closeConversations();
if (!this.conversationId) return;
this.tts.stop();
await this.detachConversation();
this.clearLocalConversation();
}
2026-02-13 15:03:02 +00:00
// ====================
2026-10-30 22:42:51 +00:00
// SYNC
2026-02-13 15:03:02 +00:00
// ====================
2026-07-04 12:44:59 +00:00
2026-10-30 22:42:51 +00:00
/**
* sync subscribes with the cached rows as its manifest. The
* server sends what changed since they were saved, then the
* events of a run in progress, ending with done. Without a run
* in progress, the stream ends after the diff: the cache is then
* in step with the saved history until the next change.
*
* Re-subscribing is the reconnect path: it replays what a lost
* stream missed and re-attaches to a run still in progress.
*/
async sync() {
if (!this.conversationId || this.busy) return;
const controller = new AbortController();
this.syncController?.abort();
this.syncController = controller;
try {
const manifest = this.orderedRows().map(({ id, updated_at }) => ({
id,
updated_at,
}));
for await (const { type, data } of this.api.subscribe(
this.conversationId,
manifest,
controller.signal,
)) {
if (this.handleEvent(type, data)) break;
}
if (!this.isOrdered()) await this.resync();
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
this.ui.renderError('This conversation was deleted.');
await this.clearSelection();
return;
}
if (error.name !== 'AbortError') {
// Keep the cache; the next sync retries.
console.error('sync failed:', error);
}
} finally {
if (this.syncController === controller) this.syncController = null;
}
if (this.conversationId && !this.busy) {
await this.updateApprovals();
}
}
/**
* resync replaces the whole cache from the saved history. A
* structural change can shift rows the sync diff does not
* re-send, because their updated_at did not change; a full read
* restores the saved order.
*/
async resync() {
if (!this.conversationId || this.busy) return;
const controller = new AbortController();
this.syncController?.abort();
this.syncController = controller;
this.messages.clear();
this.drafts.clear();
try {
for await (const { type, data } of this.api.subscribe(
this.conversationId,
[],
controller.signal,
)) {
if (type === 'message_saved') {
this.cacheRow(data.message, '', false);
} else if (this.handleEvent(type, data)) {
break;
}
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error('resync failed:', error);
}
} finally {
if (this.syncController === controller) this.syncController = null;
this.persistCache();
}
this.ui.renderConversation(this.orderedRows());
if (this.conversationId && !this.busy) {
await this.updateApprovals();
}
}
/**
* clearSelection drops the local selection without a server
* request. Used when the server reports the conversation gone.
*/
async clearSelection() {
this.messages.clear();
this.drafts.clear();
this.storage.clearConversation();
this.conversationId = null;
this.totalTokens = 0;
this.ui.setAutoScroll(false);
this.ui.clearChat();
this.ui.setPendingApprovals([]);
this.ui.renderSystemDefault(this.storage.getSystemMessage());
this.ui.updateContext(0, null);
this.updateSendEnabled();
}
// ====================
// TOOL APPROVALS
// ====================
/**
* updateApprovals shows the approve and deny buttons on the tool
* calls of this conversation that wait for a decision. Other
* conversations, such as job runs, are not shown here.
*/
async updateApprovals() {
if (!this.conversationId) {
this.ui.setPendingApprovals([]);
return;
}
try {
const pending = await this.api.listPendingExecutions();
// Map each row to the fields the UI matches on; the rows
// arrive in the server's snake_case.
this.ui.setPendingApprovals(
pending
.filter((execution) => this.messages.has(execution.message_id))
.map((execution) => ({
executionId: execution.id,
messageId: execution.message_id,
toolCallId: execution.tool_call_id,
})),
);
} catch (error) {
console.error('failed to list pending executions:', error);
}
}
/**
* approve records an approval and continues the run without new
* input; the next inference executes the approved call.
* @param {{executionId: string}} execution
*/
async approve({ executionId }) {
if (this.busy || !this.conversationId) return;
try {
await this.api.decideExecution(executionId, 'approved');
} catch (error) {
this.ui.renderError(`Approval failed: ${error.message}`);
await this.updateApprovals();
return;
}
await this.send({ text: '', voice: this.lastVoice });
}
/**
* deny records a denial and answers the call with a denial note,
* so the conversation can continue. The decision alone would
* leave the call without a result, and every later run would
* refuse the batch with 'will not retry'. The note inserts a
* message, so the cache is replaced from the saved order before
* the run continues.
* @param {{executionId: string, messageId: string, toolCallId: string}} execution
*/
async deny({ executionId, messageId, toolCallId }) {
if (this.busy || !this.conversationId) return;
const assistant = this.messages.get(messageId);
if (!assistant) return;
try {
await this.api.decideExecution(executionId, 'denied');
} catch (error) {
this.ui.renderError(`Denial failed: ${error.message}`);
await this.updateApprovals();
return;
}
// Hide the buttons at once; a second press races the requests.
this.ui.setPendingApprovals(
this.ui.pendingApprovals.filter(
(execution) => execution.executionId !== executionId,
),
2026-06-13 00:44:01 +00:00
);
2026-10-30 22:42:51 +00:00
try {
await this.api.insertMessage(this.conversationId, {
message: {
role: 'tool',
tool_call_id: toolCallId,
content: DENIAL_NOTE,
},
message_id: messageId,
updated_at: assistant.updated_at,
});
} catch (error) {
this.reportMutationError(error);
return;
}
await this.resync();
await this.send({ text: '', voice: this.lastVoice });
2026-06-13 00:44:01 +00:00
}
2026-10-30 22:42:51 +00:00
// ====================
// EDITS
// ====================
/**
* editTarget edits one bubble: a message's text or reasoning, a
* tool call, or the system message. The system bubble of a saved
* conversation edits the saved system row; the placeholder shown
* without a conversation edits the default used to create the
* next one.
* @param {{id: string, field: string | null, toolCallId: string | null}} target
*/
async editTarget({ id, field, toolCallId }) {
if (id === 'system') {
const text = await this.ui.editText({
title: 'Edit System Message',
value: this.storage.getSystemMessage(),
});
if (text === null) return;
const trimmed = text.trim();
this.storage.setSystemMessage(trimmed);
this.ui.renderSystemDefault(trimmed);
return;
2026-06-09 01:32:49 +00:00
}
2026-10-30 22:42:51 +00:00
const row = this.messages.get(id);
if (!row) return;
if (field === 'tool' && toolCallId) {
await this.editToolCall(row, toolCallId);
} else if (field === 'reasoning') {
await this.editText(row, 'reasoning');
2026-07-06 16:06:48 +00:00
} else {
2026-10-30 22:42:51 +00:00
await this.editText(row, 'text');
2026-07-06 16:06:48 +00:00
}
2026-10-30 22:42:51 +00:00
}
/**
* editText edits the text or the reasoning of one saved message
* and sends the whole updated message, with the row's updated_at
* as the revision check.
* @param {Object} row
* @param {'text' | 'reasoning'} kind
*/
async editText(row, kind) {
const message = structuredClone(row.content ?? { role: row.role });
const value =
kind === 'reasoning'
? message.reasoning_content || ''
: extractText(message.content);
const edited = await this.ui.editText({
title: kind === 'reasoning' ? 'Edit Reasoning' : 'Edit Message',
value,
});
if (edited === null) return;
if (kind === 'reasoning') {
message.reasoning = '';
message.reasoning_content = edited;
} else if (typeof message.content === 'string') {
message.content = edited;
} else if (Array.isArray(message.content)) {
const part = message.content.find((p) => p.type === 'text');
if (part) {
part.text = edited;
} else {
message.content.unshift({ type: 'text', text: edited });
}
2026-06-09 23:17:55 +00:00
} else {
2026-10-30 22:42:51 +00:00
message.content = edited;
}
await this.putMessage(row.id, { message, updated_at: row.updated_at });
}
/**
* editToolCall edits the name and arguments of one call, and the
* text of its result when it has one. The call is updated first:
* both requests check revisions of the assistant message, and
* the call update changes its updated_at.
* @param {Object} row - the assistant message row.
* @param {string} callId
*/
async editToolCall(row, callId) {
const call = row.content?.tool_calls?.find((c) => c.id === callId);
if (!call) return;
const resultRow = this.findResultRow(row, callId);
const edited = await this.ui.editToolCall({
name: call.function?.name ?? '',
arguments: call.function?.arguments ?? '',
result: resultRow ? extractText(resultRow.content?.content) : '',
});
if (edited === null) return;
let assistant = row;
if (
edited.name !== call.function?.name ||
edited.arguments !== call.function?.arguments
) {
try {
const saved = await this.api.updateToolCall(
this.conversationId,
row.id,
callId,
{
call: {
id: callId,
type: 'function',
function: { name: edited.name, arguments: edited.arguments },
},
updated_at: row.updated_at,
},
);
this.cacheRow(saved, row.voice);
assistant = this.messages.get(saved.id);
} catch (error) {
this.reportMutationError(error);
return;
}
}
if (!resultRow && edited.result) {
// A new result is an inserted message; replace the cache
// from the saved order before the display renders it.
try {
await this.api.insertMessage(this.conversationId, {
message: {
role: 'tool',
tool_call_id: callId,
content: edited.result,
},
message_id: assistant.id,
updated_at: assistant.updated_at,
});
} catch (error) {
this.reportMutationError(error);
return;
}
await this.resync();
return;
}
if (
resultRow &&
edited.result !== extractText(resultRow.content?.content)
) {
try {
const saved = await this.api.updateMessage(
this.conversationId,
assistant.id,
{
message: {
role: 'tool',
tool_call_id: callId,
content: edited.result,
},
// A tool result update checks the result row's
// revision, with the assistant message as its address.
updated_at: resultRow.updated_at,
},
);
this.cacheRow(saved, resultRow.voice);
} catch (error) {
this.reportMutationError(error);
return;
}
}
this.ui.renderConversation(this.orderedRows());
}
/**
* deleteTarget deletes one bubble: a saved message, a tool call
* with its result, or the reasoning of a saved message. An empty
* message left by a reasoning delete is deleted with its row.
* @param {{id: string, field: string | null, toolCallId: string | null}} target
*/
async deleteTarget({ id, field, toolCallId }) {
const row = this.messages.get(id);
if (!row) return;
if (field === 'tool' && toolCallId) {
// The server removes the call, its result, and its
// execution record together, and rewrites the assistant row;
// replace the cache from the saved order.
try {
await this.api.deleteToolCall(
this.conversationId,
id,
toolCallId,
row.updated_at,
);
} catch (error) {
this.reportMutationError(error);
return;
}
await this.resync();
return;
}
if (field === 'reasoning') {
const message = structuredClone(row.content ?? { role: row.role });
message.reasoning = '';
message.reasoning_content = '';
const hasText = Boolean(extractText(message.content) || message.refusal);
const hasCalls = (message.tool_calls ?? []).length > 0;
if (hasText || hasCalls) {
await this.putMessage(row.id, { message, updated_at: row.updated_at });
} else {
try {
await this.api.deleteMessage(this.conversationId, id, row.updated_at);
} catch (error) {
this.reportMutationError(error);
return;
}
await this.resync();
}
return;
}
try {
await this.api.deleteMessage(this.conversationId, id, row.updated_at);
} catch (error) {
this.reportMutationError(error);
return;
}
await this.resync();
}
/**
* putMessage updates one saved message and re-renders the chat
* from the cache. An update does not shift rows, so no resync is
* needed.
* @param {string} mid
* @param {Object} body - message and updated_at.
*/
async putMessage(mid, body) {
try {
const saved = await this.api.updateMessage(
this.conversationId,
mid,
body,
);
const previous = this.messages.get(saved.id);
this.cacheRow(saved, previous?.voice);
this.ui.renderConversation(this.orderedRows());
} catch (error) {
this.reportMutationError(error);
2026-06-09 23:17:55 +00:00
}
2026-02-13 15:03:02 +00:00
}
2026-10-30 22:42:51 +00:00
/**
* reportMutationError explains a failed edit and recovers what
* the server knows: a conflict means the row changed elsewhere,
* a missing row means the message is gone.
* @param {Error} error
*/
async reportMutationError(error) {
if (error instanceof ApiError && error.status === 409) {
this.ui.renderError('The message changed elsewhere. Reloading it.');
await this.resync();
} else if (error instanceof ApiError && error.status === 404) {
this.ui.renderError('The message is gone. Reloading the conversation.');
await this.resync();
} else {
this.ui.renderError(`Edit failed: ${error.message}`);
}
2026-02-13 15:03:02 +00:00
}
2026-10-30 22:42:51 +00:00
// ====================
// SPEECH
// ====================
/**
* play speaks one saved assistant message with the voice of its
* run, or the selected voice when it has none.
* @param {string} id - message row ID.
*/
play(id) {
const row = this.messages.get(id);
if (!row) return;
const text = extractText(row.content?.content);
if (this.tts.currentMessageId === id) {
this.tts.stop();
return;
}
this.tts.stop();
this.tts.enqueue(text, row.voice || this.voice, id);
}
/** toggleMute silences or restores speech playback. */
2026-07-04 12:44:59 +00:00
toggleMute() {
2026-02-13 15:03:02 +00:00
this.isMuted = !this.isMuted;
2026-10-30 22:42:51 +00:00
this.storage.setMuted(this.isMuted);
this.ui.setMuted(this.isMuted);
2026-07-04 12:44:59 +00:00
this.tts.setMuted(this.isMuted);
2026-02-13 15:03:02 +00:00
}
2026-10-30 22:42:51 +00:00
/**
* handlePushToTalkStart records audio while the button or the
* space bar is held.
*/
async handlePushToTalkStart() {
if (this.isRecording || this.isTranscribing || this.busy) return;
this.tts.stop();
this.isRecording = true;
this.ui.setRecording(true);
// Keep the screen on while the user speaks.
this.wakeLock.acquire();
try {
await this.stt.acquire();
// The user may have released while acquire was pending, for
// example during the permission dialog.
if (!this.isRecording) return;
this.stt.start();
} catch (error) {
if (!this.isRecording) return;
this.isRecording = false;
this.ui.setRecording(false);
this.wakeLock.release();
this.ui.renderError(`Microphone access denied: ${error.message}`);
2026-06-27 12:35:11 +00:00
}
2026-10-30 22:42:51 +00:00
}
2026-06-27 12:35:11 +00:00
2026-10-30 22:42:51 +00:00
/** handlePushToTalkEnd sends the recorded audio as one turn. */
async handlePushToTalkEnd() {
if (!this.isRecording) return;
// Clear the flag first, so a pending acquire bails out.
this.isRecording = false;
this.ui.setRecording(false);
this.wakeLock.release();
try {
const { text, detectedLanguage } = await this.stt.stop();
if (!text) return;
const existing = this.ui.takeComposeText().trim();
const combined = existing ? `${existing}\n${text}` : text;
await this.send({
text: combined,
voice: this.resolveVoice(detectedLanguage),
});
} catch (error) {
console.error('transcription failed:', error);
this.ui.renderError(`Transcription failed: ${error.message}`);
}
}
2026-06-27 12:35:11 +00:00
2026-10-30 22:42:51 +00:00
/**
* handleTranscribeStart records audio that will be transcribed
* into the compose field, not sent.
*/
async handleTranscribeStart() {
if (this.isRecording || this.isTranscribing || this.busy) return;
this.tts.stop();
this.isTranscribing = true;
this.ui.setTranscribing(true);
this.wakeLock.acquire();
try {
await this.stt.acquire();
if (!this.isTranscribing) return;
this.stt.start();
} catch (error) {
if (!this.isTranscribing) return;
this.isTranscribing = false;
this.ui.setTranscribing(false);
this.wakeLock.release();
this.ui.renderError(`Microphone access denied: ${error.message}`);
}
}
2026-06-27 12:35:11 +00:00
2026-10-30 22:42:51 +00:00
/** handleTranscribeEnd puts the transcription into the field. */
async handleTranscribeEnd() {
if (!this.isTranscribing) return;
this.isTranscribing = false;
this.ui.setTranscribing(false);
this.wakeLock.release();
try {
const { text } = await this.stt.stop();
if (text) this.ui.appendComposeText(text);
this.ui.focusCompose();
this.updateSendEnabled();
} catch (error) {
console.error('transcription failed:', error);
this.ui.renderError(`Transcription failed: ${error.message}`);
2026-06-27 12:35:11 +00:00
}
}
2026-02-13 15:03:02 +00:00
// ====================
2026-10-30 22:42:51 +00:00
// INPUT
2026-02-13 15:03:02 +00:00
// ====================
2026-10-30 22:42:51 +00:00
/**
* updateSendEnabled enables send when there is input to send or
* saved history to continue.
*/
updateSendEnabled() {
const hasInput =
this.ui.getComposeText().trim().length > 0 ||
this.attachments.length > 0 ||
this.locationData !== null;
this.ui.setSendEnabled(hasInput || this.messages.size > 0);
}
/**
* handleAttachments adds files, skipping files already attached.
* @param {FileList} files
*/
2026-07-04 12:44:59 +00:00
handleAttachments(files) {
for (const file of files) {
2026-10-30 22:42:51 +00:00
const duplicate = this.attachments.some(
(attached) =>
attached.name === file.name &&
attached.size === file.size &&
attached.lastModified === file.lastModified,
2026-06-26 14:22:05 +00:00
);
2026-10-30 22:42:51 +00:00
if (!duplicate) this.attachments.push(file);
2026-06-26 14:22:05 +00:00
}
2026-10-30 22:42:51 +00:00
this.renderAttachments();
this.updateSendEnabled();
2026-06-26 14:22:05 +00:00
}
2026-10-30 22:42:51 +00:00
/** renderAttachments redraws the attachment chips. */
renderAttachments() {
this.ui.renderAttachments(this.attachments, (index) => {
this.attachments.splice(index, 1);
this.renderAttachments();
this.updateSendEnabled();
});
2026-02-13 15:03:02 +00:00
}
2026-10-30 22:42:51 +00:00
/** toggleLocation includes or drops the GPS location. */
toggleLocation() {
2026-07-04 12:44:59 +00:00
if (this.locationData) {
2026-10-30 22:42:51 +00:00
this.locationData = null;
this.ui.setLocationActive(false);
this.updateSendEnabled();
2026-07-04 12:44:59 +00:00
return;
}
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(
(position) => {
2026-10-30 22:42:51 +00:00
if (this.busy) return;
2026-07-04 12:44:59 +00:00
this.locationData = position;
2026-10-30 22:42:51 +00:00
this.ui.setLocationActive(true);
this.updateSendEnabled();
2026-07-04 12:44:59 +00:00
},
2026-10-31 12:13:36 +00:00
() => {},
2026-10-30 22:42:51 +00:00
GEOLOCATION_OPTIONS,
2026-07-04 12:44:59 +00:00
);
}
2026-07-05 17:55:07 +00:00
// ====================
2026-10-30 22:42:51 +00:00
// MODELS AND VOICES
2026-07-05 17:55:07 +00:00
// ====================
2026-10-30 22:42:51 +00:00
async loadModels() {
try {
const data = await this.api.getModels();
this.ui.populateModels(data, this.storage.getModelSelection());
this.ui.updateContext(
this.totalTokens,
this.ui.getSelectedModel().contextSize,
2026-07-04 12:44:59 +00:00
);
2026-10-30 22:42:51 +00:00
} catch (error) {
console.error('failed to load models:', error);
this.ui.populateModels(
{ providers: {}, default_provider: '', default_model: '' },
{},
2026-07-04 12:44:59 +00:00
);
2026-06-09 23:17:55 +00:00
}
2026-10-30 22:42:51 +00:00
}
2026-07-04 12:44:59 +00:00
2026-10-30 22:42:51 +00:00
async loadVoices() {
try {
const data = await this.api.getVoices(
document.querySelector('meta[name="tts-url"]')?.content || '',
);
// Filter out legacy v0 voices.
const voices = (data.voices ?? [])
.map((voice) => (typeof voice === 'string' ? voice : voice.id))
.filter((id) => !id.includes('_v0'));
this.ui.populateVoices(voices, this.storage.getVoice());
} catch (error) {
console.error('failed to load voices:', error);
this.ui.populateVoices([], '');
2026-07-04 12:44:59 +00:00
}
2026-06-09 23:17:55 +00:00
}
2026-02-13 15:03:02 +00:00
}
new Odidere({ document });