1386 lines
41 KiB
JavaScript
1386 lines
41 KiB
JavaScript
/**
|
|
* Odidere is the composition root and orchestrator. It creates all
|
|
* subsystems, wires them together, owns DOM refs, event delegation,
|
|
* and runTurn (the streaming orchestration formerly known as sendRequest).
|
|
*/
|
|
|
|
import { ConversationTree, extractText } from './conversation.js';
|
|
import { EditModal } from './edit.js';
|
|
import { Renderer } from './render.js';
|
|
import { Settings } from './settings.js';
|
|
import { STT, VOICE_MAP } from './stt.js';
|
|
import { TTSPlayer } from './tts.js';
|
|
import { WakeLock } from './wakelock.js';
|
|
|
|
const STREAM_ENDPOINT = '/v1/chat/voice/stream';
|
|
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
|
|
const MAX_TURNS_KEY = 'odidere_max_turns';
|
|
const ICONS_URL = '/static/icons.svg';
|
|
|
|
const StreamEventDelta = 'delta';
|
|
const StreamEventReasoningDelta = 'reasoning_delta';
|
|
const StreamEventDone = 'done';
|
|
const StreamEventMessage = 'message';
|
|
|
|
const ContentTypeImageURL = 'image_url';
|
|
const ContentTypeText = 'text';
|
|
|
|
// GeolocationOptions: coarse accuracy is sufficient for chat context.
|
|
// 30s cache avoids redundant GPS requests on repeated taps.
|
|
const GeolocationOptions = {
|
|
enableHighAccuracy: false,
|
|
timeout: 20_000,
|
|
maximumAge: 30_000,
|
|
};
|
|
|
|
class Odidere {
|
|
constructor({ document }) {
|
|
this.document = document;
|
|
|
|
// --- Create subsystems ---
|
|
this.conversation = new ConversationTree();
|
|
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.settings = new Settings({
|
|
document,
|
|
ttsURL: document.querySelector('meta[name="tts-url"]')?.content || '',
|
|
});
|
|
this.renderer = new Renderer({
|
|
document,
|
|
conversation: this.conversation,
|
|
});
|
|
this.editModal = new EditModal({
|
|
document,
|
|
conversation: this.conversation,
|
|
});
|
|
this.wakeLock = new WakeLock();
|
|
|
|
// --- Wire TTS events ---
|
|
this.tts.addEventListener('playbackstart', (e) =>
|
|
this.renderer.setPlayButton(e.detail.messageId, true),
|
|
);
|
|
this.tts.addEventListener('playbackend', (e) =>
|
|
this.renderer.setPlayButton(e.detail.messageId, false),
|
|
);
|
|
|
|
// --- State flags ---
|
|
this.isProcessing = false;
|
|
this.isRecording = false;
|
|
this.isTranscribing = false;
|
|
this.isMuted = false;
|
|
this.isResetPending = false;
|
|
this.isScrollPending = false;
|
|
this.isStopPending = false;
|
|
this.pendingDeleteId = null;
|
|
// Promise for the currently in-flight runTurn, so reset() can await it.
|
|
this._currentRun = null;
|
|
this.currentController = null;
|
|
this.totalTokens = 0;
|
|
|
|
// Attachments & location
|
|
this.attachments = [];
|
|
this.locationData = null;
|
|
|
|
// --- DOM refs ---
|
|
this.$attach = document.getElementById('attach');
|
|
this.$context = document.getElementById('context');
|
|
this.$fileInput = document.getElementById('file-input');
|
|
this.$location = document.getElementById('location');
|
|
this.$mute = document.getElementById('mute');
|
|
this.$ptt = document.getElementById('ptt');
|
|
this.$reset = document.getElementById('reset');
|
|
this.$send = document.getElementById('send');
|
|
this.$textInput = document.getElementById('text-input');
|
|
this.$transcribe = document.getElementById('transcribe');
|
|
this.$settings = document.getElementById('settings');
|
|
this.$settingsOverlay = document.getElementById('settings-overlay');
|
|
this.$settingsClose = document.getElementById('settings-close');
|
|
this.$settingsTabs = document.querySelectorAll('.settings-tab');
|
|
this.$saveSystemMessage = document.getElementById('save-system-message');
|
|
this.$composeSettingsOverlay = document.getElementById(
|
|
'compose-settings-overlay',
|
|
);
|
|
this.$composeSettingsClose = document.getElementById(
|
|
'compose-settings-close',
|
|
);
|
|
this.$maxTurnsInput = document.getElementById('max-turns-input');
|
|
this.$compose = document.querySelector('.compose');
|
|
|
|
this.init();
|
|
}
|
|
|
|
// ====================
|
|
// LIFECYCLE
|
|
// ====================
|
|
|
|
init() {
|
|
this.conversation.load();
|
|
if (this.conversation.size > 0) {
|
|
this.renderer.renderMessages(this.conversation.getActivePath());
|
|
}
|
|
this.bindEvents();
|
|
this.settings.fetchVoices();
|
|
this.settings.fetchModels();
|
|
this.updateSendButtonState();
|
|
this._initVisualViewport();
|
|
}
|
|
|
|
destroy() {
|
|
this.stt.destroy();
|
|
this.tts.stop();
|
|
this.currentController?.abort();
|
|
this.wakeLock.release();
|
|
}
|
|
|
|
/**
|
|
* Reset stops any in-flight turn, waits for it to settle, then
|
|
* clears the conversation, DOM, attachments, location, and input.
|
|
* The abort and await pattern ensures message streaming in runTurn completes
|
|
* before conversation.clear().
|
|
*/
|
|
async reset() {
|
|
this.tts.stop();
|
|
if (this.currentController) {
|
|
this.currentController.abort();
|
|
this.currentController = null;
|
|
}
|
|
this.setLoadingState(false);
|
|
|
|
// Wait for the current run to settle before clearing.
|
|
if (this._currentRun) {
|
|
try {
|
|
await this._currentRun;
|
|
} catch {}
|
|
}
|
|
this.pendingDeleteId = null;
|
|
this.conversation.clear();
|
|
this.renderer.setAutoScroll(true);
|
|
this.renderer.clearChat();
|
|
this.clearAttachments();
|
|
this.clearLocation();
|
|
this.$textInput.value = '';
|
|
this.$textInput.style.height = 'auto';
|
|
this.totalTokens = 0;
|
|
this.updateContext();
|
|
this.updateSendButtonState();
|
|
}
|
|
|
|
// ====================
|
|
// EVENT BINDING
|
|
// ====================
|
|
|
|
bindEvents() {
|
|
// --- Delegated click for all data-action elements ---
|
|
this.document.addEventListener('click', (e) => this._handleClick(e));
|
|
|
|
// --- Press-and-hold for PTT and transcribe ---
|
|
this.$compose.addEventListener('mousedown', (e) =>
|
|
this._handlePressStart(e),
|
|
);
|
|
this.$compose.addEventListener('touchstart', (e) =>
|
|
this._handlePressStart(e),
|
|
);
|
|
this.document.addEventListener('mouseup', (e) => this._handlePressEnd(e));
|
|
this.document.addEventListener('touchend', (e) => this._handlePressEnd(e));
|
|
this.document.addEventListener('touchcancel', (e) =>
|
|
this._handlePressEnd(e),
|
|
);
|
|
|
|
// Prevent context menu on long press for PTT and transcribe.
|
|
this.$ptt.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
this.$transcribe.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
|
|
// --- Keyboard: spacebar PTT ---
|
|
this.document.addEventListener('keydown', (e) => this._handleKeyDown(e));
|
|
this.document.addEventListener('keyup', (e) => this._handleKeyUp(e));
|
|
|
|
// --- Textarea ---
|
|
this.$textInput.addEventListener('keydown', (e) =>
|
|
this._handleTextareaKeyDown(e),
|
|
);
|
|
this.$textInput.addEventListener('input', () =>
|
|
this._handleTextareaInput(),
|
|
);
|
|
|
|
// --- File input ---
|
|
this.$attach.addEventListener('click', () => this.$fileInput.click());
|
|
this.$fileInput.addEventListener('change', (e) =>
|
|
this.handleAttachments(e.target.files),
|
|
);
|
|
|
|
// --- Chat scroll ---
|
|
this.renderer.$chat.addEventListener(
|
|
'scroll',
|
|
() => {
|
|
this.renderer.updateScrollIcon();
|
|
this.renderer.handleChatScroll();
|
|
},
|
|
{ passive: true },
|
|
);
|
|
this.renderer.updateScrollIcon();
|
|
|
|
// --- Settings modal ---
|
|
this.$settings.addEventListener('click', () => this.settings.open());
|
|
this.$settingsClose.addEventListener('click', () => this.settings.close());
|
|
this.$settingsOverlay.addEventListener('click', (e) => {
|
|
if (e.target === this.$settingsOverlay) this.settings.close();
|
|
});
|
|
this.document.addEventListener('keydown', (e) => {
|
|
if (
|
|
e.key === 'Escape' &&
|
|
this.$settingsOverlay.classList.contains('open')
|
|
) {
|
|
e.preventDefault();
|
|
this.settings.close();
|
|
}
|
|
});
|
|
this.$settingsTabs.forEach(($tab) => {
|
|
$tab.addEventListener('click', () =>
|
|
this.settings.switchTab($tab.dataset.tab),
|
|
);
|
|
});
|
|
this.$saveSystemMessage.addEventListener('click', () =>
|
|
this.settings.saveSystemMessage(),
|
|
);
|
|
|
|
// --- Compose settings ---
|
|
this.$composeSettingsClose.addEventListener('click', () =>
|
|
this._closeComposeSettings(),
|
|
);
|
|
this.$composeSettingsOverlay.addEventListener('click', (e) => {
|
|
if (e.target === this.$composeSettingsOverlay)
|
|
this._closeComposeSettings();
|
|
});
|
|
this.$maxTurnsInput.addEventListener('input', () => {
|
|
this._setMaxTurns(this.$maxTurnsInput.value);
|
|
});
|
|
|
|
// --- Model/voice change persistence ---
|
|
this.settings.$model.addEventListener('change', () =>
|
|
this.settings.saveModelSelection(),
|
|
);
|
|
this.settings.$voice.addEventListener('change', () =>
|
|
this.settings.saveVoiceSelection(),
|
|
);
|
|
|
|
// --- Wake lock: reacquire on visibility change ---
|
|
this.document.addEventListener('visibilitychange', () =>
|
|
this.wakeLock.handleVisibilityChange(
|
|
this.isRecording || this.isProcessing,
|
|
),
|
|
);
|
|
}
|
|
|
|
// ====================
|
|
// CLICK DELEGATION
|
|
// ====================
|
|
|
|
_handleClick(event) {
|
|
const $target = event.target.closest('[data-action]');
|
|
const action = $target?.dataset.action;
|
|
|
|
// Click outside any action button: cancel all pending states.
|
|
if (!action) {
|
|
this._cancelAllPending();
|
|
return;
|
|
}
|
|
|
|
// Cancel pending states for other actions.
|
|
if (action !== 'reset') this._cancelResetPending();
|
|
if (action !== 'send') this._cancelStopPending();
|
|
if (action !== 'scroll') this._cancelScrollPending();
|
|
if (action !== 'delete') this._cancelDeletePending();
|
|
|
|
this.handleAction(action, event, $target);
|
|
}
|
|
|
|
handleAction(action, event, $target) {
|
|
switch (action) {
|
|
case 'send':
|
|
this._handleSendClick();
|
|
break;
|
|
case 'reset':
|
|
this._handleResetClick();
|
|
break;
|
|
case 'scroll':
|
|
this._handleScrollClick();
|
|
break;
|
|
case 'mute':
|
|
this.toggleMute();
|
|
break;
|
|
case 'settings':
|
|
this.settings.open();
|
|
break;
|
|
case 'compose-settings':
|
|
this._openComposeSettings();
|
|
break;
|
|
case 'attach':
|
|
// Handled by direct listener on $attach.
|
|
break;
|
|
case 'location':
|
|
this.handleLocationToggle();
|
|
break;
|
|
case 'inspect': {
|
|
const $msg = $target.closest('.message');
|
|
const isOpen = $msg.classList.toggle('message--debug-open');
|
|
$target.setAttribute('aria-expanded', String(isOpen));
|
|
break;
|
|
}
|
|
case 'copy': {
|
|
const $msg = $target.closest('.message');
|
|
const text = $msg.dataset.copyText || '';
|
|
this.renderer.copyToClipboard($target, text);
|
|
break;
|
|
}
|
|
case 'play': {
|
|
const $msg = $target.closest('.message');
|
|
const messageId = $msg.dataset.id;
|
|
const text = $msg.querySelector('.message__content').textContent;
|
|
const voice = $msg.dataset.voice || '';
|
|
if (this.tts.currentMessageId === messageId) {
|
|
this.tts.stop();
|
|
} else {
|
|
this.tts.stop();
|
|
this.tts.enqueue(text, voice, messageId);
|
|
}
|
|
break;
|
|
}
|
|
case 'edit': {
|
|
const $msg = $target.closest('.message');
|
|
const messageId = $msg.dataset.messageId || $msg.dataset.id;
|
|
const field = $msg.dataset.field || null;
|
|
const toolCallId = $msg.dataset.toolCallId || null;
|
|
this._openEdit(messageId, field, toolCallId);
|
|
break;
|
|
}
|
|
case 'delete': {
|
|
const $msg = $target.closest('.message');
|
|
// Error messages have no data-id: just remove from DOM.
|
|
if (!$msg.dataset.id && !$msg.dataset.messageId) {
|
|
$msg.remove();
|
|
break;
|
|
}
|
|
const messageId = $msg.dataset.messageId || $msg.dataset.id;
|
|
const field = $msg.dataset.field || null;
|
|
const toolCallId = $msg.dataset.toolCallId || null;
|
|
this._handleDelete(event, messageId, field, toolCallId, $target);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ====================
|
|
// PRESS-AND-HOLD
|
|
// ====================
|
|
|
|
_handlePressStart(event) {
|
|
const $target = event.target.closest('[data-action]');
|
|
if (!$target) return;
|
|
const action = $target.dataset.action;
|
|
|
|
if (action === 'ptt') {
|
|
if (event.type === 'touchstart') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
} else if (event.sourceCapabilities?.firesTouchEvents) {
|
|
return;
|
|
} else {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
this.handleSendButtonPress();
|
|
} else if (action === 'transcribe') {
|
|
if (event.type === 'touchstart') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
} else if (event.sourceCapabilities?.firesTouchEvents) {
|
|
return;
|
|
} else {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
this.handleTranscribePress();
|
|
}
|
|
}
|
|
|
|
_handlePressEnd(_event) {
|
|
if (this.isRecording) {
|
|
this.handleSendButtonRelease();
|
|
} else if (this.isTranscribing) {
|
|
this.handleTranscribeRelease();
|
|
}
|
|
}
|
|
|
|
// ====================
|
|
// STT HANDLERS
|
|
// ====================
|
|
|
|
async handleSendButtonPress() {
|
|
if (this.isRecording || this.isTranscribing || this.isProcessing) return;
|
|
this.tts.stop();
|
|
this.isRecording = true;
|
|
this.setRecordingState(true);
|
|
try {
|
|
await this.stt.acquire();
|
|
// User may have released while acquire was pending (e.g. during
|
|
// the iOS permission dialog). Bail before starting.
|
|
if (!this.isRecording) return;
|
|
this.stt.start();
|
|
} catch (e) {
|
|
if (!this.isRecording) return;
|
|
this.isRecording = false;
|
|
this.setRecordingState(false);
|
|
this.renderer.renderError(`Microphone access denied: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
async handleSendButtonRelease() {
|
|
if (!this.isRecording) return;
|
|
// Clear the flag immediately so that a pending acquire() in the
|
|
// press handler bails out instead of starting the recorder.
|
|
this.isRecording = false;
|
|
this.setRecordingState(false);
|
|
try {
|
|
const { text: transcription, detectedLanguage } = await this.stt.stop();
|
|
if (!transcription) return;
|
|
const voice = this.resolveVoice(detectedLanguage);
|
|
const existing = this.$textInput.value.trim();
|
|
this.$textInput.value = '';
|
|
this.$textInput.style.height = 'auto';
|
|
const combined = existing
|
|
? `${existing}\n${transcription}`
|
|
: transcription;
|
|
await this.runTurn({ text: combined, detectedLanguage, voice });
|
|
} catch (e) {
|
|
console.error('transcription failed:', e);
|
|
this.renderer.renderError(`Transcription failed: ${e.message}`);
|
|
this.setLoadingState(false);
|
|
}
|
|
}
|
|
|
|
async handleTranscribePress() {
|
|
if (this.isRecording || this.isTranscribing || this.isProcessing) return;
|
|
this.tts.stop();
|
|
this.isTranscribing = true;
|
|
this.$transcribe.classList.add('compose__action-btn--recording');
|
|
try {
|
|
await this.stt.acquire();
|
|
if (!this.isTranscribing) return;
|
|
this.stt.start();
|
|
} catch (e) {
|
|
if (!this.isTranscribing) return;
|
|
this.isTranscribing = false;
|
|
this.$transcribe.classList.remove('compose__action-btn--recording');
|
|
this.renderer.renderError(`Microphone access denied: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
async handleTranscribeRelease() {
|
|
if (!this.isTranscribing) return;
|
|
this.isTranscribing = false;
|
|
this.$transcribe.classList.remove('compose__action-btn--recording');
|
|
try {
|
|
const { text: transcription } = await this.stt.stop();
|
|
if (transcription) {
|
|
const existing = this.$textInput.value;
|
|
if (existing.trim()) {
|
|
this.$textInput.value = `${existing}\n${transcription}`;
|
|
} else {
|
|
this.$textInput.value = transcription;
|
|
}
|
|
this._handleTextareaInput();
|
|
}
|
|
this.$textInput.focus();
|
|
} catch (e) {
|
|
console.error('transcription failed:', e);
|
|
this.renderer.renderError(`Transcription failed: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// ====================
|
|
// KEYBOARD
|
|
// ====================
|
|
|
|
_handleKeyDown(event) {
|
|
if (event.code !== 'Space') return;
|
|
if (event.repeat) return;
|
|
const isInput =
|
|
event.target.tagName === 'INPUT' ||
|
|
event.target.tagName === 'TEXTAREA' ||
|
|
event.target.tagName === 'SELECT';
|
|
if (isInput) return;
|
|
event.preventDefault();
|
|
this.handleSendButtonPress();
|
|
}
|
|
|
|
_handleKeyUp(event) {
|
|
if (event.code !== 'Space') return;
|
|
const isInput =
|
|
event.target.tagName === 'INPUT' ||
|
|
event.target.tagName === 'TEXTAREA' ||
|
|
event.target.tagName === 'SELECT';
|
|
if (isInput) return;
|
|
event.preventDefault();
|
|
this.handleSendButtonRelease();
|
|
}
|
|
|
|
_handleTextareaKeyDown(event) {
|
|
if (event.code !== 'Enter') return;
|
|
if (event.shiftKey) return;
|
|
event.preventDefault();
|
|
this._submitText();
|
|
}
|
|
|
|
_handleTextareaInput() {
|
|
const textarea = this.$textInput;
|
|
textarea.style.height = 'auto';
|
|
const computed = getComputedStyle(textarea);
|
|
const maxHeight = parseFloat(computed.maxHeight);
|
|
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
|
|
textarea.style.height = `${newHeight}px`;
|
|
this.updateSendButtonState();
|
|
}
|
|
|
|
// ====================
|
|
// CONVERSATION ORCHESTRATION
|
|
// ====================
|
|
|
|
async _submitText() {
|
|
const text = this.$textInput.value.trim();
|
|
if (this.isProcessing) return;
|
|
this.tts.stop();
|
|
this.$textInput.value = '';
|
|
this.$textInput.style.height = 'auto';
|
|
await this.runTurn({ text, voice: this.settings.getVoice() });
|
|
}
|
|
|
|
/**
|
|
* Build and send a chat request, then consume the streaming SSE response.
|
|
*
|
|
* The body executes inside an async IIFE stored as this._currentRun so
|
|
* reset() can abort the stream and await the IIFE before clearing state.
|
|
* On interruption (stop or reset), the catch block persists any partially-
|
|
* received assistant message or pending tool calls; on reset, clear() runs
|
|
* afterward, so the persisted data is immediately discarded.
|
|
*
|
|
* @param {Object} options
|
|
* @param {string} [options.text]
|
|
* @param {string} [options.detectedLanguage]
|
|
* @param {string} [options.voice]
|
|
*/
|
|
async runTurn({ text = '', detectedLanguage = '', voice } = {}) {
|
|
this.setLoadingState(true);
|
|
this.renderer.setAutoScroll(true);
|
|
this.currentController = new AbortController();
|
|
|
|
this._currentRun = (async () => {
|
|
let streamingBubbles = {
|
|
assistantId: null,
|
|
message: null,
|
|
reasoning: null,
|
|
text: null,
|
|
};
|
|
let pendingTools = null;
|
|
|
|
const streamingMeta = (usage, timings) => {
|
|
const meta = {};
|
|
if (voice) meta.voice = voice;
|
|
if (usage) meta.usage = usage;
|
|
if (timings) meta.timings = timings;
|
|
return meta;
|
|
};
|
|
|
|
try {
|
|
const hasNewInput =
|
|
text || this.attachments.length > 0 || this.locationData;
|
|
|
|
const activePath = this.conversation.getActivePath();
|
|
let messages;
|
|
let userMessage;
|
|
|
|
if (hasNewInput) {
|
|
userMessage = await this._buildUserMessage(text, this.attachments);
|
|
messages = [
|
|
...activePath.map(({ parent, children, id, meta, ...msg }) => msg),
|
|
userMessage,
|
|
];
|
|
} else {
|
|
messages = activePath.map(
|
|
({ parent, children, id, meta, ...msg }) => msg,
|
|
);
|
|
}
|
|
|
|
const { provider, model } = this.settings.getModel();
|
|
const payload = { messages, provider, model };
|
|
const systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY);
|
|
if (systemMessage) {
|
|
payload.system_message = systemMessage;
|
|
}
|
|
const maxTurns = this._getMaxTurns();
|
|
if (maxTurns > 0) {
|
|
payload.max_iterations = maxTurns;
|
|
}
|
|
|
|
// Clear attachments and location after building payload.
|
|
this.clearAttachments();
|
|
this.clearLocation();
|
|
|
|
// For text-only requests with new input, add to history and
|
|
// render immediately.
|
|
if (hasNewInput) {
|
|
const meta = {};
|
|
if (detectedLanguage) meta.language = detectedLanguage;
|
|
if (voice) meta.voice = voice;
|
|
const appended = this.conversation.append([userMessage], meta);
|
|
this.renderer.renderMessages(appended, meta);
|
|
}
|
|
|
|
const res = await fetch(STREAM_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
signal: this.currentController.signal,
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.text();
|
|
throw new Error(`server error ${res.status}: ${err}`);
|
|
}
|
|
|
|
for await (const { eventType, event } of this._parseSSEStream(res)) {
|
|
if (event.error) {
|
|
throw new Error(event.error);
|
|
}
|
|
|
|
if (
|
|
eventType === StreamEventDelta ||
|
|
eventType === StreamEventReasoningDelta
|
|
) {
|
|
if (!streamingBubbles.message) {
|
|
streamingBubbles.assistantId = crypto.randomUUID();
|
|
streamingBubbles.message = {
|
|
id: streamingBubbles.assistantId,
|
|
role: 'assistant',
|
|
content: [{ type: ContentTypeText, text: '' }],
|
|
};
|
|
}
|
|
|
|
if (eventType === StreamEventReasoningDelta) {
|
|
if (!streamingBubbles.reasoning) {
|
|
const $el = this.renderer.createStreamingReasoning(
|
|
streamingBubbles.assistantId,
|
|
streamingMeta(event.usage, event.timings),
|
|
);
|
|
streamingBubbles.reasoning = { $el };
|
|
streamingBubbles.message.reasoning_content = '';
|
|
}
|
|
streamingBubbles.message.reasoning_content +=
|
|
event.reasoning_delta || '';
|
|
this.renderer.appendReasoningDelta(
|
|
streamingBubbles.reasoning.$el,
|
|
event.reasoning_delta || '',
|
|
);
|
|
} else {
|
|
if (!streamingBubbles.text) {
|
|
const $el = this.renderer.createStreamingText(
|
|
streamingBubbles.message,
|
|
streamingMeta(event.usage, event.timings),
|
|
);
|
|
streamingBubbles.text = { $el };
|
|
}
|
|
streamingBubbles.message.content[0].text += event.delta || '';
|
|
this.renderer.appendDelta(
|
|
streamingBubbles.text.$el,
|
|
event.delta || '',
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const message = event.message;
|
|
if (!message) continue;
|
|
|
|
if (eventType === StreamEventDone) {
|
|
if (message.role !== 'assistant') continue;
|
|
|
|
if (event.usage?.total_tokens) {
|
|
this.totalTokens = event.usage.total_tokens;
|
|
this.updateContext();
|
|
}
|
|
|
|
const meta = streamingMeta(event.usage, event.timings);
|
|
let finalMessage = message;
|
|
|
|
if (streamingBubbles.message) {
|
|
finalMessage.id = streamingBubbles.assistantId;
|
|
const appended = this.conversation.append([finalMessage], meta);
|
|
finalMessage = appended[0];
|
|
|
|
if (streamingBubbles.reasoning) {
|
|
this.renderer.finalizeStreamingReasoning(
|
|
streamingBubbles.reasoning.$el,
|
|
finalMessage,
|
|
meta,
|
|
);
|
|
}
|
|
if (streamingBubbles.text && !finalMessage.tool_calls?.length) {
|
|
this.renderer.finalizeStreamingText(
|
|
streamingBubbles.text.$el,
|
|
finalMessage,
|
|
meta,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (finalMessage.tool_calls?.length > 0) {
|
|
if (!streamingBubbles.message) {
|
|
const appended = this.conversation.append([finalMessage], meta);
|
|
finalMessage = appended[0];
|
|
}
|
|
pendingTools = {
|
|
assistant: finalMessage,
|
|
results: [],
|
|
expected: finalMessage.tool_calls.length,
|
|
$el: streamingBubbles.text?.$el || null,
|
|
};
|
|
streamingBubbles = {
|
|
assistantId: null,
|
|
message: null,
|
|
reasoning: null,
|
|
text: null,
|
|
};
|
|
if (extractText(finalMessage.content)) {
|
|
this.tts.enqueue(
|
|
extractText(finalMessage.content),
|
|
voice || '',
|
|
finalMessage.id,
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!streamingBubbles.message) {
|
|
const appended = this.conversation.append([finalMessage], meta);
|
|
finalMessage = appended[0];
|
|
this.renderer.renderMessages(appended, meta);
|
|
}
|
|
streamingBubbles = {
|
|
assistantId: null,
|
|
message: null,
|
|
reasoning: null,
|
|
text: null,
|
|
};
|
|
if (extractText(finalMessage.content)) {
|
|
this.tts.enqueue(
|
|
extractText(finalMessage.content),
|
|
voice || '',
|
|
finalMessage.id,
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Collect tool results.
|
|
if (
|
|
message.role === 'tool' &&
|
|
pendingTools &&
|
|
pendingTools.assistant
|
|
) {
|
|
const appended = this.conversation.append([message]);
|
|
pendingTools.results.push(appended[0]);
|
|
|
|
if (pendingTools.results.length >= pendingTools.expected) {
|
|
if (pendingTools.$el) {
|
|
this.renderer.finalizeStreamingText(
|
|
pendingTools.$el,
|
|
pendingTools.assistant,
|
|
pendingTools.assistant.meta,
|
|
);
|
|
}
|
|
this.renderer.renderMessages([
|
|
pendingTools.assistant,
|
|
...pendingTools.results,
|
|
]);
|
|
pendingTools = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.setLoadingState(false);
|
|
} catch (e) {
|
|
if (e.name !== 'AbortError') {
|
|
console.error('failed to send request:', e);
|
|
this.renderer.renderError(`Error: ${e.message}`);
|
|
}
|
|
this.setLoadingState(false);
|
|
|
|
// Persist a streaming message interrupted by stop or error.
|
|
if (streamingBubbles.message) {
|
|
const meta = streamingMeta(null, null);
|
|
const finalMessage = streamingBubbles.message;
|
|
const hasContent = !!extractText(finalMessage.content);
|
|
const hasToolCalls = finalMessage.tool_calls?.length > 0;
|
|
|
|
if (hasContent || hasToolCalls) {
|
|
const appended = this.conversation.append([finalMessage], meta);
|
|
if (streamingBubbles.reasoning) {
|
|
this.renderer.finalizeStreamingReasoning(
|
|
streamingBubbles.reasoning.$el,
|
|
appended[0],
|
|
meta,
|
|
);
|
|
}
|
|
if (streamingBubbles.text) {
|
|
this.renderer.finalizeStreamingText(
|
|
streamingBubbles.text.$el,
|
|
appended[0],
|
|
meta,
|
|
);
|
|
}
|
|
} else {
|
|
streamingBubbles.reasoning?.$el?.remove();
|
|
streamingBubbles.text?.$el?.remove();
|
|
}
|
|
}
|
|
|
|
// Finalize pending tool calls interrupted by stop or error.
|
|
if (pendingTools) {
|
|
if (pendingTools.$el) {
|
|
this.renderer.finalizeStreamingText(
|
|
pendingTools.$el,
|
|
pendingTools.assistant,
|
|
pendingTools.assistant.meta ?? {},
|
|
);
|
|
}
|
|
this.renderer.renderMessages([
|
|
pendingTools.assistant,
|
|
...pendingTools.results,
|
|
]);
|
|
pendingTools = null;
|
|
}
|
|
} finally {
|
|
this.currentController = null;
|
|
}
|
|
})();
|
|
|
|
try {
|
|
await this._currentRun;
|
|
} finally {
|
|
this._currentRun = null;
|
|
}
|
|
}
|
|
|
|
// ====================
|
|
// SSE PARSING
|
|
// ====================
|
|
|
|
async *_parseSSEStream(response) {
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const parts = buffer.split('\n\n');
|
|
buffer = parts.pop();
|
|
|
|
for (const part of parts) {
|
|
if (!part.trim()) continue;
|
|
|
|
let eventType = StreamEventMessage;
|
|
let data = '';
|
|
for (const line of part.split('\n')) {
|
|
if (line.startsWith('event: ')) eventType = line.slice(7);
|
|
if (line.startsWith('data: ')) data += line.slice(6);
|
|
}
|
|
if (!data) continue;
|
|
|
|
let event;
|
|
try {
|
|
event = JSON.parse(data);
|
|
} catch {
|
|
console.error('failed to parse SSE data:', data);
|
|
continue;
|
|
}
|
|
|
|
yield { eventType, event };
|
|
}
|
|
}
|
|
}
|
|
|
|
// ====================
|
|
// BUILD USER MESSAGE
|
|
// ====================
|
|
|
|
async _buildUserMessage(text, attachments) {
|
|
const textParts = [];
|
|
const imageParts = [];
|
|
|
|
for (const file of attachments) {
|
|
if (file.type.startsWith('image/')) {
|
|
const buffer = await file.arrayBuffer();
|
|
const base64 = new Uint8Array(buffer).toBase64();
|
|
const dataURL = `data:${file.type};base64,${base64}`;
|
|
imageParts.push({
|
|
type: ContentTypeImageURL,
|
|
image_url: { url: dataURL },
|
|
});
|
|
} 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 combinedText = textParts.join('\n\n');
|
|
|
|
if (imageParts.length === 0) {
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
role: 'user',
|
|
content: combinedText || '',
|
|
};
|
|
}
|
|
|
|
const parts = [];
|
|
if (combinedText) {
|
|
parts.push({ type: ContentTypeText, text: combinedText });
|
|
}
|
|
parts.push(...imageParts);
|
|
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
role: 'user',
|
|
content: parts,
|
|
};
|
|
}
|
|
|
|
// ====================
|
|
// VOICE RESOLUTION
|
|
// ====================
|
|
|
|
resolveVoice(detectedLanguage) {
|
|
let voice = this.settings.getVoice();
|
|
if (!voice && detectedLanguage) {
|
|
voice = VOICE_MAP[detectedLanguage] || '';
|
|
}
|
|
return voice;
|
|
}
|
|
|
|
// ====================
|
|
// STATE
|
|
// ====================
|
|
|
|
updateSendButtonState() {
|
|
const hasText = this.$textInput.value.trim().length > 0;
|
|
const hasAttachments = this.attachments.length > 0;
|
|
const hasLocation = this.locationData !== null;
|
|
const hasHistory = this.conversation.size > 0;
|
|
const enabled = hasText || hasAttachments || hasLocation || hasHistory;
|
|
this.$send.disabled = !enabled;
|
|
this.$send.setAttribute(
|
|
'aria-label',
|
|
enabled ? 'Send message' : 'Start a conversation to enable send',
|
|
);
|
|
}
|
|
|
|
setLoadingState(loading) {
|
|
this.isProcessing = loading;
|
|
this.$send.classList.toggle('loading', loading);
|
|
this.$send.setAttribute('aria-label', loading ? 'Stop' : 'Send message');
|
|
this.renderer.$chatLoading.hidden = !loading;
|
|
this.$ptt.disabled = loading;
|
|
this.$transcribe.disabled = loading;
|
|
if (!loading && this.isStopPending) {
|
|
this.isStopPending = false;
|
|
this.$send.classList.remove('pending');
|
|
}
|
|
if (loading) {
|
|
this.wakeLock.acquire();
|
|
} else {
|
|
this.wakeLock.release();
|
|
}
|
|
}
|
|
|
|
setRecordingState(recording) {
|
|
this.$ptt.classList.toggle('recording', recording);
|
|
this.$ptt.setAttribute('aria-pressed', String(recording));
|
|
}
|
|
|
|
toggleMute() {
|
|
this.isMuted = !this.isMuted;
|
|
const iconName = this.isMuted ? 'volume-off' : 'volume';
|
|
this.$mute.replaceChildren(this._icon(iconName));
|
|
this.$mute.classList.toggle('footer__toolbar-btn--muted', this.isMuted);
|
|
this.$mute.setAttribute('aria-label', this.isMuted ? 'Unmute' : 'Mute');
|
|
this.tts.setMuted(this.isMuted);
|
|
}
|
|
|
|
updateContext() {
|
|
if (!this.$context) return;
|
|
const { model } = this.settings.getModel();
|
|
const contextSize = model ? this.settings.getModelContextSize(model) : null;
|
|
|
|
if (this.totalTokens === 0) {
|
|
this.$context.hidden = true;
|
|
return;
|
|
}
|
|
this.$context.hidden = false;
|
|
|
|
this.$context.classList.remove(
|
|
'footer__toolbar-context--warning',
|
|
'footer__toolbar-context--critical',
|
|
);
|
|
|
|
const tokenLabel =
|
|
this.totalTokens >= 1000
|
|
? `${(this.totalTokens / 1000).toFixed(1)}k tokens`
|
|
: `${this.totalTokens} tokens`;
|
|
|
|
if (contextSize && contextSize > 0) {
|
|
const pct = Math.round((this.totalTokens / contextSize) * 100);
|
|
this.$context.textContent = `${pct}%`;
|
|
this.$context.title = tokenLabel;
|
|
if (pct >= 90) {
|
|
this.$context.classList.add('footer__toolbar-context--critical');
|
|
} else if (pct >= 70) {
|
|
this.$context.classList.add('footer__toolbar-context--warning');
|
|
}
|
|
} else {
|
|
this.$context.textContent = tokenLabel;
|
|
this.$context.title = '';
|
|
}
|
|
}
|
|
|
|
// ====================
|
|
// ATTACHMENTS & LOCATION
|
|
// ====================
|
|
|
|
handleAttachments(files) {
|
|
for (const file of files) {
|
|
const isDuplicate = this.attachments.some(
|
|
(a) =>
|
|
a.name === file.name &&
|
|
a.size === file.size &&
|
|
a.lastModified === file.lastModified,
|
|
);
|
|
if (isDuplicate) continue;
|
|
this.attachments.push(file);
|
|
}
|
|
this._renderAttachments();
|
|
this.updateSendButtonState();
|
|
this.$fileInput.value = '';
|
|
}
|
|
|
|
_renderAttachments() {
|
|
this.renderer.renderAttachments(this.attachments, (i) =>
|
|
this._removeAttachment(i),
|
|
);
|
|
}
|
|
|
|
_removeAttachment(index) {
|
|
this.attachments.splice(index, 1);
|
|
this._renderAttachments();
|
|
this.updateSendButtonState();
|
|
}
|
|
|
|
clearAttachments() {
|
|
this.attachments = [];
|
|
this._renderAttachments();
|
|
}
|
|
|
|
handleLocationToggle() {
|
|
if (this.locationData) {
|
|
this.clearLocation();
|
|
this.updateSendButtonState();
|
|
return;
|
|
}
|
|
if (!navigator.geolocation) return;
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => {
|
|
this.locationData = position;
|
|
this.$location.classList.add('compose__action-btn--location', 'active');
|
|
this.updateSendButtonState();
|
|
},
|
|
() => {},
|
|
GeolocationOptions,
|
|
);
|
|
}
|
|
|
|
clearLocation() {
|
|
this.locationData = null;
|
|
this.$location.classList.remove('compose__action-btn--location', 'active');
|
|
}
|
|
|
|
// ====================
|
|
// COMPOSE SETTINGS
|
|
// ====================
|
|
|
|
_openComposeSettings() {
|
|
this.$maxTurnsInput.value = this._getMaxTurns();
|
|
this.$composeSettingsOverlay.classList.add('open');
|
|
this.$composeSettingsClose.focus();
|
|
}
|
|
|
|
_closeComposeSettings() {
|
|
this.$composeSettingsOverlay.classList.remove('open');
|
|
this.$composeSettings.focus();
|
|
}
|
|
|
|
_getMaxTurns() {
|
|
const stored = localStorage.getItem(MAX_TURNS_KEY);
|
|
return stored ? Number(stored) : 0;
|
|
}
|
|
|
|
_setMaxTurns(value) {
|
|
localStorage.setItem(MAX_TURNS_KEY, String(value));
|
|
}
|
|
|
|
// ====================
|
|
// TWO-PRESS SAFETY PATTERNS
|
|
// ====================
|
|
|
|
_handleSendClick() {
|
|
if (this.$send.classList.contains('loading')) {
|
|
this._handleStopClick();
|
|
} else {
|
|
this._submitText();
|
|
}
|
|
}
|
|
|
|
_handleStopClick() {
|
|
if (this.isStopPending) {
|
|
this.isStopPending = false;
|
|
this.$send.classList.remove('pending');
|
|
this.stopConversation();
|
|
} else {
|
|
this.isStopPending = true;
|
|
this.$send.classList.add('pending');
|
|
}
|
|
}
|
|
|
|
_cancelStopPending() {
|
|
if (!this.isStopPending) return;
|
|
this.isStopPending = false;
|
|
this.$send.classList.remove('pending');
|
|
}
|
|
|
|
_handleResetClick() {
|
|
if (this.isResetPending) {
|
|
this.isResetPending = false;
|
|
this.$reset.classList.remove('footer__toolbar-btn--pending');
|
|
this.reset();
|
|
} else {
|
|
this.isResetPending = true;
|
|
this.$reset.classList.add('footer__toolbar-btn--pending');
|
|
}
|
|
}
|
|
|
|
_cancelResetPending() {
|
|
if (!this.isResetPending) return;
|
|
this.isResetPending = false;
|
|
this.$reset.classList.remove('footer__toolbar-btn--pending');
|
|
}
|
|
|
|
_handleScrollClick() {
|
|
if (this.isScrollPending) {
|
|
this.isScrollPending = false;
|
|
this.$scroll.classList.remove('footer__toolbar-btn--pending');
|
|
const $chat = this.renderer.$chat;
|
|
if (this.renderer.isAtBottom()) {
|
|
$chat.scrollTo({ top: 0, behavior: 'smooth' });
|
|
} else {
|
|
$chat.scrollTo({ top: $chat.scrollHeight, behavior: 'smooth' });
|
|
}
|
|
this.renderer.updateScrollIcon();
|
|
} else {
|
|
this.isScrollPending = true;
|
|
this.$scroll.classList.add('footer__toolbar-btn--pending');
|
|
}
|
|
}
|
|
|
|
_cancelScrollPending() {
|
|
if (!this.isScrollPending) return;
|
|
this.isScrollPending = false;
|
|
this.$scroll.classList.remove('footer__toolbar-btn--pending');
|
|
}
|
|
|
|
_handleDelete(event, messageId, field, toolCallId, $btn) {
|
|
event.stopPropagation();
|
|
const deleteKey = field
|
|
? `${messageId}:${field}:${toolCallId || ''}`
|
|
: messageId;
|
|
|
|
if (this.pendingDeleteId === deleteKey) {
|
|
this.pendingDeleteId = null;
|
|
$btn.classList.remove('pending');
|
|
this._deleteField(messageId, field, toolCallId);
|
|
} else {
|
|
this._cancelDeletePending();
|
|
this.pendingDeleteId = deleteKey;
|
|
$btn.classList.add('pending');
|
|
}
|
|
}
|
|
|
|
_deleteField(messageId, field, toolCallId) {
|
|
if (!field) {
|
|
this.conversation.delete(messageId);
|
|
this.renderer.reRenderActivePath();
|
|
this.totalTokens = 0;
|
|
this.updateContext();
|
|
this.updateSendButtonState();
|
|
return;
|
|
}
|
|
|
|
const msg = this.conversation.get(messageId);
|
|
if (msg?.role !== 'assistant') return;
|
|
|
|
if (field === 'reasoning') {
|
|
msg.reasoning_content = '';
|
|
} else if (field === 'text') {
|
|
msg.content = '';
|
|
} else if (field === 'tool' && toolCallId) {
|
|
const idx = msg.tool_calls?.findIndex((tc) => tc.id === toolCallId);
|
|
if (idx !== -1) {
|
|
msg.tool_calls.splice(idx, 1);
|
|
}
|
|
for (const [id, m] of this.conversation.messagesMap) {
|
|
if (m.role === 'tool' && m.tool_call_id === toolCallId) {
|
|
this.conversation.delete(id);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
!extractText(msg.content) &&
|
|
!msg.reasoning_content &&
|
|
(!msg.tool_calls || msg.tool_calls.length === 0)
|
|
) {
|
|
this.conversation.delete(messageId);
|
|
this.renderer.reRenderActivePath();
|
|
this.totalTokens = 0;
|
|
this.updateContext();
|
|
this.updateSendButtonState();
|
|
} else {
|
|
this.renderer.reRenderActivePath();
|
|
this.conversation.save();
|
|
}
|
|
}
|
|
|
|
_cancelDeletePending() {
|
|
if (!this.pendingDeleteId) return;
|
|
const deleteKey = this.pendingDeleteId;
|
|
const parts = deleteKey.split(':');
|
|
const messageId = parts[0];
|
|
const field = parts[1] || null;
|
|
const toolCallId = parts[2] || null;
|
|
|
|
let $msg;
|
|
if (field === 'reasoning') {
|
|
$msg = this.renderer.$chat.querySelector(
|
|
`[data-id="${messageId}/reasoning"]`,
|
|
);
|
|
} else if (field === 'tool' && toolCallId) {
|
|
$msg = this.renderer.$chat.querySelector(
|
|
`[data-tool-call-id="${toolCallId}"]`,
|
|
);
|
|
} else {
|
|
$msg = this.renderer.$chat.querySelector(`[data-id="${messageId}"]`);
|
|
}
|
|
|
|
if ($msg) {
|
|
const $btn = $msg.querySelector('[data-action="delete"]');
|
|
if ($btn) $btn.classList.remove('pending');
|
|
}
|
|
this.pendingDeleteId = null;
|
|
this._currentRun = null;
|
|
}
|
|
|
|
_cancelAllPending() {
|
|
this._cancelResetPending();
|
|
this._cancelStopPending();
|
|
this._cancelScrollPending();
|
|
this._cancelDeletePending();
|
|
}
|
|
|
|
// ====================
|
|
// EDIT
|
|
// ====================
|
|
|
|
async _openEdit(messageId, field, toolCallId) {
|
|
const saved = await this.editModal.open(messageId, field, toolCallId);
|
|
if (saved) this.renderer.reRenderActivePath();
|
|
}
|
|
|
|
// ====================
|
|
// STOP CONVERSATION
|
|
// ====================
|
|
|
|
stopConversation() {
|
|
this.tts.stop();
|
|
if (this.currentController) {
|
|
this.currentController.abort();
|
|
this.currentController = null;
|
|
}
|
|
this.setLoadingState(false);
|
|
}
|
|
|
|
// ====================
|
|
// VISUAL VIEWPORT
|
|
// ====================
|
|
|
|
_initVisualViewport() {
|
|
if (!window.visualViewport) return;
|
|
const container = this.document.querySelector('.container');
|
|
if (!container) return;
|
|
|
|
const handler = () => {
|
|
const h = visualViewport.height;
|
|
if (h > 0) {
|
|
container.style.height = `${h}px`;
|
|
}
|
|
};
|
|
|
|
visualViewport.addEventListener('resize', handler);
|
|
handler();
|
|
}
|
|
|
|
// ====================
|
|
// ICONS
|
|
// ====================
|
|
|
|
_icon(name) {
|
|
const svg = this.document.createElementNS(
|
|
'http://www.w3.org/2000/svg',
|
|
'svg',
|
|
);
|
|
svg.setAttribute('class', 'icon');
|
|
const use = this.document.createElementNS(
|
|
'http://www.w3.org/2000/svg',
|
|
'use',
|
|
);
|
|
use.setAttribute('href', `${ICONS_URL}#${name}`);
|
|
svg.appendChild(use);
|
|
return svg;
|
|
}
|
|
}
|
|
|
|
// Bootstrap
|
|
new Odidere({ document });
|