1675 lines
53 KiB
JavaScript
1675 lines
53 KiB
JavaScript
/**
|
|
* 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.
|
|
*/
|
|
|
|
import { Api, ApiError } from './api.js';
|
|
import { Storage } from './storage.js';
|
|
import { STT, VOICE_MAP } from './stt.js';
|
|
import { TTSPlayer } from './tts.js';
|
|
import { conversationLabel, extractText, UI } from './ui.js';
|
|
import { WakeLock } from './wakelock.js';
|
|
|
|
// 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.';
|
|
|
|
// Coarse accuracy is sufficient for chat context. The 30 s cache
|
|
// avoids a fresh GPS fix on repeated taps.
|
|
const GEOLOCATION_OPTIONS = {
|
|
enableHighAccuracy: false,
|
|
timeout: 20_000,
|
|
maximumAge: 30_000,
|
|
};
|
|
|
|
class Odidere {
|
|
constructor({ document }) {
|
|
this.api = new Api();
|
|
this.storage = new Storage();
|
|
this.ui = new UI({
|
|
document,
|
|
handlers: {
|
|
onComposeInput: () => this.updateSendEnabled(),
|
|
onComposeSettings: () => {
|
|
const settings = this.resolveConversationSettings();
|
|
this.ui.openComposeSettings({
|
|
maxIterations: settings.maxIterations,
|
|
reasoningBudget: settings.reasoningBudget,
|
|
});
|
|
},
|
|
onComposeSettingsChange: ({ maxIterations, reasoningBudget }) => {
|
|
if (!this.conversationId) {
|
|
this.storage.setMaxIterations(maxIterations);
|
|
this.storage.setReasoningBudget(reasoningBudget);
|
|
return;
|
|
}
|
|
const { provider, model } = this.ui.getSelectedModel();
|
|
this.storage.setConversationSettings(this.conversationId, {
|
|
provider,
|
|
model,
|
|
maxIterations,
|
|
reasoningBudget,
|
|
});
|
|
},
|
|
onComposeSubmit: () => this.submit(),
|
|
onDefaultModelChange: ({ provider, model }) =>
|
|
this.storage.setModelSelection({ provider, model }),
|
|
onComposeModelChange: ({ provider, model }) => {
|
|
if (this.conversationId) {
|
|
const settings = this.resolveConversationSettings();
|
|
this.storage.setConversationSettings(this.conversationId, {
|
|
provider,
|
|
model,
|
|
maxIterations: settings.maxIterations,
|
|
reasoningBudget: settings.reasoningBudget,
|
|
});
|
|
}
|
|
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),
|
|
onConversationsOpen: () => {
|
|
this.conversationQuery = '';
|
|
this.loadConversations();
|
|
},
|
|
onConversationSearch: (query) => {
|
|
this.conversationQuery = query;
|
|
this.filterConversations(query);
|
|
},
|
|
onSelectConversation: (id) => this.switchConversation(id),
|
|
onRenameConversation: (id) => this.renameConversation(id),
|
|
onDeleteConversation: (id) => this.deleteConversation(id),
|
|
onNewConversation: () => this.newConversation(),
|
|
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(),
|
|
},
|
|
});
|
|
|
|
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();
|
|
|
|
// The playback buttons follow the TTS queue.
|
|
this.tts.addEventListener('playbackstart', (event) =>
|
|
this.ui.setPlayButton(event.detail.messageId, true),
|
|
);
|
|
this.tts.addEventListener('playbackend', (event) =>
|
|
this.ui.setPlayButton(event.detail.messageId, false),
|
|
);
|
|
|
|
// --- In-memory state ---
|
|
this.conversationId = null;
|
|
// The rows the conversation picker shows, fetched on open.
|
|
this.conversations = [];
|
|
// The picker's current search text. A rename or delete keeps
|
|
// it, so the list re-renders with the same filter.
|
|
this.conversationQuery = '';
|
|
this.messages = new Map();
|
|
this.drafts = new Map();
|
|
this.busy = false;
|
|
this.attachments = [];
|
|
this.locationData = null;
|
|
this.totalTokens = 0;
|
|
this.voice = this.storage.getVoice();
|
|
this.lastVoice = '';
|
|
this.isRecording = false;
|
|
this.isTranscribing = false;
|
|
this.isMuted = this.storage.getMuted();
|
|
|
|
// 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;
|
|
|
|
this.init().catch((error) =>
|
|
this.ui.renderError(`Startup failed: ${error.message}`),
|
|
);
|
|
}
|
|
|
|
// ====================
|
|
// LIFECYCLE
|
|
// ====================
|
|
|
|
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());
|
|
}
|
|
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();
|
|
}
|
|
|
|
/** Stop every activity. Called on page teardown. */
|
|
destroy() {
|
|
this.stt.destroy();
|
|
this.tts.stop();
|
|
this.runController?.abort();
|
|
this.syncController?.abort();
|
|
this.wakeLock.release();
|
|
}
|
|
|
|
/** 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
|
|
// ====================
|
|
|
|
/**
|
|
* orderedRows returns the cached rows in saved order, sorted by
|
|
* sequence. The cache is a Map keyed by message ID, and a Map
|
|
* keeps the order in which keys were first added: a row that
|
|
* another client inserted earlier in the conversation arrives
|
|
* after rows that follow it. Sorting puts it in its place.
|
|
* @returns {Array<Object>}
|
|
*/
|
|
orderedRows() {
|
|
return [...this.messages.values()].sort((a, b) => a.sequence - b.sequence);
|
|
}
|
|
|
|
/**
|
|
* 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 || '',
|
|
});
|
|
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;
|
|
}
|
|
if (!after) continue;
|
|
if (candidate.role === 'assistant') break;
|
|
if (
|
|
candidate.role === 'tool' &&
|
|
callIds.has(candidate.content?.tool_call_id)
|
|
) {
|
|
results.push(candidate);
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// ====================
|
|
// EVENT HANDLING
|
|
// ====================
|
|
|
|
/**
|
|
* 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]
|
|
* @param {boolean} [speak] - false while reading the saved diff,
|
|
* so history is not read aloud.
|
|
* @returns {boolean} true when the stream is finished.
|
|
*/
|
|
handleEvent(type, data, voice = '', speak = true) {
|
|
switch (type) {
|
|
case 'message_saved': {
|
|
const row = data.message;
|
|
const previous = this.messages.get(row.id);
|
|
// When a subscriber joins a run, the server resends the
|
|
// run's events for messages the client's manifest already
|
|
// held (see Subscribe in conversation.go). Skip a revision
|
|
// that is already cached, so it does not render or speak
|
|
// again.
|
|
if (previous?.updated_at === row.updated_at) return false;
|
|
const existed = previous !== undefined;
|
|
this.cacheRow(row, voice);
|
|
this.applySavedRow(row, existed, voice, speak);
|
|
this.trackUsage(row.metadata?.usage);
|
|
return false;
|
|
}
|
|
case 'message_deleted': {
|
|
const existed = this.messages.delete(data.message_id);
|
|
this.persistCache();
|
|
if (existed) this.ui.renderConversation(this.orderedRows());
|
|
return false;
|
|
}
|
|
case 'assistant_update': {
|
|
// Skip resent chunks of a message that is already saved.
|
|
if (this.messages.has(data.message_id)) return false;
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* @param {boolean} [speak] - whether to read the reply aloud.
|
|
*/
|
|
applySavedRow(row, existed, voice = '', speak = true) {
|
|
const wasDraft = this.drafts.delete(row.id);
|
|
if (row.role === 'tool') {
|
|
this.ui.attachToolResult(
|
|
this.findParentRow(row),
|
|
row.content?.tool_call_id,
|
|
row,
|
|
);
|
|
return;
|
|
}
|
|
const rows = this.orderedRows();
|
|
if (wasDraft) {
|
|
this.ui.replaceDraftWithSaved(row.id, row, this.resultsFor(row));
|
|
} else if (existed || rows.at(-1)?.id !== row.id) {
|
|
this.ui.renderConversation(rows);
|
|
} 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();
|
|
} else {
|
|
this.ui.scrollToLastUserMessage();
|
|
}
|
|
}
|
|
}
|
|
if (speak && 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
applyChunk(draftId, chunk) {
|
|
let draft = this.drafts.get(draftId);
|
|
if (!draft) {
|
|
draft = {
|
|
id: draftId,
|
|
reasoning: '',
|
|
text: '',
|
|
refusal: '',
|
|
toolCalls: [],
|
|
};
|
|
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;
|
|
}
|
|
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 ?? '';
|
|
}
|
|
}
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* setBusy marks the conversation busy while a run or an attached
|
|
* subscription streams, or clears that state and discards unsaved
|
|
* drafts when the stream ends.
|
|
* @param {boolean} busy
|
|
*/
|
|
setBusy(busy) {
|
|
this.busy = busy;
|
|
this.ui.setBusy(busy);
|
|
if (busy) {
|
|
this.ui.setPendingApprovals([]);
|
|
this.wakeLock.acquire();
|
|
} else {
|
|
this.clearDrafts();
|
|
this.updateSendEnabled();
|
|
this.wakeLock.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.setBusy(true);
|
|
|
|
// 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();
|
|
};
|
|
|
|
try {
|
|
const body = { stream: true };
|
|
if (message) body.message = message;
|
|
// Read the conversation's settings once: the stored entry, or
|
|
// the defaults when the conversation has none. Save them for
|
|
// this conversation, which always has an ID here, and send them
|
|
// with the request.
|
|
const settings = this.resolveConversationSettings();
|
|
const { provider, model } = this.ui.getSelectedModel();
|
|
if (provider && model) {
|
|
this.storage.setConversationSettings(this.conversationId, {
|
|
provider,
|
|
model,
|
|
maxIterations: settings.maxIterations,
|
|
reasoningBudget: settings.reasoningBudget,
|
|
});
|
|
}
|
|
if (provider) body.provider = provider;
|
|
if (model) body.model = model;
|
|
if (settings.maxIterations > 0) {
|
|
body.max_iterations = settings.maxIterations;
|
|
}
|
|
if (settings.reasoningBudget !== null) {
|
|
body.thinking_budget_tokens = settings.reasoningBudget;
|
|
}
|
|
|
|
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) {
|
|
if (hasInput) {
|
|
this.ui.renderError(
|
|
'This conversation is already working in another session.',
|
|
);
|
|
this.ui.appendComposeText(text);
|
|
}
|
|
} 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.
|
|
} finally {
|
|
if (this.runController === controller) {
|
|
this.runController = null;
|
|
this.setBusy(false);
|
|
}
|
|
if (this.conversationId && !controller.signal.aborted) {
|
|
await this.sync();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
const textParts = [];
|
|
const imageParts = [];
|
|
|
|
for (const file of this.attachments) {
|
|
if (file.type.startsWith('image/')) {
|
|
const buffer = await file.arrayBuffer();
|
|
const base64 = new Uint8Array(buffer).toBase64();
|
|
imageParts.push({
|
|
type: 'image_url',
|
|
image_url: { url: `data:${file.type};base64,${base64}` },
|
|
});
|
|
} else {
|
|
const content = await file.text();
|
|
textParts.push(`=== File: ${file.name} ===\n\n${content}`);
|
|
}
|
|
}
|
|
|
|
if (text) textParts.push(text);
|
|
if (this.locationData) {
|
|
textParts.push(JSON.stringify(this.locationData, null, 2));
|
|
}
|
|
const combined = textParts.join('\n\n');
|
|
|
|
if (imageParts.length === 0) {
|
|
return { role: 'user', content: combined };
|
|
}
|
|
const parts = [];
|
|
if (combined) parts.push({ type: 'text', text: combined });
|
|
parts.push(...imageParts);
|
|
return { role: 'user', content: parts };
|
|
}
|
|
|
|
/**
|
|
* 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] ?? '') : '';
|
|
}
|
|
|
|
// ====================
|
|
// STOP AND RESET
|
|
// ====================
|
|
|
|
/**
|
|
* 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}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
await this.detachConversation();
|
|
if (id) {
|
|
try {
|
|
await this.api.deleteConversation(id);
|
|
this.storage.deleteConversationSettings(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}`);
|
|
}
|
|
}
|
|
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() {
|
|
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.selectComposeModel(this.resolveConversationSettings(null));
|
|
this.ui.updateContext(0, null);
|
|
this.updateSendEnabled();
|
|
}
|
|
|
|
/**
|
|
* 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 {}
|
|
}
|
|
if (this.busy) this.setBusy(false);
|
|
}
|
|
|
|
// ====================
|
|
// 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;
|
|
}
|
|
// Remove settings entries for conversations deleted in other tabs
|
|
// or on other devices.
|
|
this.storage.pruneConversationSettings(this.conversations.map((c) => c.id));
|
|
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;
|
|
} catch {}
|
|
}
|
|
if (this.busy) this.setBusy(false);
|
|
}
|
|
|
|
// Drop the local state of the old selection. sync 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;
|
|
this.ui.selectComposeModel(this.resolveConversationSettings(id));
|
|
await this.sync();
|
|
this.updateSendEnabled();
|
|
}
|
|
|
|
/**
|
|
* 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.storage.deleteConversationSettings(id);
|
|
this.conversations = this.conversations.filter((c) => c.id !== id);
|
|
if (id === this.conversationId) {
|
|
// The server-side delete cancels an ongoing inference.
|
|
this.tts.stop();
|
|
await this.detachConversation();
|
|
this.clearLocalConversation();
|
|
}
|
|
this.filterConversations(this.conversationQuery);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
// ====================
|
|
// SYNC
|
|
// ====================
|
|
|
|
/**
|
|
* sync subscribes with the cached rows as its manifest. The
|
|
* server sends what changed since they were saved, then, when a
|
|
* run is in progress, inference_active and the run's events,
|
|
* 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.runController) return;
|
|
const controller = new AbortController();
|
|
this.syncController?.abort();
|
|
this.syncController = controller;
|
|
let live = false;
|
|
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 (type === 'inference_active') {
|
|
live = true;
|
|
this.setBusy(true);
|
|
} else if (this.handleEvent(type, data, '', live)) {
|
|
break;
|
|
}
|
|
}
|
|
} 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 (live) this.setBusy(false);
|
|
}
|
|
}
|
|
if (!controller.signal.aborted && 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() {
|
|
if (this.conversationId) {
|
|
this.storage.deleteConversationSettings(this.conversationId);
|
|
}
|
|
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.selectComposeModel(this.resolveConversationSettings(null));
|
|
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 sync updates the cache 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,
|
|
),
|
|
);
|
|
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.sync();
|
|
await this.send({ text: '', voice: this.lastVoice });
|
|
}
|
|
|
|
// ====================
|
|
// 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;
|
|
}
|
|
|
|
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');
|
|
} else {
|
|
await this.editText(row, 'text');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 });
|
|
}
|
|
} else {
|
|
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; sync brings in the
|
|
// result and any shifted sequences.
|
|
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.sync();
|
|
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;
|
|
// sync applies those changes.
|
|
try {
|
|
await this.api.deleteToolCall(
|
|
this.conversationId,
|
|
id,
|
|
toolCallId,
|
|
row.updated_at,
|
|
);
|
|
} catch (error) {
|
|
this.reportMutationError(error);
|
|
return;
|
|
}
|
|
await this.sync();
|
|
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.sync();
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.api.deleteMessage(this.conversationId, id, row.updated_at);
|
|
} catch (error) {
|
|
this.reportMutationError(error);
|
|
return;
|
|
}
|
|
await this.sync();
|
|
}
|
|
|
|
/**
|
|
* putMessage updates one saved message and re-renders the chat
|
|
* from the cache. The response returns the updated row, so no
|
|
* sync 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.sync();
|
|
} else if (error instanceof ApiError && error.status === 404) {
|
|
this.ui.renderError('The message is gone. Reloading the conversation.');
|
|
await this.sync();
|
|
} else {
|
|
this.ui.renderError(`Edit failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// ====================
|
|
// 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. */
|
|
toggleMute() {
|
|
this.isMuted = !this.isMuted;
|
|
this.storage.setMuted(this.isMuted);
|
|
this.ui.setMuted(this.isMuted);
|
|
this.tts.setMuted(this.isMuted);
|
|
}
|
|
|
|
/**
|
|
* 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}`);
|
|
}
|
|
}
|
|
|
|
/** 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}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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}`);
|
|
}
|
|
}
|
|
|
|
/** 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}`);
|
|
}
|
|
}
|
|
|
|
// ====================
|
|
// INPUT
|
|
// ====================
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
handleAttachments(files) {
|
|
for (const file of files) {
|
|
const duplicate = this.attachments.some(
|
|
(attached) =>
|
|
attached.name === file.name &&
|
|
attached.size === file.size &&
|
|
attached.lastModified === file.lastModified,
|
|
);
|
|
if (!duplicate) this.attachments.push(file);
|
|
}
|
|
this.renderAttachments();
|
|
this.updateSendEnabled();
|
|
}
|
|
|
|
/** renderAttachments redraws the attachment chips. */
|
|
renderAttachments() {
|
|
this.ui.renderAttachments(this.attachments, (index) => {
|
|
this.attachments.splice(index, 1);
|
|
this.renderAttachments();
|
|
this.updateSendEnabled();
|
|
});
|
|
}
|
|
|
|
/** toggleLocation includes or drops the GPS location. */
|
|
toggleLocation() {
|
|
if (this.locationData) {
|
|
this.locationData = null;
|
|
this.ui.setLocationActive(false);
|
|
this.updateSendEnabled();
|
|
return;
|
|
}
|
|
if (!navigator.geolocation) return;
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => {
|
|
if (this.busy) return;
|
|
this.locationData = position;
|
|
this.ui.setLocationActive(true);
|
|
this.updateSendEnabled();
|
|
},
|
|
() => {},
|
|
GEOLOCATION_OPTIONS,
|
|
);
|
|
}
|
|
|
|
// ====================
|
|
// MODELS AND VOICES
|
|
// ====================
|
|
|
|
/**
|
|
* resolveConversationSettings returns the settings stored for the
|
|
* given conversation ID, falling back to the default selections
|
|
* when none are stored. Invalid numbers never reach a request:
|
|
* iterations that are not a positive safe integer read as 0, and
|
|
* a budget that is not a safe integer reads as null. A budget
|
|
* below -1 reads as -1.
|
|
* @param {string | null} [id]
|
|
* @returns {{provider: string | null, model: string | null, maxIterations: number, reasoningBudget: number | null}}
|
|
*/
|
|
resolveConversationSettings(id = this.conversationId) {
|
|
if (id) {
|
|
const stored = this.storage.getConversationSettings(id);
|
|
if (stored) {
|
|
return {
|
|
provider: stored.provider,
|
|
model: stored.model,
|
|
maxIterations:
|
|
Number.isSafeInteger(stored.maxIterations) &&
|
|
stored.maxIterations > 0
|
|
? stored.maxIterations
|
|
: 0,
|
|
reasoningBudget: Number.isSafeInteger(stored.reasoningBudget)
|
|
? Math.max(stored.reasoningBudget, -1)
|
|
: null,
|
|
};
|
|
}
|
|
}
|
|
const { provider, model } = this.storage.getModelSelection();
|
|
const maxIterations = this.storage.getMaxIterations();
|
|
const budget = this.storage.getReasoningBudget();
|
|
return {
|
|
provider,
|
|
model,
|
|
maxIterations:
|
|
Number.isSafeInteger(maxIterations) && maxIterations > 0
|
|
? maxIterations
|
|
: 0,
|
|
reasoningBudget: Number.isSafeInteger(budget)
|
|
? Math.max(budget, -1)
|
|
: null,
|
|
};
|
|
}
|
|
|
|
async loadModels() {
|
|
try {
|
|
const data = await this.api.getModels();
|
|
this.ui.populateModels(
|
|
data,
|
|
this.storage.getModelSelection(),
|
|
this.resolveConversationSettings(),
|
|
);
|
|
this.ui.updateContext(
|
|
this.totalTokens,
|
|
this.ui.getSelectedModel().contextSize,
|
|
);
|
|
} catch (error) {
|
|
console.error('failed to load models:', error);
|
|
this.ui.populateModels(
|
|
{ providers: {}, default_provider: '', default_model: '' },
|
|
{},
|
|
);
|
|
}
|
|
}
|
|
|
|
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([], '');
|
|
}
|
|
}
|
|
}
|
|
|
|
new Odidere({ document });
|