2026-02-13 15:03:02 +00:00
|
|
|
const ICONS_URL = '/static/icons.svg';
|
|
|
|
|
const MODELS_ENDPOINT = '/v1/models';
|
|
|
|
|
const MODEL_KEY = 'odidere_model';
|
2026-06-19 14:31:41 +00:00
|
|
|
const PROVIDER_KEY = 'odidere_provider';
|
2026-02-13 15:03:02 +00:00
|
|
|
const STORAGE_KEY = 'odidere_history';
|
2026-06-26 14:22:05 +00:00
|
|
|
const STREAM_ENDPOINT = '/v1/chat/voice/stream';
|
|
|
|
|
const StreamEventDelta = 'delta';
|
|
|
|
|
const StreamEventDone = 'done';
|
|
|
|
|
const StreamEventMessage = 'message';
|
2026-05-28 00:53:08 +00:00
|
|
|
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
|
2026-02-13 15:03:02 +00:00
|
|
|
const VOICE_KEY = 'odidere_voice';
|
|
|
|
|
|
2026-06-15 10:51:17 +00:00
|
|
|
/**
|
2026-06-26 14:22:05 +00:00
|
|
|
* Content part type constants for OpenAI Chat Completions content parts.
|
|
|
|
|
* @see https://platform.openai.com/docs/api-reference/chat/create
|
|
|
|
|
*/
|
|
|
|
|
const ContentTypeImageURL = 'image_url';
|
|
|
|
|
const ContentTypeText = 'text';
|
|
|
|
|
const _ContentTypeFile = 'file';
|
|
|
|
|
const _ContentTypeInputAudio = 'input_audio';
|
|
|
|
|
const _ContentTypeRefusal = 'refusal';
|
|
|
|
|
/**
|
|
|
|
|
|
2026-06-15 10:51:17 +00:00
|
|
|
* VOICE_MAP maps whisper language names to default Kokoro voices.
|
|
|
|
|
* Used for auto-selecting a voice when the selector is set to "Auto".
|
|
|
|
|
*/
|
|
|
|
|
const VOICE_MAP = {
|
|
|
|
|
chinese: 'zf_xiaobei',
|
|
|
|
|
english: 'af_heart',
|
|
|
|
|
french: 'ff_siwis',
|
|
|
|
|
hindi: 'hf_alpha',
|
|
|
|
|
italian: 'if_sara',
|
|
|
|
|
japanese: 'jf_alpha',
|
|
|
|
|
korean: 'kf_sarah',
|
|
|
|
|
portuguese: 'pf_dora',
|
|
|
|
|
spanish: 'ef_dora',
|
|
|
|
|
};
|
2026-06-26 14:22:05 +00:00
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
/**
|
|
|
|
|
* Odidere is the main application class for the voice assistant UI.
|
|
|
|
|
* It manages audio recording, chat history, and communication with the API.
|
|
|
|
|
* Creates a new instance bound to the given document.
|
|
|
|
|
* @param {Object} options
|
|
|
|
|
* @param {Document} options.document
|
|
|
|
|
*/
|
|
|
|
|
class Odidere {
|
|
|
|
|
constructor({ document }) {
|
|
|
|
|
this.document = document;
|
|
|
|
|
|
|
|
|
|
// State
|
|
|
|
|
this.attachments = [];
|
|
|
|
|
this.audioChunks = [];
|
|
|
|
|
this.currentAudio = null;
|
2026-06-26 14:22:05 +00:00
|
|
|
this.currentAudioURL = null;
|
2026-02-13 15:03:02 +00:00
|
|
|
this.currentController = null;
|
|
|
|
|
this.isProcessing = false;
|
|
|
|
|
this.isRecording = false;
|
2026-06-15 10:51:17 +00:00
|
|
|
this.isTranscribing = false;
|
2026-02-13 15:03:02 +00:00
|
|
|
this.isMuted = false;
|
2026-05-28 01:00:12 +00:00
|
|
|
this.isResetPending = false;
|
2026-06-09 18:50:25 +00:00
|
|
|
this.isScrollPending = false;
|
2026-06-09 01:32:49 +00:00
|
|
|
this.isStopPending = false;
|
2026-02-13 15:03:02 +00:00
|
|
|
this.mediaRecorder = null;
|
2026-06-09 23:17:55 +00:00
|
|
|
this.wakeLock = null;
|
2026-06-12 19:26:30 +00:00
|
|
|
this.pendingDeleteId = null;
|
|
|
|
|
// Conversation state
|
|
|
|
|
this.messagesMap = new Map();
|
|
|
|
|
this.leafId = null;
|
|
|
|
|
this.rootId = null;
|
2026-02-13 15:03:02 +00:00
|
|
|
|
2026-06-13 18:55:16 +00:00
|
|
|
// TTS state
|
2026-06-26 14:22:05 +00:00
|
|
|
this.ttsURL = document.querySelector('meta[name="tts-url"]')?.content || '';
|
2026-06-13 18:55:16 +00:00
|
|
|
this.ttsDefaultVoice =
|
|
|
|
|
document.querySelector('meta[name="tts-default-voice"]')?.content || '';
|
|
|
|
|
this.playQueue = [];
|
|
|
|
|
this.isPlaying = false;
|
|
|
|
|
this.currentMessageId = null;
|
|
|
|
|
|
2026-06-15 10:51:17 +00:00
|
|
|
// STT state
|
2026-06-26 14:22:05 +00:00
|
|
|
this.sttURL = document.querySelector('meta[name="stt-url"]')?.content || '';
|
2026-06-15 10:51:17 +00:00
|
|
|
|
2026-06-09 18:50:25 +00:00
|
|
|
// Auto-scroll: enabled by default, disabled when user scrolls up,
|
|
|
|
|
// re-enabled when they scroll back to bottom or send a new message.
|
|
|
|
|
this.isAutoScrollEnabled = true;
|
|
|
|
|
this._lastScrollTop = 0;
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
// DOM Elements
|
|
|
|
|
this.$attach = document.getElementById('attach');
|
|
|
|
|
this.$attachments = document.getElementById('attachments');
|
|
|
|
|
this.$chat = document.getElementById('chat');
|
2026-06-09 01:32:49 +00:00
|
|
|
this.$chatLoading = document.getElementById('chat-loading');
|
2026-02-13 15:03:02 +00:00
|
|
|
this.$fileInput = document.getElementById('file-input');
|
|
|
|
|
this.$model = document.getElementById('model');
|
|
|
|
|
this.$ptt = document.getElementById('ptt');
|
|
|
|
|
this.$reset = document.getElementById('reset');
|
2026-06-09 18:50:25 +00:00
|
|
|
this.$scroll = document.getElementById('scroll');
|
2026-02-13 15:03:02 +00:00
|
|
|
this.$send = document.getElementById('send');
|
|
|
|
|
this.$textInput = document.getElementById('text-input');
|
|
|
|
|
this.$voice = document.getElementById('voice');
|
|
|
|
|
this.$mute = document.getElementById('mute');
|
2026-06-15 10:51:17 +00:00
|
|
|
this.$transcribe = document.getElementById('transcribe');
|
2026-02-13 15:03:02 +00:00
|
|
|
|
2026-05-28 00:53:08 +00:00
|
|
|
// Settings modal
|
|
|
|
|
this.$settings = document.getElementById('settings');
|
|
|
|
|
this.$settingsOverlay = document.getElementById('settings-overlay');
|
|
|
|
|
this.$settingsClose = document.getElementById('settings-close');
|
|
|
|
|
this.$settingsTabs = document.querySelectorAll('.settings-tab');
|
|
|
|
|
this.$settingsPanels = document.querySelectorAll('.settings-tab-panel');
|
|
|
|
|
this.$systemMessageInput = document.getElementById('system-message-input');
|
|
|
|
|
this.$saveSystemMessage = document.getElementById('save-system-message');
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
// Templates
|
|
|
|
|
this.$tplAssistantMessage = document.getElementById(
|
|
|
|
|
'tpl-assistant-message',
|
|
|
|
|
);
|
|
|
|
|
this.$tplAttachmentChip = document.getElementById('tpl-attachment-chip');
|
|
|
|
|
this.$tplCollapsible = document.getElementById('tpl-collapsible');
|
|
|
|
|
this.$tplDebugRow = document.getElementById('tpl-debug-row');
|
|
|
|
|
this.$tplErrorMessage = document.getElementById('tpl-error-message');
|
|
|
|
|
this.$tplToolCall = document.getElementById('tpl-tool-call');
|
|
|
|
|
this.$tplUserMessage = document.getElementById('tpl-user-message');
|
|
|
|
|
|
|
|
|
|
this.#init();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ====================
|
|
|
|
|
// PUBLIC API
|
|
|
|
|
// ====================
|
|
|
|
|
/**
|
|
|
|
|
* destroy releases all resources including media streams and audio.
|
|
|
|
|
*/
|
|
|
|
|
destroy() {
|
|
|
|
|
for (const track of this.mediaRecorder?.stream?.getTracks() ?? []) {
|
|
|
|
|
track.stop();
|
|
|
|
|
}
|
|
|
|
|
this.#stopCurrentAudio();
|
2026-06-13 18:55:16 +00:00
|
|
|
this.#stopTTS();
|
2026-02-13 15:03:02 +00:00
|
|
|
this.currentController?.abort();
|
2026-06-09 23:17:55 +00:00
|
|
|
this.#releaseWakeLock();
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* reset clears all state including history, attachments, and pending
|
|
|
|
|
* requests.
|
|
|
|
|
*/
|
|
|
|
|
reset() {
|
|
|
|
|
this.#stopCurrentAudio();
|
2026-06-13 18:55:16 +00:00
|
|
|
this.#stopTTS();
|
2026-02-13 15:03:02 +00:00
|
|
|
|
|
|
|
|
if (this.currentController) {
|
|
|
|
|
this.currentController.abort();
|
|
|
|
|
this.currentController = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.#setLoadingState(false);
|
2026-06-12 19:26:30 +00:00
|
|
|
this.pendingDeleteId = null;
|
|
|
|
|
this.messagesMap.clear();
|
|
|
|
|
this.leafId = null;
|
|
|
|
|
this.rootId = null;
|
2026-06-09 18:50:25 +00:00
|
|
|
this.isAutoScrollEnabled = true;
|
|
|
|
|
this._lastScrollTop = 0;
|
2026-02-13 15:03:02 +00:00
|
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
|
|
|
this.$chat.innerHTML = '';
|
|
|
|
|
this.#clearAttachments();
|
|
|
|
|
this.$textInput.value = '';
|
|
|
|
|
this.$textInput.style.height = 'auto';
|
2026-06-13 00:44:01 +00:00
|
|
|
this.#updateSendButtonState();
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ====================
|
|
|
|
|
// INITIALIZATION
|
|
|
|
|
// ====================
|
|
|
|
|
/**
|
|
|
|
|
* #init initializes the application.
|
|
|
|
|
*/
|
|
|
|
|
#init() {
|
|
|
|
|
this.#loadHistory();
|
|
|
|
|
this.#bindEvents();
|
|
|
|
|
this.#fetchVoices();
|
|
|
|
|
this.#fetchModels();
|
2026-06-09 23:17:55 +00:00
|
|
|
this.#initWakeLock();
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #bindEvents attaches all event listeners to DOM elements.
|
|
|
|
|
*/
|
|
|
|
|
#bindEvents() {
|
|
|
|
|
// PTT button: touch
|
|
|
|
|
this.$ptt.addEventListener('touchstart', this.#handlePttTouchStart, {
|
|
|
|
|
passive: false,
|
|
|
|
|
});
|
|
|
|
|
this.$ptt.addEventListener('touchend', this.#handlePttTouchEnd);
|
|
|
|
|
this.$ptt.addEventListener('touchcancel', this.#handlePttTouchEnd);
|
|
|
|
|
// Prevent context menu on long press.
|
|
|
|
|
this.$ptt.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
|
|
|
|
|
|
|
|
// PTT button: mouse
|
|
|
|
|
this.$ptt.addEventListener('mousedown', this.#handlePttMouseDown);
|
|
|
|
|
this.$ptt.addEventListener('mouseup', this.#handlePttMouseUp);
|
|
|
|
|
this.$ptt.addEventListener('mouseleave', this.#handlePttMouseUp);
|
|
|
|
|
|
|
|
|
|
// Keyboard: spacebar PTT (outside inputs)
|
|
|
|
|
this.document.addEventListener('keydown', this.#handleKeyDown);
|
|
|
|
|
this.document.addEventListener('keyup', this.#handleKeyUp);
|
|
|
|
|
|
|
|
|
|
// Textarea: Enter to send, Shift+Enter for newline
|
|
|
|
|
this.$textInput.addEventListener('keydown', this.#handleTextareaKeyDown);
|
|
|
|
|
this.$textInput.addEventListener('input', this.#handleTextareaInput);
|
|
|
|
|
|
2026-06-09 01:32:49 +00:00
|
|
|
// Send button: when loading, acts as stop button with two-press safety
|
|
|
|
|
this.$send.addEventListener('click', () => this.#handleSendClick());
|
|
|
|
|
this.document.addEventListener('click', (e) => this.#handleStopCancel(e));
|
2026-02-13 15:03:02 +00:00
|
|
|
|
2026-05-28 01:00:12 +00:00
|
|
|
// Reset button: two-press safety
|
|
|
|
|
this.$reset.addEventListener('click', () => this.#handleResetClick());
|
|
|
|
|
this.document.addEventListener('click', (e) => this.#handleResetCancel(e));
|
2026-02-13 15:03:02 +00:00
|
|
|
|
2026-06-09 18:50:25 +00:00
|
|
|
// Scroll button: two-press safety, swaps between scroll-to-bottom and
|
|
|
|
|
// scroll-to-top.
|
|
|
|
|
this.$scroll.addEventListener('click', () => this.#handleScrollClick());
|
|
|
|
|
this.document.addEventListener('click', (e) => this.#handleScrollCancel(e));
|
2026-06-12 19:26:30 +00:00
|
|
|
this.document.addEventListener('click', (e) => this.#handleDeleteCancel(e));
|
2026-06-09 18:50:25 +00:00
|
|
|
|
|
|
|
|
// File attachment.
|
2026-02-13 15:03:02 +00:00
|
|
|
this.$attach.addEventListener('click', () => this.$fileInput.click());
|
|
|
|
|
this.$fileInput.addEventListener('change', (e) =>
|
|
|
|
|
this.#handleAttachments(e.target.files),
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-15 10:51:17 +00:00
|
|
|
// Transcribe button: hold to record, release to transcribe and populate textarea.
|
|
|
|
|
this.$transcribe.addEventListener(
|
|
|
|
|
'touchstart',
|
|
|
|
|
this.#handleTranscribeTouchStart,
|
|
|
|
|
{
|
|
|
|
|
passive: false,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
this.$transcribe.addEventListener(
|
|
|
|
|
'touchend',
|
|
|
|
|
this.#handleTranscribeTouchEnd,
|
|
|
|
|
);
|
|
|
|
|
this.$transcribe.addEventListener(
|
|
|
|
|
'touchcancel',
|
|
|
|
|
this.#handleTranscribeTouchEnd,
|
|
|
|
|
);
|
|
|
|
|
// Prevent context menu on long press.
|
|
|
|
|
this.$transcribe.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
|
|
|
|
|
|
|
|
this.$transcribe.addEventListener(
|
|
|
|
|
'mousedown',
|
|
|
|
|
this.#handleTranscribeMouseDown,
|
|
|
|
|
);
|
|
|
|
|
this.$transcribe.addEventListener('mouseup', this.#handleTranscribeMouseUp);
|
|
|
|
|
this.$transcribe.addEventListener(
|
|
|
|
|
'mouseleave',
|
|
|
|
|
this.#handleTranscribeMouseUp,
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
// Save selections on change.
|
|
|
|
|
this.$model.addEventListener('change', () => {
|
2026-06-19 14:31:41 +00:00
|
|
|
const $opt = this.$model.options[this.$model.selectedIndex];
|
|
|
|
|
localStorage.setItem(PROVIDER_KEY, $opt.dataset.provider);
|
|
|
|
|
localStorage.setItem(MODEL_KEY, $opt.dataset.model);
|
2026-02-13 15:03:02 +00:00
|
|
|
});
|
|
|
|
|
this.$voice.addEventListener('change', () => {
|
|
|
|
|
localStorage.setItem(VOICE_KEY, this.$voice.value);
|
|
|
|
|
});
|
|
|
|
|
// Mute button
|
|
|
|
|
this.$mute.addEventListener('click', () => this.#toggleMute());
|
2026-05-28 00:53:08 +00:00
|
|
|
|
2026-06-09 18:50:25 +00:00
|
|
|
// Scroll button: update icon as user scrolls
|
|
|
|
|
this.$chat.addEventListener(
|
|
|
|
|
'scroll',
|
|
|
|
|
() => {
|
|
|
|
|
this.#updateScrollIcon();
|
|
|
|
|
this.#handleChatScroll();
|
|
|
|
|
},
|
|
|
|
|
{ passive: true },
|
|
|
|
|
);
|
|
|
|
|
this.#updateScrollIcon();
|
|
|
|
|
|
2026-05-28 00:53:08 +00:00
|
|
|
// Settings modal
|
|
|
|
|
this.$settings.addEventListener('click', () => this.openSettings());
|
|
|
|
|
this.$settingsClose.addEventListener('click', () => this.closeSettings());
|
|
|
|
|
this.$settingsOverlay.addEventListener('click', (e) => {
|
|
|
|
|
if (e.target === this.$settingsOverlay) this.closeSettings();
|
|
|
|
|
});
|
|
|
|
|
this.document.addEventListener('keydown', (e) => {
|
|
|
|
|
if (
|
|
|
|
|
e.key === 'Escape' &&
|
|
|
|
|
this.$settingsOverlay.classList.contains('open')
|
|
|
|
|
) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
this.closeSettings();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
this.$settingsTabs.forEach(($tab) => {
|
|
|
|
|
$tab.addEventListener('click', () => this.#switchTab($tab.dataset.tab));
|
|
|
|
|
});
|
|
|
|
|
this.$saveSystemMessage.addEventListener('click', () =>
|
|
|
|
|
this.#saveSystemMessage(),
|
|
|
|
|
);
|
2026-06-09 23:17:55 +00:00
|
|
|
|
|
|
|
|
// Wake lock: reacquire on visibility change
|
2026-06-09 23:27:12 +00:00
|
|
|
this.document.addEventListener('visibilitychange', () =>
|
|
|
|
|
this.#handleVisibilityChange(),
|
|
|
|
|
);
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-12 19:26:30 +00:00
|
|
|
// ====================
|
|
|
|
|
// CONVERSATION TREE
|
2026-06-13 18:55:16 +00:00
|
|
|
// ====================
|
2026-06-12 19:26:30 +00:00
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
/**
|
|
|
|
|
* #loadHistory loads chat history from localStorage and renders it.
|
|
|
|
|
*/
|
|
|
|
|
#loadHistory() {
|
|
|
|
|
try {
|
|
|
|
|
const stored = localStorage.getItem(STORAGE_KEY);
|
|
|
|
|
if (!stored) return;
|
|
|
|
|
|
2026-06-12 19:26:30 +00:00
|
|
|
const parsed = JSON.parse(stored);
|
|
|
|
|
|
|
|
|
|
// Ensure messages have parent/children fields (defensive).
|
|
|
|
|
for (const msg of parsed.messages) {
|
|
|
|
|
if (msg.parent === undefined) msg.parent = null;
|
|
|
|
|
if (msg.children === undefined) msg.children = [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.messagesMap = new Map(parsed.messages.map((m) => [m.id, m]));
|
|
|
|
|
this.leafId = parsed.leafId;
|
|
|
|
|
this.rootId = parsed.rootId;
|
|
|
|
|
|
|
|
|
|
// Render the active path.
|
|
|
|
|
const activePath = this.getActivePath();
|
|
|
|
|
this.#renderMessages(activePath);
|
2026-06-13 00:44:01 +00:00
|
|
|
this.#updateSendButtonState();
|
2026-02-13 15:03:02 +00:00
|
|
|
} catch (e) {
|
|
|
|
|
console.error('failed to load history:', e);
|
2026-06-12 19:26:30 +00:00
|
|
|
this.messagesMap.clear();
|
|
|
|
|
this.leafId = null;
|
|
|
|
|
this.rootId = null;
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 19:26:30 +00:00
|
|
|
/**
|
|
|
|
|
* #saveToStorage persists the tree to localStorage.
|
|
|
|
|
*/
|
|
|
|
|
#saveToStorage() {
|
|
|
|
|
const messages = Array.from(this.messagesMap.values());
|
|
|
|
|
const data = {
|
|
|
|
|
messages,
|
|
|
|
|
leafId: this.leafId,
|
|
|
|
|
rootId: this.rootId,
|
|
|
|
|
};
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('failed to save history:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* getActivePath resolves the ordered array of messages from root to leaf
|
|
|
|
|
* by walking parent pointers from leafId.
|
|
|
|
|
* @returns {Object[]} ordered messages
|
|
|
|
|
*/
|
|
|
|
|
getActivePath() {
|
|
|
|
|
if (!this.leafId) return [];
|
|
|
|
|
const path = [];
|
|
|
|
|
let current = this.leafId;
|
|
|
|
|
while (current) {
|
|
|
|
|
const msg = this.messagesMap.get(current);
|
|
|
|
|
if (!msg) break;
|
|
|
|
|
path.push(msg);
|
|
|
|
|
current = msg.parent;
|
|
|
|
|
}
|
|
|
|
|
path.reverse();
|
|
|
|
|
return path;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #appendHistory adds messages to the tree. Each new message is a child
|
|
|
|
|
* of the previous one (or the current leaf for the first).
|
|
|
|
|
* Only the last message receives the metadata.
|
|
|
|
|
* Mutates the passed-in message objects in place (adds id, parent, children,
|
|
|
|
|
* meta) and stores the same references in messagesMap. Callers should use
|
|
|
|
|
* the returned messages for rendering to ensure the delete handler captures
|
|
|
|
|
* the correct IDs.
|
|
|
|
|
* @param {Object[]} messages
|
|
|
|
|
* @param {Object} [meta]
|
|
|
|
|
* @returns {Object[]} the same message objects, now enriched
|
|
|
|
|
*/
|
|
|
|
|
#appendHistory(messages, meta = {}) {
|
|
|
|
|
let prevId = this.leafId;
|
|
|
|
|
for (let i = 0; i < messages.length; i++) {
|
|
|
|
|
const msg = messages[i];
|
|
|
|
|
|
|
|
|
|
// Generate ID if not present.
|
|
|
|
|
if (!msg.id) {
|
|
|
|
|
msg.id = crypto.randomUUID();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Attach metadata only to the final message.
|
|
|
|
|
if (i === messages.length - 1) {
|
|
|
|
|
msg.meta = meta;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Link to the tree.
|
|
|
|
|
msg.parent = prevId;
|
|
|
|
|
msg.children = [];
|
|
|
|
|
|
|
|
|
|
if (prevId) {
|
|
|
|
|
const parent = this.messagesMap.get(prevId);
|
|
|
|
|
if (parent) {
|
|
|
|
|
parent.children.push(msg.id);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// First message in the conversation.
|
|
|
|
|
this.rootId = msg.id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.messagesMap.set(msg.id, msg);
|
|
|
|
|
this.leafId = msg.id;
|
|
|
|
|
prevId = msg.id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.#saveToStorage();
|
2026-06-13 00:44:01 +00:00
|
|
|
this.#updateSendButtonState();
|
2026-06-12 19:26:30 +00:00
|
|
|
return messages;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* deleteMessage removes a message from the tree and reparents its
|
|
|
|
|
* children to the grandparent. Also handles tool call / tool result
|
|
|
|
|
* pairing. When deleting the root, the first child becomes the new root
|
|
|
|
|
* and any remaining children are reparented under it.
|
|
|
|
|
* @param {string} messageId
|
|
|
|
|
*/
|
|
|
|
|
deleteMessage(messageId) {
|
|
|
|
|
const msg = this.messagesMap.get(messageId);
|
|
|
|
|
if (!msg) return;
|
|
|
|
|
|
|
|
|
|
const parentId = msg.parent; // null if this is the root
|
|
|
|
|
const parent = parentId ? this.messagesMap.get(parentId) : null;
|
|
|
|
|
|
|
|
|
|
// Identify tool result messages that belong to this assistant message.
|
|
|
|
|
const idsToDelete = new Set([messageId]);
|
|
|
|
|
if (msg.role === 'assistant' && msg.tool_calls?.length > 0) {
|
|
|
|
|
const toolCallIds = new Set(msg.tool_calls.map((tc) => tc.id));
|
|
|
|
|
for (const [id, m] of this.messagesMap) {
|
|
|
|
|
if (m.role === 'tool' && toolCallIds.has(m.tool_call_id)) {
|
|
|
|
|
idsToDelete.add(id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collect all children that need reparenting.
|
|
|
|
|
const childrenToReparent = [];
|
|
|
|
|
for (const deleteId of idsToDelete) {
|
|
|
|
|
const delMsg = this.messagesMap.get(deleteId);
|
|
|
|
|
if (delMsg?.children) {
|
|
|
|
|
childrenToReparent.push(...delMsg.children);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove deleted messages from the map.
|
|
|
|
|
for (const deleteId of idsToDelete) {
|
|
|
|
|
this.messagesMap.delete(deleteId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove deleted IDs from parent's children.
|
|
|
|
|
if (parent) {
|
|
|
|
|
parent.children = parent.children.filter((c) => !idsToDelete.has(c));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reparent children of deleted messages to the grandparent.
|
|
|
|
|
// When deleting the root, the first child becomes the new root and the
|
|
|
|
|
// remaining children are reparented under it.
|
|
|
|
|
if (parent) {
|
|
|
|
|
for (const childId of childrenToReparent) {
|
|
|
|
|
const child = this.messagesMap.get(childId);
|
|
|
|
|
if (child) {
|
|
|
|
|
child.parent = parentId;
|
|
|
|
|
if (!parent.children.includes(childId)) {
|
|
|
|
|
parent.children.push(childId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if (childrenToReparent.length > 0) {
|
|
|
|
|
// Root deletion: first child becomes the new root, rest become its children.
|
|
|
|
|
const newRootId = childrenToReparent[0];
|
|
|
|
|
const newRoot = this.messagesMap.get(newRootId);
|
|
|
|
|
if (newRoot) {
|
|
|
|
|
newRoot.parent = null;
|
|
|
|
|
for (let i = 1; i < childrenToReparent.length; i++) {
|
|
|
|
|
const childId = childrenToReparent[i];
|
|
|
|
|
const child = this.messagesMap.get(childId);
|
|
|
|
|
if (child) {
|
|
|
|
|
child.parent = newRootId;
|
|
|
|
|
if (!newRoot.children.includes(childId)) {
|
|
|
|
|
newRoot.children.push(childId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
this.rootId = newRootId;
|
|
|
|
|
} else {
|
|
|
|
|
this.rootId = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update leafId if it was deleted or was a descendant of a deleted message.
|
|
|
|
|
if (idsToDelete.has(this.leafId)) {
|
|
|
|
|
if (parentId) {
|
|
|
|
|
this.#updateLeafId(parentId);
|
|
|
|
|
} else if (childrenToReparent.length > 0) {
|
|
|
|
|
this.#updateLeafId(childrenToReparent[0]);
|
|
|
|
|
} else {
|
|
|
|
|
this.leafId = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove the DOM element(s).
|
|
|
|
|
for (const deleteId of idsToDelete) {
|
|
|
|
|
const $el = this.$chat.querySelector(`[data-id="${deleteId}"]`);
|
|
|
|
|
if ($el) $el.remove();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.#saveToStorage();
|
2026-06-13 00:44:01 +00:00
|
|
|
this.#updateSendButtonState();
|
2026-06-12 19:26:30 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #updateLeafId walks the tree from a starting node to find the deepest
|
|
|
|
|
* leaf by following the first child at each step. Used when the active
|
|
|
|
|
* leaf is deleted and a new one must be chosen. With branching, this
|
|
|
|
|
* picks the first branch; future work may track which branch was active.
|
|
|
|
|
* @param {string} startId
|
|
|
|
|
*/
|
|
|
|
|
#updateLeafId(startId) {
|
|
|
|
|
let current = startId;
|
|
|
|
|
while (current) {
|
|
|
|
|
const msg = this.messagesMap.get(current);
|
2026-06-13 18:55:16 +00:00
|
|
|
if (!msg?.children || msg.children.length === 0) break;
|
2026-06-12 19:26:30 +00:00
|
|
|
// Follow the first child. With branching, this picks the first branch.
|
|
|
|
|
current = msg.children[0];
|
|
|
|
|
}
|
|
|
|
|
this.leafId = current;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
// ====================
|
|
|
|
|
// EVENT HANDLERS
|
|
|
|
|
// ====================
|
|
|
|
|
/**
|
|
|
|
|
* #handlePttTouchStart handles touch start on the PTT button.
|
|
|
|
|
* @param {TouchEvent} event
|
|
|
|
|
*/
|
|
|
|
|
#handlePttTouchStart = (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
this.#startRecording();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handlePttTouchEnd handles touch end on the PTT button.
|
|
|
|
|
* @param {TouchEvent} event
|
|
|
|
|
*/
|
|
|
|
|
#handlePttTouchEnd = (event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
this.#stopRecording();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handlePttMouseDown handles mouse down on the PTT button.
|
|
|
|
|
* Ignores events that originate from touch to avoid double-firing.
|
|
|
|
|
* @param {MouseEvent} event
|
|
|
|
|
*/
|
|
|
|
|
#handlePttMouseDown = (event) => {
|
|
|
|
|
if (event.sourceCapabilities?.firesTouchEvents) return;
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
this.#startRecording();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handlePttMouseUp handles mouse up on the PTT button.
|
|
|
|
|
* @param {MouseEvent} event
|
|
|
|
|
*/
|
|
|
|
|
#handlePttMouseUp = (event) => {
|
|
|
|
|
if (event.sourceCapabilities?.firesTouchEvents) return;
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
this.#stopRecording();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handleKeyDown handles keydown events for spacebar PTT.
|
|
|
|
|
* Only triggers when focus is not in an input element.
|
|
|
|
|
* @param {KeyboardEvent} event
|
|
|
|
|
*/
|
|
|
|
|
#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.#startRecording();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handleKeyUp handles keyup events for spacebar PTT.
|
|
|
|
|
* @param {KeyboardEvent} event
|
|
|
|
|
*/
|
|
|
|
|
#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.#stopRecording();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handleTextareaKeyDown handles Enter key to submit text.
|
|
|
|
|
* Shift+Enter inserts a newline instead.
|
|
|
|
|
* @param {KeyboardEvent} event
|
|
|
|
|
*/
|
|
|
|
|
#handleTextareaKeyDown = (event) => {
|
|
|
|
|
if (event.code !== 'Enter') return;
|
|
|
|
|
if (event.shiftKey) return;
|
|
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
this.#submitText();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handleTextareaInput adjusts textarea height to fit content.
|
|
|
|
|
*/
|
|
|
|
|
#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`;
|
2026-06-13 00:44:01 +00:00
|
|
|
this.#updateSendButtonState();
|
2026-02-13 15:03:02 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handleAttachments adds files to the attachment list.
|
|
|
|
|
* Duplicates are ignored based on name and size.
|
|
|
|
|
* @param {FileList} files
|
|
|
|
|
*/
|
|
|
|
|
#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();
|
2026-06-13 00:44:01 +00:00
|
|
|
this.#updateSendButtonState();
|
2026-02-13 15:03:02 +00:00
|
|
|
this.$fileInput.value = '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 01:00:12 +00:00
|
|
|
/**
|
|
|
|
|
* #handleResetClick toggles the pending state on first press,
|
|
|
|
|
* executes the reset on second press.
|
|
|
|
|
*/
|
|
|
|
|
#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');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handleResetCancel clears the pending reset state when the user
|
|
|
|
|
* clicks anywhere outside the reset button.
|
|
|
|
|
* @param {MouseEvent} event
|
|
|
|
|
*/
|
|
|
|
|
#handleResetCancel(event) {
|
|
|
|
|
if (!this.isResetPending) return;
|
|
|
|
|
if (this.$reset.contains(event.target)) return;
|
|
|
|
|
this.isResetPending = false;
|
|
|
|
|
this.$reset.classList.remove('footer__toolbar-btn--pending');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 18:50:25 +00:00
|
|
|
/**
|
|
|
|
|
* #handleScrollClick toggles the pending scroll state on first press,
|
|
|
|
|
* executes the scroll on second press. At bottom → scroll to top,
|
|
|
|
|
* elsewhere → scroll to bottom.
|
|
|
|
|
*/
|
|
|
|
|
#handleScrollClick() {
|
|
|
|
|
if (this.isScrollPending) {
|
|
|
|
|
this.isScrollPending = false;
|
|
|
|
|
this.$scroll.classList.remove('footer__toolbar-btn--pending');
|
|
|
|
|
if (this.#isAtBottom()) {
|
|
|
|
|
this.$chat.scrollTo({ top: 0, behavior: 'smooth' });
|
|
|
|
|
} else {
|
|
|
|
|
this.$chat.scrollTo({
|
|
|
|
|
top: this.$chat.scrollHeight,
|
|
|
|
|
behavior: 'smooth',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
this.#updateScrollIcon();
|
|
|
|
|
} else {
|
|
|
|
|
this.isScrollPending = true;
|
|
|
|
|
this.$scroll.classList.add('footer__toolbar-btn--pending');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handleScrollCancel clears the pending scroll state when the user
|
|
|
|
|
* clicks anywhere outside the scroll button.
|
|
|
|
|
* @param {MouseEvent} event
|
|
|
|
|
*/
|
|
|
|
|
#handleScrollCancel(event) {
|
|
|
|
|
if (!this.isScrollPending) return;
|
|
|
|
|
if (this.$scroll.contains(event.target)) return;
|
|
|
|
|
this.isScrollPending = false;
|
|
|
|
|
this.$scroll.classList.remove('footer__toolbar-btn--pending');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handleChatScroll detects whether the user scrolled up or down and
|
|
|
|
|
* toggles auto-scroll accordingly. Scrolling up disables it; scrolling
|
|
|
|
|
* back to the bottom re-enables it.
|
|
|
|
|
*/
|
|
|
|
|
#handleChatScroll() {
|
|
|
|
|
const { scrollTop, scrollHeight, clientHeight } = this.$chat;
|
|
|
|
|
const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
|
|
|
|
|
const isAtBottom = distanceFromBottom < 10;
|
|
|
|
|
|
|
|
|
|
if (scrollTop < this._lastScrollTop && !isAtBottom) {
|
|
|
|
|
// User scrolled up and is not at the bottom → disable auto-scroll.
|
|
|
|
|
this.isAutoScrollEnabled = false;
|
|
|
|
|
} else if (isAtBottom) {
|
|
|
|
|
// User scrolled back to the bottom → re-enable auto-scroll.
|
|
|
|
|
this.isAutoScrollEnabled = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this._lastScrollTop = scrollTop;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #isAtBottom checks if the chat is scrolled within 10px of the bottom.
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
#isAtBottom() {
|
|
|
|
|
const { scrollTop, scrollHeight, clientHeight } = this.$chat;
|
|
|
|
|
return scrollHeight - clientHeight - scrollTop < 10;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #updateScrollIcon toggles a CSS class to show the appropriate arrow
|
|
|
|
|
* and updates the aria-label. No DOM nodes are created on scroll.
|
|
|
|
|
*/
|
|
|
|
|
#updateScrollIcon() {
|
|
|
|
|
const atBottom = this.#isAtBottom();
|
|
|
|
|
this.$scroll.classList.toggle('scrolled-up', !atBottom);
|
|
|
|
|
this.$scroll.setAttribute(
|
|
|
|
|
'aria-label',
|
|
|
|
|
atBottom ? 'Scroll to top' : 'Scroll to bottom',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #scrollToBottom scrolls the chat to the bottom if auto-scroll is
|
|
|
|
|
* enabled (i.e., the user is following along and hasn't scrolled up).
|
|
|
|
|
*/
|
|
|
|
|
#scrollToBottom() {
|
|
|
|
|
if (this.isAutoScrollEnabled) {
|
|
|
|
|
this.$chat.scrollTop = this.$chat.scrollHeight;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 01:32:49 +00:00
|
|
|
/**
|
|
|
|
|
* #handleSendClick routes to either send or stop based on loading state.
|
|
|
|
|
*/
|
|
|
|
|
#handleSendClick() {
|
|
|
|
|
if (this.$send.classList.contains('loading')) {
|
|
|
|
|
this.#handleStopClick();
|
|
|
|
|
} else {
|
|
|
|
|
this.#submitText();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handleStopClick toggles the pending stop state on first press,
|
|
|
|
|
* aborts the current request on second press.
|
|
|
|
|
*/
|
|
|
|
|
#handleStopClick() {
|
|
|
|
|
if (this.isStopPending) {
|
|
|
|
|
this.isStopPending = false;
|
|
|
|
|
this.$send.classList.remove('pending');
|
|
|
|
|
this.#stopConversation();
|
|
|
|
|
} else {
|
|
|
|
|
this.isStopPending = true;
|
|
|
|
|
this.$send.classList.add('pending');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handleStopCancel clears the pending stop state when the user
|
|
|
|
|
* clicks anywhere outside the send button.
|
|
|
|
|
* @param {MouseEvent} event
|
|
|
|
|
*/
|
|
|
|
|
#handleStopCancel(event) {
|
|
|
|
|
if (!this.isStopPending) return;
|
|
|
|
|
if (this.$send.contains(event.target)) return;
|
|
|
|
|
this.isStopPending = false;
|
|
|
|
|
this.$send.classList.remove('pending');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 19:26:30 +00:00
|
|
|
/**
|
|
|
|
|
* #handleDeleteClick toggles the pending delete state on first press
|
|
|
|
|
* (yellow highlight), executes the delete on second press. Clicking
|
|
|
|
|
* anywhere outside the button cancels the pending state.
|
|
|
|
|
* @param {MouseEvent} event
|
|
|
|
|
* @param {string} messageId
|
|
|
|
|
*/
|
|
|
|
|
#handleDeleteClick(event, messageId) {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
const $btn = event.currentTarget;
|
|
|
|
|
|
|
|
|
|
if (this.pendingDeleteId === messageId) {
|
|
|
|
|
this.pendingDeleteId = null;
|
|
|
|
|
$btn.classList.remove('pending');
|
|
|
|
|
this.deleteMessage(messageId);
|
|
|
|
|
} else {
|
|
|
|
|
// Clear any previously pending delete button.
|
|
|
|
|
this.#clearPendingDelete();
|
|
|
|
|
this.pendingDeleteId = messageId;
|
|
|
|
|
$btn.classList.add('pending');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #clearPendingDelete clears the pending delete state and removes the
|
|
|
|
|
* yellow highlight from the delete button.
|
|
|
|
|
*/
|
|
|
|
|
#clearPendingDelete() {
|
|
|
|
|
if (!this.pendingDeleteId) return;
|
|
|
|
|
const $msg = this.$chat.querySelector(
|
|
|
|
|
`[data-id="${this.pendingDeleteId}"]`,
|
|
|
|
|
);
|
|
|
|
|
if ($msg) {
|
|
|
|
|
const $btn = $msg.querySelector('[data-action="delete"]');
|
|
|
|
|
if ($btn) $btn.classList.remove('pending');
|
|
|
|
|
}
|
|
|
|
|
this.pendingDeleteId = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handleDeleteCancel clears the pending delete state when the user
|
|
|
|
|
* clicks anywhere outside the delete button.
|
|
|
|
|
* @param {MouseEvent} event
|
|
|
|
|
*/
|
|
|
|
|
#handleDeleteCancel(event) {
|
|
|
|
|
if (!this.pendingDeleteId) return;
|
|
|
|
|
const $btn = event.target.closest('[data-action="delete"]');
|
|
|
|
|
if ($btn) return;
|
|
|
|
|
this.#clearPendingDelete();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 01:32:49 +00:00
|
|
|
/**
|
|
|
|
|
* #stopConversation aborts the current streaming request and audio.
|
|
|
|
|
*/
|
|
|
|
|
#stopConversation() {
|
|
|
|
|
this.#stopCurrentAudio();
|
2026-06-13 18:55:16 +00:00
|
|
|
this.#stopTTS();
|
2026-06-09 01:32:49 +00:00
|
|
|
|
|
|
|
|
if (this.currentController) {
|
|
|
|
|
this.currentController.abort();
|
|
|
|
|
this.currentController = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.#setLoadingState(false);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 10:51:17 +00:00
|
|
|
// ====================
|
|
|
|
|
// STT: FRONTEND TRANSCRIPTION
|
|
|
|
|
// ====================
|
|
|
|
|
/**
|
|
|
|
|
* #transcribeAudio sends an audio blob to whisper-server directly from
|
|
|
|
|
* the browser and returns the transcription result.
|
|
|
|
|
* @param {Blob} audioBlob
|
|
|
|
|
* @returns {Promise<{text: string, detectedLanguage: string}>}
|
|
|
|
|
*/
|
|
|
|
|
async #transcribeAudio(audioBlob) {
|
2026-06-26 14:22:05 +00:00
|
|
|
if (!this.sttURL) {
|
2026-06-15 10:51:17 +00:00
|
|
|
throw new Error('STT URL not configured');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append('file', audioBlob, 'audio.webm');
|
|
|
|
|
formData.append('response_format', 'verbose_json');
|
|
|
|
|
|
2026-06-26 14:22:05 +00:00
|
|
|
const res = await fetch(`${this.sttURL}/inference`, {
|
2026-06-15 10:51:17 +00:00
|
|
|
method: 'POST',
|
|
|
|
|
body: formData,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const errText = await res.text().catch(() => '');
|
|
|
|
|
throw new Error(`STT error ${res.status}: ${errText}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
const text = (data.text || '').trim();
|
|
|
|
|
const detectedLanguage = data.detected_language || data.language || '';
|
|
|
|
|
return { text, detectedLanguage };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handleTranscribeTouchStart handles touch start on the transcribe button.
|
|
|
|
|
* Begins recording for transcription (hold-to-record, like PTT).
|
|
|
|
|
* @param {TouchEvent} event
|
|
|
|
|
*/
|
|
|
|
|
#handleTranscribeTouchStart = (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
this.#startTranscribeRecording();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handleTranscribeTouchEnd handles touch end on the transcribe button.
|
|
|
|
|
* Stops recording; the shared stop handler will populate the textarea.
|
|
|
|
|
* @param {TouchEvent} event
|
|
|
|
|
*/
|
|
|
|
|
#handleTranscribeTouchEnd = (event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
this.#stopTranscribeRecording();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handleTranscribeMouseDown handles mouse down on the transcribe button.
|
|
|
|
|
* Ignores events that originate from touch to avoid double-firing.
|
|
|
|
|
* @param {MouseEvent} event
|
|
|
|
|
*/
|
|
|
|
|
#handleTranscribeMouseDown = (event) => {
|
|
|
|
|
if (event.sourceCapabilities?.firesTouchEvents) return;
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
this.#startTranscribeRecording();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handleTranscribeMouseUp handles mouse up on the transcribe button.
|
|
|
|
|
* @param {MouseEvent} event
|
|
|
|
|
*/
|
|
|
|
|
#handleTranscribeMouseUp = (event) => {
|
|
|
|
|
if (event.sourceCapabilities?.firesTouchEvents) return;
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
this.#stopTranscribeRecording();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #startTranscribeRecording begins recording for transcription mode.
|
|
|
|
|
* Unlike PTT, this populates the textarea on stop rather than sending.
|
|
|
|
|
*/
|
|
|
|
|
async #startTranscribeRecording() {
|
|
|
|
|
if (this.isTranscribing) return;
|
|
|
|
|
if (this.isRecording) return;
|
|
|
|
|
if (this.isProcessing) return;
|
|
|
|
|
|
|
|
|
|
this.#stopCurrentAudio();
|
|
|
|
|
this.#stopTTS();
|
|
|
|
|
|
|
|
|
|
// Initialize MediaRecorder on first use (shared with PTT).
|
|
|
|
|
if (!this.mediaRecorder) {
|
|
|
|
|
const success = await this.#initMediaRecorder();
|
|
|
|
|
if (!success) return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.audioChunks = [];
|
|
|
|
|
this.mediaRecorder.start();
|
|
|
|
|
this.isTranscribing = true;
|
|
|
|
|
this.$transcribe.classList.add('compose__action-btn--recording');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #stopTranscribeRecording stops the media recorder for transcription mode.
|
|
|
|
|
* The shared 'stop' event handler (set up in #initMediaRecorder) will
|
|
|
|
|
* fire and clear UI state since this.isTranscribing is still true.
|
|
|
|
|
*/
|
|
|
|
|
#stopTranscribeRecording() {
|
|
|
|
|
if (!this.isTranscribing) return;
|
|
|
|
|
if (!this.mediaRecorder) return;
|
|
|
|
|
|
|
|
|
|
// Keep isTranscribing=true so the stop handler knows to populate textarea.
|
|
|
|
|
// The stop handler is responsible for clearing the recording class,
|
|
|
|
|
// so we do not remove it here.
|
|
|
|
|
this.mediaRecorder.stop();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
// ====================
|
|
|
|
|
// AUDIO RECORDING
|
|
|
|
|
// ====================
|
|
|
|
|
/**
|
|
|
|
|
* #startRecording begins audio recording if not already recording or
|
|
|
|
|
* processing.
|
|
|
|
|
* Initializes the MediaRecorder on first use.
|
|
|
|
|
*/
|
|
|
|
|
async #startRecording() {
|
|
|
|
|
if (this.isRecording) return;
|
2026-06-15 10:51:17 +00:00
|
|
|
if (this.isTranscribing) return;
|
2026-02-13 15:03:02 +00:00
|
|
|
if (this.isProcessing) return;
|
|
|
|
|
|
|
|
|
|
this.#stopCurrentAudio();
|
2026-06-13 18:55:16 +00:00
|
|
|
this.#stopTTS();
|
2026-02-13 15:03:02 +00:00
|
|
|
|
|
|
|
|
// Initialize MediaRecorder on first use
|
|
|
|
|
if (!this.mediaRecorder) {
|
|
|
|
|
const success = await this.#initMediaRecorder();
|
|
|
|
|
if (!success) return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.audioChunks = [];
|
|
|
|
|
this.mediaRecorder.start();
|
|
|
|
|
this.#setRecordingState(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #stopRecording stops the current audio recording.
|
|
|
|
|
*/
|
|
|
|
|
#stopRecording() {
|
|
|
|
|
if (!this.isRecording) return;
|
|
|
|
|
if (!this.mediaRecorder) return;
|
|
|
|
|
|
2026-06-15 10:51:17 +00:00
|
|
|
// Do not clear recording state here. The async 'stop' event handler
|
|
|
|
|
// is responsible for clearing it (#setRecordingState(false)) after
|
|
|
|
|
// the audio blob has been assembled.
|
2026-02-13 15:03:02 +00:00
|
|
|
this.mediaRecorder.stop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #initMediaRecorder initializes the MediaRecorder with microphone access.
|
|
|
|
|
* Prefers opus codec for better compression if available.
|
2026-06-15 10:51:17 +00:00
|
|
|
* The stop handler branches on this.isTranscribing: when true it populates
|
|
|
|
|
* the textarea; when false (PTT flow) it sends the request.
|
2026-02-13 15:03:02 +00:00
|
|
|
* @returns {Promise<boolean>} true if successful
|
|
|
|
|
*/
|
|
|
|
|
async #initMediaRecorder() {
|
|
|
|
|
try {
|
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
|
|
|
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
|
|
|
|
? 'audio/webm;codecs=opus'
|
|
|
|
|
: 'audio/webm';
|
|
|
|
|
|
|
|
|
|
this.mediaRecorder = new MediaRecorder(stream, { mimeType });
|
|
|
|
|
|
|
|
|
|
this.mediaRecorder.addEventListener('dataavailable', (event) => {
|
|
|
|
|
if (event.data.size > 0) {
|
|
|
|
|
this.audioChunks.push(event.data);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.mediaRecorder.addEventListener('stop', async () => {
|
|
|
|
|
if (this.audioChunks.length === 0) return;
|
|
|
|
|
|
2026-06-15 10:51:17 +00:00
|
|
|
// Capture the mode synchronously before any async work.
|
|
|
|
|
// The 'stop' event fires asynchronously, so this.isTranscribing
|
|
|
|
|
// may have changed by the time we await below.
|
|
|
|
|
const wasTranscribing = this.isTranscribing;
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
const audioBlob = new Blob(this.audioChunks, {
|
|
|
|
|
type: this.mediaRecorder.mimeType,
|
|
|
|
|
});
|
|
|
|
|
this.audioChunks = [];
|
|
|
|
|
|
2026-06-15 10:51:17 +00:00
|
|
|
// Clear the flag now that the stop event has fired.
|
|
|
|
|
if (wasTranscribing) {
|
|
|
|
|
this.isTranscribing = false;
|
|
|
|
|
this.$transcribe.classList.remove('compose__action-btn--recording');
|
|
|
|
|
} else {
|
|
|
|
|
this.#setRecordingState(false);
|
|
|
|
|
}
|
2026-02-13 15:03:02 +00:00
|
|
|
|
2026-06-15 10:51:17 +00:00
|
|
|
// Transcribe the audio.
|
|
|
|
|
try {
|
|
|
|
|
const { text: transcription, detectedLanguage } =
|
|
|
|
|
await this.#transcribeAudio(audioBlob);
|
|
|
|
|
|
|
|
|
|
// Resolve the effective voice for this message.
|
|
|
|
|
// If the selector is "Auto", pick a default for the detected language.
|
|
|
|
|
// This does NOT change the dropdown - the voice is per-message only.
|
|
|
|
|
let voice = this.$voice.value;
|
|
|
|
|
if (!voice && detectedLanguage) {
|
|
|
|
|
voice = VOICE_MAP[detectedLanguage] || '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Branch: transcribe-only (populate textarea) vs PTT (send).
|
|
|
|
|
if (wasTranscribing) {
|
|
|
|
|
// Transcribe mode: populate textarea, do not send.
|
|
|
|
|
this.$transcribe.classList.add('compose__action-btn--transcribing');
|
|
|
|
|
try {
|
|
|
|
|
if (transcription) {
|
|
|
|
|
const existing = this.$textInput.value;
|
|
|
|
|
if (existing.trim()) {
|
|
|
|
|
this.$textInput.value = `${existing}\n${transcription}`;
|
|
|
|
|
} else {
|
|
|
|
|
this.$textInput.value = transcription;
|
|
|
|
|
}
|
|
|
|
|
this.#handleTextareaInput();
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
this.$transcribe.classList.remove(
|
|
|
|
|
'compose__action-btn--transcribing',
|
|
|
|
|
);
|
|
|
|
|
this.$textInput.focus();
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// PTT mode: capture text, clear input, send request.
|
|
|
|
|
const text = this.$textInput.value.trim();
|
|
|
|
|
this.$textInput.value = '';
|
|
|
|
|
this.$textInput.style.height = 'auto';
|
|
|
|
|
|
|
|
|
|
const combined = text ? `${text}\n${transcription}` : transcription;
|
|
|
|
|
await this.#sendRequest({
|
|
|
|
|
text: combined,
|
|
|
|
|
detectedLanguage,
|
|
|
|
|
voice,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('transcription failed:', e);
|
|
|
|
|
this.#renderError(`Transcription failed: ${e.message}`);
|
|
|
|
|
if (!wasTranscribing) {
|
|
|
|
|
this.#setLoadingState(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-13 15:03:02 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.mediaRecorder.addEventListener('error', (event) => {
|
|
|
|
|
console.error('MediaRecorder error:', event.error);
|
2026-06-15 10:51:17 +00:00
|
|
|
if (this.isTranscribing) {
|
|
|
|
|
this.isTranscribing = false;
|
|
|
|
|
this.$transcribe.classList.remove('compose__action-btn--recording');
|
|
|
|
|
} else {
|
|
|
|
|
this.#setRecordingState(false);
|
|
|
|
|
}
|
2026-02-13 15:03:02 +00:00
|
|
|
this.audioChunks = [];
|
|
|
|
|
this.#renderError(
|
|
|
|
|
`Recording error: ${event.error?.message || 'Unknown error'}`,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('failed to initialize media recorder:', e);
|
|
|
|
|
this.#renderError(`Microphone access denied: ${e.message}`);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ====================
|
2026-06-13 18:55:16 +00:00
|
|
|
// TTS PLAYBACK
|
2026-02-13 15:03:02 +00:00
|
|
|
// ====================
|
2026-06-13 18:55:16 +00:00
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
/**
|
2026-06-13 18:55:16 +00:00
|
|
|
* #enqueueTTS adds a message to the TTS playback queue and starts
|
|
|
|
|
* playback if not already playing.
|
|
|
|
|
* @param {string} text
|
|
|
|
|
* @param {string} voice
|
|
|
|
|
* @param {string} messageId
|
2026-02-13 15:03:02 +00:00
|
|
|
*/
|
2026-06-13 18:55:16 +00:00
|
|
|
#enqueueTTS(text, voice, messageId) {
|
2026-06-26 14:22:05 +00:00
|
|
|
if (!text || !this.ttsURL) return;
|
2026-06-13 18:55:16 +00:00
|
|
|
this.playQueue.push({ text, voice, messageId });
|
|
|
|
|
if (!this.isPlaying) {
|
|
|
|
|
this.#processQueue();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #processQueue pops the next item from the TTS queue and synthesizes it.
|
|
|
|
|
*/
|
|
|
|
|
async #processQueue() {
|
|
|
|
|
if (this.playQueue.length === 0) {
|
|
|
|
|
this.isPlaying = false;
|
|
|
|
|
this.currentMessageId = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.isPlaying = true;
|
|
|
|
|
const item = this.playQueue.shift();
|
|
|
|
|
this.currentMessageId = item.messageId;
|
|
|
|
|
|
|
|
|
|
// Update the play button on the message to show stop state.
|
|
|
|
|
this.#setPlayButtonState(item.messageId, true);
|
|
|
|
|
|
|
|
|
|
await this.#synthesizeAndPlay(item.text, item.voice, item.messageId);
|
|
|
|
|
|
|
|
|
|
// Reset this message's button back to play state before moving on.
|
|
|
|
|
this.#setPlayButtonState(item.messageId, false);
|
|
|
|
|
|
|
|
|
|
// Move to next item in queue.
|
|
|
|
|
this.#processQueue();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #stopTTS stops current audio playback, clears the queue, and resets UI.
|
|
|
|
|
*/
|
|
|
|
|
#stopTTS() {
|
|
|
|
|
this.#stopCurrentAudio();
|
|
|
|
|
this.playQueue = [];
|
|
|
|
|
this.isPlaying = false;
|
|
|
|
|
|
|
|
|
|
// Reset all play buttons.
|
|
|
|
|
if (this.currentMessageId) {
|
|
|
|
|
this.#setPlayButtonState(this.currentMessageId, false);
|
|
|
|
|
}
|
|
|
|
|
this.currentMessageId = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #synthesizeAndPlay calls kokoro-fastapi to synthesize text and plays
|
|
|
|
|
* the resulting audio. On error, logs and returns without blocking the queue.
|
|
|
|
|
* @param {string} text
|
|
|
|
|
* @param {string} voice
|
|
|
|
|
* @param {string} _messageId
|
|
|
|
|
*/
|
|
|
|
|
async #synthesizeAndPlay(text, voice, _messageId) {
|
2026-02-13 15:03:02 +00:00
|
|
|
try {
|
|
|
|
|
this.#stopCurrentAudio();
|
|
|
|
|
|
2026-06-26 14:22:05 +00:00
|
|
|
const res = await fetch(`${this.ttsURL}/v1/audio/speech`, {
|
2026-06-13 18:55:16 +00:00
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
model: 'tts-1',
|
|
|
|
|
input: text,
|
|
|
|
|
voice: voice || this.$voice.value || this.ttsDefaultVoice,
|
|
|
|
|
response_format: 'wav',
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
throw new Error(`TTS error ${res.status}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const audioBlob = await res.blob();
|
2026-06-26 14:22:05 +00:00
|
|
|
const audioURL = URL.createObjectURL(audioBlob);
|
2026-02-13 15:03:02 +00:00
|
|
|
|
2026-06-26 14:22:05 +00:00
|
|
|
this.currentAudioURL = audioURL;
|
|
|
|
|
this.currentAudio = new Audio(audioURL);
|
2026-02-13 15:03:02 +00:00
|
|
|
this.currentAudio.muted = !!this.isMuted;
|
|
|
|
|
|
|
|
|
|
await new Promise((resolve, reject) => {
|
|
|
|
|
this.currentAudio.addEventListener(
|
|
|
|
|
'ended',
|
|
|
|
|
() => {
|
|
|
|
|
this.#cleanupAudio();
|
|
|
|
|
resolve();
|
|
|
|
|
},
|
|
|
|
|
{ once: true },
|
|
|
|
|
);
|
|
|
|
|
this.currentAudio.addEventListener(
|
|
|
|
|
'error',
|
|
|
|
|
(e) => {
|
|
|
|
|
this.#cleanupAudio();
|
|
|
|
|
reject(e);
|
|
|
|
|
},
|
|
|
|
|
{ once: true },
|
|
|
|
|
);
|
|
|
|
|
this.currentAudio.play().catch(reject);
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
2026-06-13 18:55:16 +00:00
|
|
|
console.error('TTS synthesis failed:', e);
|
2026-02-13 15:03:02 +00:00
|
|
|
this.#cleanupAudio();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 18:55:16 +00:00
|
|
|
/**
|
|
|
|
|
* #setPlayButtonState updates the play/stop button icon and state for a
|
|
|
|
|
* given message.
|
|
|
|
|
* @param {string} messageId
|
|
|
|
|
* @param {boolean} playing
|
|
|
|
|
*/
|
|
|
|
|
#setPlayButtonState(messageId, playing) {
|
|
|
|
|
const $msg = this.$chat.querySelector(`[data-id="${messageId}"]`);
|
|
|
|
|
if (!$msg) return;
|
|
|
|
|
const $btn = $msg.querySelector('[data-action="play"]');
|
|
|
|
|
if (!$btn) return;
|
|
|
|
|
|
|
|
|
|
if (playing) {
|
|
|
|
|
$btn.classList.add('playing');
|
|
|
|
|
$btn.innerHTML = `<svg class="icon"><use href="${ICONS_URL}#stop"></use></svg>`;
|
|
|
|
|
$btn.setAttribute('aria-label', 'Stop');
|
|
|
|
|
} else {
|
|
|
|
|
$btn.classList.remove('playing');
|
|
|
|
|
$btn.innerHTML = `<svg class="icon"><use href="${ICONS_URL}#volume"></use></svg>`;
|
|
|
|
|
$btn.setAttribute('aria-label', 'Play');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handlePlayClick handles the play/stop button on an assistant message.
|
|
|
|
|
* If not playing: stop any current audio, enqueue this message, start playing.
|
|
|
|
|
* If currently playing this message: stop playback, clear remaining queue, reset.
|
|
|
|
|
* @param {MouseEvent} event
|
|
|
|
|
* @param {string} messageId
|
|
|
|
|
* @param {string} text
|
|
|
|
|
* @param {string} voice
|
|
|
|
|
*/
|
|
|
|
|
#handlePlayClick(event, messageId, text, voice) {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
|
|
|
|
if (this.currentMessageId === messageId) {
|
|
|
|
|
// Currently playing this message: stop everything.
|
|
|
|
|
this.#stopTTS();
|
|
|
|
|
} else {
|
|
|
|
|
// Not playing this message: stop current playback, enqueue this one.
|
|
|
|
|
this.#stopTTS();
|
|
|
|
|
this.#enqueueTTS(text, voice, messageId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ====================
|
|
|
|
|
// AUDIO PLAYBACK
|
|
|
|
|
// ====================
|
2026-02-13 15:03:02 +00:00
|
|
|
/**
|
|
|
|
|
* #stopCurrentAudio stops and cleans up any playing audio.
|
|
|
|
|
*/
|
|
|
|
|
#stopCurrentAudio() {
|
|
|
|
|
if (this.currentAudio) {
|
|
|
|
|
this.currentAudio.pause();
|
|
|
|
|
this.currentAudio.currentTime = 0;
|
|
|
|
|
}
|
|
|
|
|
this.#cleanupAudio();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #cleanupAudio revokes the object URL and clears audio references.
|
|
|
|
|
*/
|
|
|
|
|
#cleanupAudio() {
|
2026-06-26 14:22:05 +00:00
|
|
|
if (this.currentAudioURL) {
|
|
|
|
|
URL.revokeObjectURL(this.currentAudioURL);
|
|
|
|
|
this.currentAudioURL = null;
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
this.currentAudio = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ====================
|
|
|
|
|
// API: FETCH
|
|
|
|
|
// ====================
|
|
|
|
|
/**
|
|
|
|
|
* #fetchModels fetches available models from the API and populates selectors.
|
2026-06-19 14:31:41 +00:00
|
|
|
* The response now includes models with availability status and provider grouping.
|
2026-02-13 15:03:02 +00:00
|
|
|
*/
|
|
|
|
|
async #fetchModels() {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(MODELS_ENDPOINT);
|
|
|
|
|
if (!res.ok) throw new Error(`${res.status}`);
|
|
|
|
|
const data = await res.json();
|
2026-06-19 14:31:41 +00:00
|
|
|
this.#populateModels(
|
|
|
|
|
data.providers,
|
|
|
|
|
data.default_provider,
|
|
|
|
|
data.default_model,
|
|
|
|
|
);
|
2026-02-13 15:03:02 +00:00
|
|
|
} catch (e) {
|
|
|
|
|
console.error('failed to fetch models:', e);
|
|
|
|
|
this.#populateModelsFallback();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #fetchVoices fetches available voices from the API and populates
|
|
|
|
|
* selectors.
|
|
|
|
|
*/
|
|
|
|
|
async #fetchVoices() {
|
|
|
|
|
try {
|
2026-06-26 14:22:05 +00:00
|
|
|
const res = await fetch(`${this.ttsURL}/v1/audio/voices`);
|
2026-02-13 15:03:02 +00:00
|
|
|
if (!res.ok) throw new Error(`${res.status}`);
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
|
|
// Filter out legacy v0 voices.
|
2026-06-13 18:55:16 +00:00
|
|
|
const filtered = data.voices.filter((v) => {
|
|
|
|
|
const id = typeof v === 'string' ? v : v.id;
|
|
|
|
|
return !id.includes('_v0');
|
|
|
|
|
});
|
|
|
|
|
const voiceIds = filtered.map((v) => (typeof v === 'string' ? v : v.id));
|
|
|
|
|
this.#populateVoiceSelect(voiceIds);
|
2026-02-13 15:03:02 +00:00
|
|
|
} catch (e) {
|
|
|
|
|
console.error('failed to fetch voices:', e);
|
|
|
|
|
this.$voice.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
const $opt = this.document.createElement('option');
|
|
|
|
|
$opt.value = '';
|
|
|
|
|
$opt.disabled = true;
|
|
|
|
|
$opt.selected = true;
|
|
|
|
|
$opt.textContent = 'Failed to load';
|
|
|
|
|
this.$voice.appendChild($opt);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ====================
|
|
|
|
|
// API: CHAT
|
|
|
|
|
// ====================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #submitText submits the current text input and attachments.
|
|
|
|
|
*/
|
|
|
|
|
async #submitText() {
|
|
|
|
|
const text = this.$textInput.value.trim();
|
|
|
|
|
if (this.isProcessing) return;
|
|
|
|
|
|
|
|
|
|
this.#stopCurrentAudio();
|
2026-06-13 18:55:16 +00:00
|
|
|
this.#stopTTS();
|
2026-02-13 15:03:02 +00:00
|
|
|
this.$textInput.value = '';
|
|
|
|
|
this.$textInput.style.height = 'auto';
|
|
|
|
|
|
2026-06-26 14:22:05 +00:00
|
|
|
await this.#sendRequest({ text, voice: this.$voice.value });
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #sendRequest sends a chat request to the streaming SSE endpoint.
|
|
|
|
|
*
|
|
|
|
|
* For text-only input, renders the user message immediately for
|
|
|
|
|
* responsiveness.
|
|
|
|
|
*
|
|
|
|
|
* Messages are rendered incrementally as SSE events arrive:
|
2026-06-15 10:51:17 +00:00
|
|
|
* - user, assistant (tool calls), tool (results),
|
2026-06-13 18:55:16 +00:00
|
|
|
* and a final assistant message with content.
|
2026-02-13 15:03:02 +00:00
|
|
|
*
|
|
|
|
|
* @param {Object} options
|
|
|
|
|
* @param {string} [options.text] - Text input
|
2026-06-15 10:51:17 +00:00
|
|
|
* @param {string} [options.detectedLanguage] - Detected language from STT
|
|
|
|
|
* @param {string} [options.voice] - Resolved voice for this message (from auto-selection)
|
2026-02-13 15:03:02 +00:00
|
|
|
*/
|
2026-06-15 10:51:17 +00:00
|
|
|
async #sendRequest({ text = '', detectedLanguage = '', voice }) {
|
2026-02-13 15:03:02 +00:00
|
|
|
this.#setLoadingState(true);
|
2026-06-09 18:50:25 +00:00
|
|
|
// Re-enable auto-scroll when the user sends a new message.
|
|
|
|
|
this.isAutoScrollEnabled = true;
|
2026-02-13 15:03:02 +00:00
|
|
|
this.currentController = new AbortController();
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-15 10:51:17 +00:00
|
|
|
const hasNewInput = text || this.attachments.length > 0;
|
2026-02-13 15:03:02 +00:00
|
|
|
|
2026-06-12 19:26:30 +00:00
|
|
|
// Build messages array from active path + current user message.
|
|
|
|
|
const activePath = this.getActivePath();
|
2026-06-13 00:44:01 +00:00
|
|
|
let messages;
|
|
|
|
|
let userMessage;
|
|
|
|
|
|
|
|
|
|
if (hasNewInput) {
|
|
|
|
|
userMessage = await this.#buildUserMessage(text, this.attachments);
|
|
|
|
|
messages = [
|
|
|
|
|
...activePath.map(({ parent, children, id, meta, ...msg }) => msg),
|
|
|
|
|
userMessage,
|
|
|
|
|
];
|
|
|
|
|
} else {
|
|
|
|
|
// Continue conversation: send existing history without a new user message.
|
|
|
|
|
messages = activePath.map(
|
|
|
|
|
({ parent, children, id, meta, ...msg }) => msg,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-13 15:03:02 +00:00
|
|
|
|
2026-06-19 14:31:41 +00:00
|
|
|
const $opt = this.$model.options[this.$model.selectedIndex];
|
2026-02-13 15:03:02 +00:00
|
|
|
const payload = {
|
|
|
|
|
messages,
|
2026-06-19 14:31:41 +00:00
|
|
|
provider: $opt?.dataset.provider,
|
|
|
|
|
model: $opt?.dataset.model,
|
2026-02-13 15:03:02 +00:00
|
|
|
};
|
2026-05-28 00:53:08 +00:00
|
|
|
const systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY);
|
|
|
|
|
if (systemMessage) {
|
|
|
|
|
payload.system_message = systemMessage;
|
|
|
|
|
}
|
2026-02-13 15:03:02 +00:00
|
|
|
|
|
|
|
|
// Clear attachments after building payload (before async operations).
|
|
|
|
|
this.#clearAttachments();
|
|
|
|
|
|
2026-06-13 00:44:01 +00:00
|
|
|
// For text-only requests with new input, add to history and render
|
|
|
|
|
// immediately. Skip when continuing conversation (no new user message).
|
2026-06-15 10:51:17 +00:00
|
|
|
if (hasNewInput) {
|
|
|
|
|
const meta = {};
|
|
|
|
|
if (detectedLanguage) meta.language = detectedLanguage;
|
2026-06-26 14:22:05 +00:00
|
|
|
if (voice) meta.voice = voice;
|
2026-06-15 10:51:17 +00:00
|
|
|
const appended = this.#appendHistory([userMessage], meta);
|
|
|
|
|
this.#renderMessages(appended, meta);
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}`);
|
|
|
|
|
}
|
|
|
|
|
// Parse SSE stream from response body.
|
|
|
|
|
const reader = res.body.getReader();
|
|
|
|
|
const decoder = new TextDecoder();
|
|
|
|
|
let sseBuffer = '';
|
|
|
|
|
|
|
|
|
|
// Stash assistant messages with tool_calls until their results arrive.
|
|
|
|
|
let pendingTools = null;
|
2026-06-26 14:22:05 +00:00
|
|
|
let streamingMessage = null;
|
|
|
|
|
|
|
|
|
|
const streamingMeta = () => {
|
|
|
|
|
const meta = {};
|
|
|
|
|
if (voice) meta.voice = voice;
|
|
|
|
|
return meta;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const mergeStreamingMessage = (message) => {
|
|
|
|
|
const target = streamingMessage.message;
|
|
|
|
|
const id = target.id;
|
|
|
|
|
const parent = target.parent;
|
|
|
|
|
const children = target.children;
|
|
|
|
|
const meta = target.meta;
|
|
|
|
|
|
|
|
|
|
Object.assign(target, message);
|
|
|
|
|
target.id = id;
|
|
|
|
|
target.parent = parent;
|
|
|
|
|
target.children = children;
|
|
|
|
|
target.meta = meta;
|
|
|
|
|
|
|
|
|
|
this.messagesMap.set(id, target);
|
|
|
|
|
this.#saveToStorage();
|
|
|
|
|
return target;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
while (true) {
|
|
|
|
|
const { done, value } = await reader.read();
|
|
|
|
|
if (done) break;
|
|
|
|
|
|
|
|
|
|
sseBuffer += decoder.decode(value, { stream: true });
|
|
|
|
|
|
|
|
|
|
// Process complete SSE events (separated by double newlines).
|
|
|
|
|
const parts = sseBuffer.split('\n\n');
|
|
|
|
|
sseBuffer = parts.pop();
|
|
|
|
|
|
|
|
|
|
for (const part of parts) {
|
|
|
|
|
if (!part.trim()) continue;
|
|
|
|
|
|
2026-06-26 14:22:05 +00:00
|
|
|
// Extract event type and data from SSE event lines.
|
|
|
|
|
let eventType = StreamEventMessage;
|
2026-02-13 15:03:02 +00:00
|
|
|
let data = '';
|
|
|
|
|
for (const line of part.split('\n')) {
|
2026-06-26 14:22:05 +00:00
|
|
|
if (line.startsWith('event: ')) {
|
|
|
|
|
eventType = line.slice(7);
|
|
|
|
|
}
|
2026-02-13 15:03:02 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle errors sent mid-stream.
|
|
|
|
|
if (event.error) {
|
|
|
|
|
this.#renderError(event.error);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 14:22:05 +00:00
|
|
|
if (eventType === StreamEventDelta) {
|
|
|
|
|
if (!streamingMessage) {
|
|
|
|
|
const message = {
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
content: [{ type: ContentTypeText, text: '' }],
|
|
|
|
|
};
|
|
|
|
|
const meta = streamingMeta();
|
|
|
|
|
const appended = this.#appendHistory([message], meta);
|
|
|
|
|
const $el = this.#renderStreamingAssistantMessage(
|
|
|
|
|
appended[0],
|
|
|
|
|
meta,
|
|
|
|
|
);
|
|
|
|
|
streamingMessage = { message: appended[0], $el };
|
|
|
|
|
}
|
2026-02-13 15:03:02 +00:00
|
|
|
|
2026-06-26 14:22:05 +00:00
|
|
|
streamingMessage.message.content[0].text += event.delta || '';
|
|
|
|
|
this.#appendStreamingDelta(streamingMessage.$el, event.delta || '');
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const message = event.message;
|
|
|
|
|
if (!message) continue;
|
|
|
|
|
|
|
|
|
|
if (eventType === StreamEventDone) {
|
|
|
|
|
if (message.role !== 'assistant') continue;
|
|
|
|
|
|
|
|
|
|
const meta = streamingMeta();
|
|
|
|
|
let finalMessage = message;
|
|
|
|
|
let $streaming = null;
|
|
|
|
|
if (streamingMessage) {
|
|
|
|
|
finalMessage = mergeStreamingMessage(message);
|
|
|
|
|
$streaming = streamingMessage.$el;
|
|
|
|
|
streamingMessage = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stash assistant messages with tool calls; render once all
|
|
|
|
|
// tool results have arrived so the output is visible in the UI.
|
|
|
|
|
if (finalMessage.tool_calls?.length > 0) {
|
|
|
|
|
if (!$streaming) {
|
|
|
|
|
const appended = this.#appendHistory([finalMessage], meta);
|
|
|
|
|
finalMessage = appended[0];
|
|
|
|
|
}
|
|
|
|
|
if ($streaming) {
|
|
|
|
|
$streaming = this.#finalizeStreamingAssistantMessage(
|
|
|
|
|
$streaming,
|
|
|
|
|
finalMessage,
|
|
|
|
|
meta,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
pendingTools = {
|
|
|
|
|
assistant: finalMessage,
|
|
|
|
|
results: [],
|
|
|
|
|
expected: finalMessage.tool_calls.length,
|
|
|
|
|
$el: $streaming,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Enqueue TTS for content even when tool calls are present,
|
|
|
|
|
// so the LLM's spoken text before tool execution is played.
|
|
|
|
|
if (this.#extractText(finalMessage.content)) {
|
|
|
|
|
this.#enqueueTTS(
|
|
|
|
|
this.#extractText(finalMessage.content),
|
|
|
|
|
voice || '',
|
|
|
|
|
finalMessage.id,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Regular assistant message (final reply with content).
|
|
|
|
|
if ($streaming) {
|
|
|
|
|
this.#finalizeStreamingAssistantMessage(
|
|
|
|
|
$streaming,
|
|
|
|
|
finalMessage,
|
|
|
|
|
meta,
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
const appended = this.#appendHistory([finalMessage], meta);
|
|
|
|
|
finalMessage = appended[0];
|
|
|
|
|
this.#renderMessages(appended, meta);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Enqueue TTS for the final assistant message with content.
|
|
|
|
|
if (this.#extractText(finalMessage.content)) {
|
2026-06-19 17:48:13 +00:00
|
|
|
this.#enqueueTTS(
|
2026-06-26 14:22:05 +00:00
|
|
|
this.#extractText(finalMessage.content),
|
|
|
|
|
voice || '',
|
|
|
|
|
finalMessage.id,
|
2026-06-19 17:48:13 +00:00
|
|
|
);
|
|
|
|
|
}
|
2026-02-13 15:03:02 +00:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collect tool results and render once all have arrived.
|
2026-05-28 00:53:08 +00:00
|
|
|
if (
|
|
|
|
|
message.role === 'tool' &&
|
|
|
|
|
pendingTools &&
|
|
|
|
|
pendingTools.assistant
|
|
|
|
|
) {
|
2026-06-12 19:26:30 +00:00
|
|
|
const appended = this.#appendHistory([message]);
|
|
|
|
|
pendingTools.results.push(appended[0]);
|
2026-02-13 15:03:02 +00:00
|
|
|
|
|
|
|
|
// Once all tool results are in, render the combined message.
|
|
|
|
|
if (pendingTools.results.length >= pendingTools.expected) {
|
2026-06-26 14:22:05 +00:00
|
|
|
if (pendingTools.$el) {
|
|
|
|
|
this.#finalizeStreamingAssistantMessage(
|
|
|
|
|
pendingTools.$el,
|
|
|
|
|
pendingTools.assistant,
|
|
|
|
|
pendingTools.assistant.meta,
|
|
|
|
|
pendingTools.results,
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
this.#renderMessages([
|
|
|
|
|
pendingTools.assistant,
|
|
|
|
|
...pendingTools.results,
|
|
|
|
|
]);
|
|
|
|
|
}
|
2026-02-13 15:03:02 +00:00
|
|
|
pendingTools = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.#setLoadingState(false);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (e.name === 'AbortError') {
|
|
|
|
|
console.debug('request aborted');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
console.error('failed to send request:', e);
|
|
|
|
|
this.#renderError(`Error: ${e.message}`);
|
|
|
|
|
this.#setLoadingState(false);
|
|
|
|
|
} finally {
|
|
|
|
|
this.currentController = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #buildUserMessage builds a user message object for the API.
|
|
|
|
|
* Text files are included as text, images as base64 data URLs.
|
|
|
|
|
* Returns either a simple string content or multipart array format
|
|
|
|
|
* depending on whether images are present.
|
|
|
|
|
* @param {string} text
|
|
|
|
|
* @param {File[]} attachments
|
|
|
|
|
* @returns {Promise<{id: string, role: 'user', content: string | Array}>}
|
|
|
|
|
*/
|
|
|
|
|
async #buildUserMessage(text, attachments) {
|
|
|
|
|
const textParts = [];
|
|
|
|
|
const imageParts = [];
|
|
|
|
|
|
|
|
|
|
// Process attachments.
|
|
|
|
|
// Images become data URLs, text files become inline text.
|
|
|
|
|
for (const file of attachments) {
|
|
|
|
|
if (file.type.startsWith('image/')) {
|
|
|
|
|
const base64 = await this.#toBase64(file);
|
2026-06-26 14:22:05 +00:00
|
|
|
const dataURL = `data:${file.type};base64,${base64}`;
|
2026-02-13 15:03:02 +00:00
|
|
|
imageParts.push({
|
2026-06-26 14:22:05 +00:00
|
|
|
type: ContentTypeImageURL,
|
|
|
|
|
image_url: { url: dataURL },
|
2026-02-13 15:03:02 +00:00
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
const content = await file.text();
|
|
|
|
|
textParts.push(`=== File: ${file.name} ===\n\n${content}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add user's message text after files.
|
|
|
|
|
if (text) {
|
|
|
|
|
textParts.push(text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const combinedText = textParts.join('\n\n');
|
|
|
|
|
|
|
|
|
|
// If no images, return simple string content.
|
|
|
|
|
if (imageParts.length === 0) {
|
|
|
|
|
return {
|
|
|
|
|
id: crypto.randomUUID(),
|
|
|
|
|
role: 'user',
|
|
|
|
|
content: combinedText || '',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If images present, use multipart array format.
|
|
|
|
|
const parts = [];
|
|
|
|
|
if (combinedText) {
|
2026-06-26 14:22:05 +00:00
|
|
|
parts.push({ type: ContentTypeText, text: combinedText });
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
parts.push(...imageParts);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: crypto.randomUUID(),
|
|
|
|
|
role: 'user',
|
|
|
|
|
content: parts,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ====================
|
2026-06-12 19:26:30 +00:00
|
|
|
// STATE
|
2026-02-13 15:03:02 +00:00
|
|
|
// ====================
|
2026-06-13 00:44:01 +00:00
|
|
|
/**
|
|
|
|
|
* #updateSendButtonState enables the send button when there is text,
|
|
|
|
|
* attachments, or at least one message in the conversation history,
|
|
|
|
|
* and disables it otherwise.
|
|
|
|
|
*/
|
|
|
|
|
#updateSendButtonState() {
|
|
|
|
|
const hasText = this.$textInput.value.trim().length > 0;
|
|
|
|
|
const hasAttachments = this.attachments.length > 0;
|
|
|
|
|
const hasHistory = this.messagesMap.size > 0;
|
|
|
|
|
const enabled = hasText || hasAttachments || hasHistory;
|
|
|
|
|
this.$send.disabled = !enabled;
|
|
|
|
|
this.$send.setAttribute(
|
|
|
|
|
'aria-label',
|
|
|
|
|
enabled ? 'Send message' : 'Start a conversation to enable send',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
/**
|
|
|
|
|
* #setLoadingState updates UI to reflect loading state.
|
|
|
|
|
* @param {boolean} loading
|
|
|
|
|
*/
|
|
|
|
|
#setLoadingState(loading) {
|
|
|
|
|
this.isProcessing = loading;
|
|
|
|
|
this.$send.classList.toggle('loading', loading);
|
2026-06-09 01:32:49 +00:00
|
|
|
this.$send.setAttribute('aria-label', loading ? 'Stop' : 'Send message');
|
|
|
|
|
// Show/hide the spinner in the chat area.
|
|
|
|
|
// Positioning is handled by #renderMessages to ensure it's always at the end.
|
|
|
|
|
this.$chatLoading.hidden = !loading;
|
|
|
|
|
// Keep the send button enabled during loading so it can act as a stop button.
|
2026-02-13 15:03:02 +00:00
|
|
|
this.$ptt.disabled = loading;
|
2026-06-15 10:51:17 +00:00
|
|
|
this.$transcribe.disabled = loading;
|
2026-06-09 01:32:49 +00:00
|
|
|
// Clear any pending stop state when loading ends.
|
|
|
|
|
if (!loading && this.isStopPending) {
|
|
|
|
|
this.isStopPending = false;
|
|
|
|
|
this.$send.classList.remove('pending');
|
|
|
|
|
}
|
2026-06-09 23:17:55 +00:00
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
this.#acquireWakeLock();
|
|
|
|
|
} else {
|
|
|
|
|
this.#releaseWakeLock();
|
|
|
|
|
}
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #setRecordingState updates UI to reflect recording state.
|
|
|
|
|
* @param {boolean} recording
|
|
|
|
|
*/
|
|
|
|
|
#setRecordingState(recording) {
|
|
|
|
|
this.isRecording = recording;
|
|
|
|
|
this.$ptt.classList.toggle('recording', recording);
|
|
|
|
|
this.$ptt.setAttribute('aria-pressed', String(recording));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #toggleMute toggles mute state and updates button label.
|
|
|
|
|
*/
|
|
|
|
|
#toggleMute() {
|
|
|
|
|
this.isMuted = !this.isMuted;
|
|
|
|
|
if (this.$mute) {
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
// Apply mute state to any currently playing audio.
|
|
|
|
|
if (this.currentAudio) {
|
|
|
|
|
this.currentAudio.muted = !!this.isMuted;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 00:53:08 +00:00
|
|
|
/**
|
|
|
|
|
* openSettings opens the settings modal and loads current values.
|
|
|
|
|
*/
|
|
|
|
|
openSettings() {
|
|
|
|
|
this.$settingsOverlay.classList.add('open');
|
|
|
|
|
this.#loadSystemMessage();
|
|
|
|
|
this.#switchTab('model-voice');
|
|
|
|
|
// Focus the close button for accessibility.
|
|
|
|
|
this.$settingsClose.focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* closeSettings closes the settings modal and restores focus.
|
|
|
|
|
*/
|
|
|
|
|
closeSettings() {
|
|
|
|
|
this.$settingsOverlay.classList.remove('open');
|
|
|
|
|
this.$settings.focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #switchTab switches the active tab in the settings modal.
|
|
|
|
|
* @param {string} tabName
|
|
|
|
|
*/
|
|
|
|
|
#switchTab(tabName) {
|
|
|
|
|
this.$settingsTabs.forEach(($tab) => {
|
|
|
|
|
const isActive = $tab.dataset.tab === tabName;
|
|
|
|
|
$tab.classList.toggle('active', isActive);
|
|
|
|
|
$tab.setAttribute('aria-selected', String(isActive));
|
|
|
|
|
});
|
|
|
|
|
this.$settingsPanels.forEach(($panel) => {
|
|
|
|
|
const isActive = $panel.dataset.tabPanel === tabName;
|
|
|
|
|
$panel.classList.toggle('active', isActive);
|
|
|
|
|
$panel.hidden = !isActive;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #saveSystemMessage saves the textarea value to localStorage.
|
|
|
|
|
*/
|
|
|
|
|
#saveSystemMessage() {
|
|
|
|
|
const value = this.$systemMessageInput.value;
|
|
|
|
|
localStorage.setItem(SYSTEM_MESSAGE_KEY, value);
|
|
|
|
|
|
|
|
|
|
// Brief visual feedback on the save button.
|
|
|
|
|
this.$saveSystemMessage.replaceChildren(this.#icon('check'));
|
|
|
|
|
this.$saveSystemMessage.classList.add('settings-save-btn--success');
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
this.$saveSystemMessage.textContent = 'Save';
|
|
|
|
|
this.$saveSystemMessage.classList.remove('settings-save-btn--success');
|
|
|
|
|
}, 1500);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #loadSystemMessage reads from localStorage and populates the textarea.
|
|
|
|
|
*/
|
|
|
|
|
#loadSystemMessage() {
|
|
|
|
|
const stored = localStorage.getItem(SYSTEM_MESSAGE_KEY);
|
|
|
|
|
this.$systemMessageInput.value = stored || '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
// ====================
|
|
|
|
|
// RENDER: SELECTS
|
|
|
|
|
// ====================
|
|
|
|
|
/**
|
2026-06-19 14:31:41 +00:00
|
|
|
* #populateModels populates the model selector grouped by provider.
|
|
|
|
|
* Models are displayed with availability indicators:
|
|
|
|
|
* - Available: normal selectable option
|
|
|
|
|
* - Not found: red strikethrough, disabled
|
|
|
|
|
* - Provider error: grayed out, disabled
|
|
|
|
|
* @param {Object<string, Array<{model: string, status: string}>>} providers
|
|
|
|
|
* @param {string} defaultProvider
|
2026-02-13 15:03:02 +00:00
|
|
|
* @param {string} defaultModel
|
|
|
|
|
*/
|
2026-06-19 14:31:41 +00:00
|
|
|
#populateModels(providers, defaultProvider, defaultModel) {
|
2026-02-13 15:03:02 +00:00
|
|
|
this.$model.innerHTML = '';
|
|
|
|
|
|
2026-06-19 14:31:41 +00:00
|
|
|
// Sort provider names.
|
|
|
|
|
const sortedProviders = Object.keys(providers).sort();
|
|
|
|
|
|
|
|
|
|
// Create optgroups for each provider.
|
|
|
|
|
for (const provider of sortedProviders) {
|
|
|
|
|
const $optgroup = this.document.createElement('optgroup');
|
|
|
|
|
$optgroup.label = provider;
|
|
|
|
|
|
|
|
|
|
// Sort models within provider.
|
|
|
|
|
for (const m of providers[provider].sort((a, b) =>
|
|
|
|
|
a.model.localeCompare(b.model),
|
|
|
|
|
)) {
|
|
|
|
|
const $opt = this.document.createElement('option');
|
|
|
|
|
$opt.value = m.model;
|
|
|
|
|
$opt.dataset.provider = provider;
|
|
|
|
|
$opt.dataset.model = m.model;
|
|
|
|
|
|
|
|
|
|
switch (m.status) {
|
|
|
|
|
case 'available':
|
|
|
|
|
$opt.textContent = m.model;
|
|
|
|
|
break;
|
|
|
|
|
case 'not_found':
|
|
|
|
|
$opt.textContent = `~~${m.model}~~ (not found)`;
|
|
|
|
|
$opt.disabled = true;
|
|
|
|
|
$opt.classList.add('model-unavailable-not-found');
|
|
|
|
|
break;
|
|
|
|
|
case 'provider_error':
|
|
|
|
|
$opt.textContent = `${m.model} (provider error)`;
|
|
|
|
|
$opt.disabled = true;
|
|
|
|
|
$opt.classList.add('model-unavailable-error');
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$optgroup.appendChild($opt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.$model.appendChild($optgroup);
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-19 14:31:41 +00:00
|
|
|
this.#loadModel(defaultProvider, defaultModel);
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #populateModelsFallback shows an error state when models fail to load.
|
|
|
|
|
*/
|
|
|
|
|
#populateModelsFallback() {
|
|
|
|
|
this.$model.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
const $opt = this.document.createElement('option');
|
|
|
|
|
$opt.value = '';
|
|
|
|
|
$opt.disabled = true;
|
|
|
|
|
$opt.selected = true;
|
|
|
|
|
$opt.textContent = 'Failed to load';
|
|
|
|
|
this.$model.appendChild($opt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #loadModel restores the model selection from localStorage or uses the
|
|
|
|
|
* default.
|
2026-06-19 14:31:41 +00:00
|
|
|
* @param {string} defaultProvider
|
2026-02-13 15:03:02 +00:00
|
|
|
* @param {string} defaultModel
|
|
|
|
|
*/
|
2026-06-19 14:31:41 +00:00
|
|
|
#loadModel(defaultProvider, defaultModel) {
|
|
|
|
|
const storedProvider = localStorage.getItem(PROVIDER_KEY);
|
|
|
|
|
const storedModel = localStorage.getItem(MODEL_KEY);
|
2026-02-13 15:03:02 +00:00
|
|
|
|
|
|
|
|
// Try stored value first, then default, then first available option.
|
2026-06-19 14:31:41 +00:00
|
|
|
let $opt;
|
|
|
|
|
if (storedProvider && storedModel) {
|
|
|
|
|
$opt = this.$model.querySelector(
|
|
|
|
|
`option[data-provider="${CSS.escape(storedProvider)}"][data-model="${CSS.escape(storedModel)}"]`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (!$opt && defaultProvider && defaultModel) {
|
|
|
|
|
$opt = this.$model.querySelector(
|
|
|
|
|
`option[data-provider="${CSS.escape(defaultProvider)}"][data-model="${CSS.escape(defaultModel)}"]`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (!$opt && this.$model.options.length > 0) {
|
|
|
|
|
// Find first available (non-disabled) option.
|
|
|
|
|
for (const opt of this.$model.options) {
|
|
|
|
|
if (!opt.disabled) {
|
|
|
|
|
$opt = opt;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-19 14:31:41 +00:00
|
|
|
if ($opt) {
|
|
|
|
|
this.$model.value = $opt.value;
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #populateVoiceSelect populates voice selector grouped by language.
|
|
|
|
|
* Voice IDs follow the pattern: {lang}{gender}_{name} (e.g., "af_bella").
|
|
|
|
|
* @param {string[]} voices
|
|
|
|
|
*/
|
|
|
|
|
#populateVoiceSelect(voices) {
|
|
|
|
|
const langLabels = {
|
|
|
|
|
af: '🇺🇸 American English (F)',
|
|
|
|
|
am: '🇺🇸 American English (M)',
|
|
|
|
|
bf: '🇬🇧 British English (F)',
|
|
|
|
|
bm: '🇬🇧 British English (M)',
|
|
|
|
|
jf: '🇯🇵 Japanese (F)',
|
|
|
|
|
jm: '🇯🇵 Japanese (M)',
|
|
|
|
|
zf: '🇨🇳 Mandarin Chinese (F)',
|
|
|
|
|
zm: '🇨🇳 Mandarin Chinese (M)',
|
|
|
|
|
ef: '🇪🇸 Spanish (F)',
|
|
|
|
|
em: '🇪🇸 Spanish (M)',
|
|
|
|
|
ff: '🇫🇷 French (F)',
|
|
|
|
|
fm: '🇫🇷 French (M)',
|
|
|
|
|
hf: '🇮🇳 Hindi (F)',
|
|
|
|
|
hm: '🇮🇳 Hindi (M)',
|
|
|
|
|
if: '🇮🇹 Italian (F)',
|
|
|
|
|
im: '🇮🇹 Italian (M)',
|
|
|
|
|
pf: '🇧🇷 Brazilian Portuguese (F)',
|
|
|
|
|
pm: '🇧🇷 Brazilian Portuguese (M)',
|
|
|
|
|
kf: '🇰🇷 Korean (F)',
|
|
|
|
|
km: '🇰🇷 Korean (M)',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Group voices by their two-character prefix (language + gender)
|
|
|
|
|
const groups = {};
|
|
|
|
|
for (const voice of voices) {
|
|
|
|
|
const prefix = voice.substring(0, 2);
|
|
|
|
|
if (!groups[prefix]) groups[prefix] = [];
|
|
|
|
|
groups[prefix].push(voice);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear and rebuild selector.
|
|
|
|
|
this.$voice.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
// Add "Auto" option for automatic language detection.
|
|
|
|
|
const $auto = this.document.createElement('option');
|
|
|
|
|
$auto.value = '';
|
|
|
|
|
$auto.textContent = '🌐 Auto';
|
|
|
|
|
this.$voice.appendChild($auto);
|
|
|
|
|
|
|
|
|
|
// Sort prefixes: by language code first, then by gender.
|
|
|
|
|
const sortedPrefixes = Object.keys(groups).sort((a, b) => {
|
|
|
|
|
if (a[0] !== b[0]) return a[0].localeCompare(b[0]);
|
|
|
|
|
return a[1].localeCompare(b[1]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Create optgroups for each language/gender combination.
|
|
|
|
|
for (const prefix of sortedPrefixes) {
|
|
|
|
|
const label = langLabels[prefix] || prefix.toUpperCase();
|
|
|
|
|
|
|
|
|
|
const $optgroup = this.document.createElement('optgroup');
|
|
|
|
|
$optgroup.label = label;
|
|
|
|
|
|
|
|
|
|
for (const voice of groups[prefix].sort()) {
|
|
|
|
|
// Extract voice name from ID (e.g., "af_bella" -> "Bella").
|
|
|
|
|
const name = voice.substring(3).replace(/_/g, ' ');
|
|
|
|
|
const displayName = name.charAt(0).toUpperCase() + name.slice(1);
|
|
|
|
|
|
|
|
|
|
const $opt = this.document.createElement('option');
|
|
|
|
|
$opt.value = voice;
|
|
|
|
|
$opt.textContent = displayName;
|
|
|
|
|
$optgroup.appendChild($opt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.$voice.appendChild($optgroup);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.#loadVoice();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #loadVoice restores the voice selection from localStorage.
|
|
|
|
|
*/
|
|
|
|
|
#loadVoice() {
|
|
|
|
|
const stored = localStorage.getItem(VOICE_KEY);
|
|
|
|
|
|
|
|
|
|
if (stored === null) {
|
|
|
|
|
this.$voice.value = '';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Empty string is valid ("Auto").
|
|
|
|
|
const escaped = stored === '' ? '' : CSS.escape(stored);
|
|
|
|
|
const exists =
|
|
|
|
|
stored === '' || this.$voice.querySelector(`option[value="${escaped}"]`);
|
|
|
|
|
|
|
|
|
|
if (!exists) {
|
|
|
|
|
this.$voice.value = '';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.$voice.value = stored;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ====================
|
|
|
|
|
// RENDER: MESSAGES
|
|
|
|
|
// ====================
|
|
|
|
|
/**
|
|
|
|
|
* #renderMessages renders messages to the chat container.
|
|
|
|
|
* Messages already in the DOM (matched by data-id) are skipped.
|
|
|
|
|
* Tool results are matched to their corresponding tool calls.
|
|
|
|
|
* @param {Object[]} messages
|
|
|
|
|
* @param {Object} [meta] - Default metadata for messages without their own
|
|
|
|
|
*/
|
|
|
|
|
#renderMessages(messages, meta = {}) {
|
|
|
|
|
// Build tool result map for matching tool calls to their results.
|
|
|
|
|
const toolResultMap = new Map();
|
|
|
|
|
for (const msg of messages) {
|
|
|
|
|
if (msg.role === 'tool' && msg.tool_call_id) {
|
|
|
|
|
toolResultMap.set(msg.tool_call_id, msg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const msg of messages) {
|
|
|
|
|
// Skip if already rendered.
|
|
|
|
|
if (msg.id && this.$chat.querySelector(`[data-id="${msg.id}"]`)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
// Render user message.
|
|
|
|
|
if (msg.role === 'user') {
|
|
|
|
|
this.#renderUserMessage(msg, msg.meta ?? meta);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
// Tool call -- already processed with the result map.
|
|
|
|
|
if (msg.role !== 'assistant') continue;
|
|
|
|
|
|
|
|
|
|
// Skip assistant messages with no displayable content.
|
|
|
|
|
const hasContent =
|
|
|
|
|
msg.content || msg.reasoning_content || msg.tool_calls?.length;
|
|
|
|
|
if (!hasContent) continue;
|
|
|
|
|
|
|
|
|
|
// Otherwise, render assistant message.
|
|
|
|
|
const toolResults = (msg.tool_calls ?? [])
|
|
|
|
|
.map((tc) => toolResultMap.get(tc.id))
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
this.#renderAssistantMessage(msg, msg.meta ?? meta, toolResults);
|
|
|
|
|
}
|
2026-06-09 01:32:49 +00:00
|
|
|
|
|
|
|
|
// Ensure the loading spinner is always at the end of the chat.
|
|
|
|
|
if (!this.$chatLoading.hidden) {
|
|
|
|
|
this.$chat.appendChild(this.$chatLoading);
|
|
|
|
|
}
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #renderUserMessage renders a user message to the chat.
|
|
|
|
|
* @param {Object} message
|
|
|
|
|
* @param {Object} [meta]
|
|
|
|
|
*/
|
|
|
|
|
#renderUserMessage(message, meta = null) {
|
|
|
|
|
const content = (() => {
|
|
|
|
|
if (typeof message.content === 'string') {
|
|
|
|
|
return message.content;
|
|
|
|
|
}
|
|
|
|
|
if (Array.isArray(message.content)) {
|
|
|
|
|
return message.content
|
2026-06-26 14:22:05 +00:00
|
|
|
.filter((p) => p.type === ContentTypeText)
|
2026-02-13 15:03:02 +00:00
|
|
|
.map((p) => p.text)
|
|
|
|
|
.join('\n');
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
const $msg = this.$tplUserMessage.content.cloneNode(true).firstElementChild;
|
|
|
|
|
if (message.id) $msg.dataset.id = message.id;
|
|
|
|
|
|
|
|
|
|
$msg.querySelector('.message__content').textContent = content;
|
|
|
|
|
|
|
|
|
|
// Populate debug panel.
|
|
|
|
|
const $dl = $msg.querySelector('.message__debug-list');
|
|
|
|
|
if (meta?.language) this.#appendDebugRow($dl, 'Language', meta.language);
|
|
|
|
|
if (meta?.voice) this.#appendDebugRow($dl, 'Voice', meta.voice);
|
|
|
|
|
if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', '');
|
|
|
|
|
|
|
|
|
|
// Bind action buttons.
|
|
|
|
|
const $inspect = $msg.querySelector('[data-action="inspect"]');
|
|
|
|
|
$inspect.addEventListener('click', () => {
|
|
|
|
|
const isOpen = $msg.classList.toggle('message--debug-open');
|
|
|
|
|
$inspect.setAttribute('aria-expanded', String(isOpen));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $copy = $msg.querySelector('[data-action="copy"]');
|
|
|
|
|
$copy.addEventListener('click', () =>
|
|
|
|
|
this.#copyToClipboard($copy, content),
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-12 19:26:30 +00:00
|
|
|
const $delete = $msg.querySelector('[data-action="delete"]');
|
|
|
|
|
$delete.addEventListener('click', (e) =>
|
|
|
|
|
this.#handleDeleteClick(e, message.id),
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
this.$chat.appendChild($msg);
|
2026-06-09 18:50:25 +00:00
|
|
|
this.#scrollToBottom();
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-26 14:22:05 +00:00
|
|
|
/**
|
|
|
|
|
* #renderStreamingAssistantMessage renders a placeholder assistant message
|
|
|
|
|
* that receives streamed text deltas until the completion is done.
|
|
|
|
|
* @param {Object} message
|
|
|
|
|
* @param {Object} [meta]
|
|
|
|
|
* @returns {HTMLElement}
|
|
|
|
|
*/
|
|
|
|
|
#renderStreamingAssistantMessage(message, meta = null) {
|
|
|
|
|
const $msg =
|
|
|
|
|
this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
|
|
|
|
|
if (message.id) $msg.dataset.id = message.id;
|
|
|
|
|
$msg.classList.add('message--streaming');
|
|
|
|
|
|
|
|
|
|
// Populate debug panel.
|
|
|
|
|
const $dl = $msg.querySelector('.message__debug-list');
|
|
|
|
|
if (meta?.voice) this.#appendDebugRow($dl, 'Voice', meta.voice);
|
|
|
|
|
if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', '');
|
|
|
|
|
|
|
|
|
|
// Disable actions until the full message has arrived.
|
|
|
|
|
const $inspect = $msg.querySelector('[data-action="inspect"]');
|
|
|
|
|
$inspect.addEventListener('click', () => {
|
|
|
|
|
const isOpen = $msg.classList.toggle('message--debug-open');
|
|
|
|
|
$inspect.setAttribute('aria-expanded', String(isOpen));
|
|
|
|
|
});
|
|
|
|
|
$msg.querySelector('[data-action="play"]').remove();
|
|
|
|
|
$msg.querySelector('[data-action="copy"]').remove();
|
|
|
|
|
$msg.querySelector('[data-action="delete"]').remove();
|
|
|
|
|
|
|
|
|
|
this.$chat.appendChild($msg);
|
|
|
|
|
this.#scrollToBottom();
|
|
|
|
|
return $msg;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #appendStreamingDelta appends text to a streaming assistant message.
|
|
|
|
|
* @param {HTMLElement} $msg
|
|
|
|
|
* @param {string} delta
|
|
|
|
|
*/
|
|
|
|
|
#appendStreamingDelta($msg, delta) {
|
|
|
|
|
const $content = $msg.querySelector('.message__content');
|
|
|
|
|
$content.textContent += delta;
|
|
|
|
|
this.#scrollToBottom();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #finalizeStreamingAssistantMessage replaces a streaming placeholder with
|
|
|
|
|
* a fully bound assistant message.
|
|
|
|
|
* @param {HTMLElement} $streaming
|
|
|
|
|
* @param {Object} message
|
|
|
|
|
* @param {Object} [meta]
|
|
|
|
|
* @param {Object[]} [toolResults]
|
|
|
|
|
* @returns {HTMLElement|null}
|
|
|
|
|
*/
|
|
|
|
|
#finalizeStreamingAssistantMessage(
|
|
|
|
|
$streaming,
|
|
|
|
|
message,
|
|
|
|
|
meta = null,
|
|
|
|
|
toolResults = [],
|
|
|
|
|
) {
|
|
|
|
|
if (!$streaming) return null;
|
|
|
|
|
|
|
|
|
|
const $msg =
|
|
|
|
|
this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
|
|
|
|
|
if (message.id) $msg.dataset.id = message.id;
|
|
|
|
|
|
|
|
|
|
const $body = $msg.querySelector('.message__body');
|
|
|
|
|
const $content = $msg.querySelector('.message__content');
|
|
|
|
|
|
|
|
|
|
// Reasoning block (collapsible, shows LLM's chain-of-thought)
|
|
|
|
|
if (message.reasoning_content) {
|
|
|
|
|
const $reasoning = this.#createCollapsibleBlock(
|
|
|
|
|
'reasoning',
|
|
|
|
|
'Reasoning',
|
|
|
|
|
message.reasoning_content,
|
|
|
|
|
);
|
|
|
|
|
$body.insertBefore($reasoning, $content);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Tool call blocks (collapsible, shows function name, args, and result)
|
|
|
|
|
if (message.tool_calls?.length > 0) {
|
|
|
|
|
for (const toolCall of message.tool_calls) {
|
|
|
|
|
const result = toolResults.find((r) => r.tool_call_id === toolCall.id);
|
|
|
|
|
const $toolBlock = this.#createToolCallBlock(toolCall, result);
|
|
|
|
|
$body.insertBefore($toolBlock, $content);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Main content
|
|
|
|
|
if (this.#extractText(message.content)) {
|
|
|
|
|
$content.textContent = this.#extractText(message.content);
|
|
|
|
|
} else {
|
|
|
|
|
$content.remove();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Populate debug panel.
|
|
|
|
|
const $dl = $msg.querySelector('.message__debug-list');
|
|
|
|
|
if (meta?.voice) this.#appendDebugRow($dl, 'Voice', meta.voice);
|
|
|
|
|
if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', '');
|
|
|
|
|
|
|
|
|
|
// Bind action buttons.
|
|
|
|
|
const $inspect = $msg.querySelector('[data-action="inspect"]');
|
|
|
|
|
$inspect.addEventListener('click', () => {
|
|
|
|
|
const isOpen = $msg.classList.toggle('message--debug-open');
|
|
|
|
|
$inspect.setAttribute('aria-expanded', String(isOpen));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Play button: only for messages with non-empty content.
|
|
|
|
|
const $play = $msg.querySelector('[data-action="play"]');
|
|
|
|
|
if (this.#extractText(message.content)) {
|
|
|
|
|
const voice = meta?.voice || this.$voice.value || '';
|
|
|
|
|
$play.addEventListener('click', (e) =>
|
|
|
|
|
this.#handlePlayClick(
|
|
|
|
|
e,
|
|
|
|
|
message.id,
|
|
|
|
|
this.#extractText(message.content),
|
|
|
|
|
voice,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
$play.remove();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Assemble text from all available content.
|
|
|
|
|
const copyParts = [];
|
|
|
|
|
if (message.reasoning_content) copyParts.push(message.reasoning_content);
|
|
|
|
|
for (const tc of message.tool_calls ?? []) {
|
|
|
|
|
const name = tc.function?.name || 'unknown';
|
|
|
|
|
const args = tc.function?.arguments || '{}';
|
|
|
|
|
copyParts.push(`${name}(${args})`);
|
|
|
|
|
}
|
|
|
|
|
if (this.#extractText(message.content))
|
|
|
|
|
copyParts.push(this.#extractText(message.content));
|
|
|
|
|
const copyText = copyParts.join('\n\n');
|
|
|
|
|
|
|
|
|
|
const $copy = $msg.querySelector('[data-action="copy"]');
|
|
|
|
|
$copy.addEventListener('click', () =>
|
|
|
|
|
this.#copyToClipboard($copy, copyText),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const $delete = $msg.querySelector('[data-action="delete"]');
|
|
|
|
|
$delete.addEventListener('click', (e) =>
|
|
|
|
|
this.#handleDeleteClick(e, message.id),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$streaming.replaceWith($msg);
|
|
|
|
|
this.#scrollToBottom();
|
|
|
|
|
return $msg;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
/**
|
|
|
|
|
* #renderAssistantMessage renders an assistant message with optional
|
|
|
|
|
* reasoning, tool calls, and their results.
|
|
|
|
|
* @param {Object} message
|
|
|
|
|
* @param {string} [message.content]
|
|
|
|
|
* @param {string} [message.reasoning_content]
|
|
|
|
|
* @param {Array} [message.tool_calls]
|
|
|
|
|
* @param {Object} [meta]
|
|
|
|
|
* @param {Object[]} [toolResults]
|
|
|
|
|
*/
|
|
|
|
|
#renderAssistantMessage(message, meta = null, toolResults = []) {
|
|
|
|
|
const $msg =
|
|
|
|
|
this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
|
|
|
|
|
if (message.id) $msg.dataset.id = message.id;
|
|
|
|
|
|
|
|
|
|
const $body = $msg.querySelector('.message__body');
|
|
|
|
|
const $content = $msg.querySelector('.message__content');
|
|
|
|
|
|
|
|
|
|
// Reasoning block (collapsible, shows LLM's chain-of-thought)
|
|
|
|
|
if (message.reasoning_content) {
|
|
|
|
|
const $reasoning = this.#createCollapsibleBlock(
|
|
|
|
|
'reasoning',
|
|
|
|
|
'Reasoning',
|
|
|
|
|
message.reasoning_content,
|
|
|
|
|
);
|
|
|
|
|
$body.insertBefore($reasoning, $content);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Tool call blocks (collapsible, shows function name, args, and result)
|
|
|
|
|
if (message.tool_calls?.length > 0) {
|
|
|
|
|
for (const toolCall of message.tool_calls) {
|
|
|
|
|
const result = toolResults.find((r) => r.tool_call_id === toolCall.id);
|
|
|
|
|
const $toolBlock = this.#createToolCallBlock(toolCall, result);
|
|
|
|
|
$body.insertBefore($toolBlock, $content);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Main content
|
2026-06-26 14:22:05 +00:00
|
|
|
if (this.#extractText(message.content)) {
|
|
|
|
|
$content.textContent = this.#extractText(message.content);
|
2026-02-13 15:03:02 +00:00
|
|
|
} else {
|
|
|
|
|
$content.remove();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Populate debug panel.
|
|
|
|
|
const $dl = $msg.querySelector('.message__debug-list');
|
|
|
|
|
if (meta?.voice) this.#appendDebugRow($dl, 'Voice', meta.voice);
|
|
|
|
|
if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', '');
|
|
|
|
|
|
|
|
|
|
// Bind action buttons.
|
|
|
|
|
const $inspect = $msg.querySelector('[data-action="inspect"]');
|
|
|
|
|
$inspect.addEventListener('click', () => {
|
|
|
|
|
const isOpen = $msg.classList.toggle('message--debug-open');
|
|
|
|
|
$inspect.setAttribute('aria-expanded', String(isOpen));
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-13 18:55:16 +00:00
|
|
|
// Play button: only for messages with non-empty content.
|
|
|
|
|
const $play = $msg.querySelector('[data-action="play"]');
|
2026-06-26 14:22:05 +00:00
|
|
|
if (this.#extractText(message.content)) {
|
2026-06-13 18:55:16 +00:00
|
|
|
const voice = meta?.voice || this.$voice.value || '';
|
|
|
|
|
$play.addEventListener('click', (e) =>
|
2026-06-26 14:22:05 +00:00
|
|
|
this.#handlePlayClick(
|
|
|
|
|
e,
|
|
|
|
|
message.id,
|
|
|
|
|
this.#extractText(message.content),
|
|
|
|
|
voice,
|
|
|
|
|
),
|
2026-06-13 18:55:16 +00:00
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
$play.remove();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
// Assemble text from all available content.
|
|
|
|
|
const copyParts = [];
|
|
|
|
|
if (message.reasoning_content) copyParts.push(message.reasoning_content);
|
|
|
|
|
for (const tc of message.tool_calls ?? []) {
|
|
|
|
|
const name = tc.function?.name || 'unknown';
|
|
|
|
|
const args = tc.function?.arguments || '{}';
|
|
|
|
|
copyParts.push(`${name}(${args})`);
|
|
|
|
|
}
|
2026-06-26 14:22:05 +00:00
|
|
|
if (this.#extractText(message.content))
|
|
|
|
|
copyParts.push(this.#extractText(message.content));
|
2026-02-13 15:03:02 +00:00
|
|
|
const copyText = copyParts.join('\n\n');
|
|
|
|
|
|
|
|
|
|
const $copy = $msg.querySelector('[data-action="copy"]');
|
|
|
|
|
$copy.addEventListener('click', () =>
|
|
|
|
|
this.#copyToClipboard($copy, copyText),
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-12 19:26:30 +00:00
|
|
|
const $delete = $msg.querySelector('[data-action="delete"]');
|
|
|
|
|
$delete.addEventListener('click', (e) =>
|
|
|
|
|
this.#handleDeleteClick(e, message.id),
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
this.$chat.appendChild($msg);
|
2026-06-09 18:50:25 +00:00
|
|
|
this.#scrollToBottom();
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #renderError renders an error message in the chat.
|
|
|
|
|
* @param {string} message
|
|
|
|
|
*/
|
|
|
|
|
#renderError(message) {
|
|
|
|
|
const $msg =
|
|
|
|
|
this.$tplErrorMessage.content.cloneNode(true).firstElementChild;
|
|
|
|
|
$msg.querySelector('.message__content').textContent = message;
|
|
|
|
|
|
|
|
|
|
this.$chat.appendChild($msg);
|
2026-06-09 18:50:25 +00:00
|
|
|
this.#scrollToBottom();
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ====================
|
|
|
|
|
// RENDER: ATTACHMENTS
|
|
|
|
|
// ====================
|
|
|
|
|
/**
|
|
|
|
|
* #renderAttachments renders the attachment chips in the footer.
|
|
|
|
|
*/
|
|
|
|
|
#renderAttachments() {
|
|
|
|
|
this.$attachments.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < this.attachments.length; i++) {
|
|
|
|
|
const file = this.attachments[i];
|
|
|
|
|
|
|
|
|
|
const $chip =
|
|
|
|
|
this.$tplAttachmentChip.content.cloneNode(true).firstElementChild;
|
|
|
|
|
|
|
|
|
|
const $name = $chip.querySelector('.compose__attachment-name');
|
|
|
|
|
$name.textContent = file.name;
|
|
|
|
|
$name.title = file.name;
|
|
|
|
|
|
|
|
|
|
const $remove = $chip.querySelector('.compose__attachment-remove');
|
|
|
|
|
$remove.setAttribute('aria-label', `Remove ${file.name}`);
|
|
|
|
|
$remove.addEventListener('click', () => this.#removeAttachment(i));
|
|
|
|
|
|
|
|
|
|
this.$attachments.appendChild($chip);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #removeAttachment removes the attachment at the given index.
|
|
|
|
|
* @param {number} index
|
|
|
|
|
*/
|
|
|
|
|
#removeAttachment(index) {
|
|
|
|
|
this.attachments.splice(index, 1);
|
|
|
|
|
this.#renderAttachments();
|
2026-06-13 00:44:01 +00:00
|
|
|
this.#updateSendButtonState();
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #clearAttachments removes all attachments.
|
|
|
|
|
*/
|
|
|
|
|
#clearAttachments() {
|
|
|
|
|
this.attachments = [];
|
|
|
|
|
this.#renderAttachments();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ====================
|
|
|
|
|
// RENDER: COMPONENTS
|
|
|
|
|
// ====================
|
|
|
|
|
/**
|
|
|
|
|
* #createCollapsibleBlock creates a collapsible details element.
|
|
|
|
|
* @param {string} type - CSS modifier for styling
|
|
|
|
|
* @param {string} label - Summary text
|
|
|
|
|
* @param {string} content - Content to display when expanded
|
|
|
|
|
* @returns {HTMLDetailsElement}
|
|
|
|
|
*/
|
|
|
|
|
#createCollapsibleBlock(type, label, content) {
|
|
|
|
|
const $details =
|
|
|
|
|
this.$tplCollapsible.content.cloneNode(true).firstElementChild;
|
|
|
|
|
$details.classList.add(`collapsible--${type}`);
|
|
|
|
|
|
|
|
|
|
const $summary = $details.querySelector('.collapsible__summary');
|
|
|
|
|
$summary.insertBefore(this.#icon(type), $summary.firstChild);
|
|
|
|
|
|
|
|
|
|
$details.querySelector('.collapsible__label').textContent = label;
|
|
|
|
|
$details.querySelector('pre').textContent = content;
|
|
|
|
|
|
|
|
|
|
return $details;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #createToolCallBlock creates a collapsible block for a tool call.
|
|
|
|
|
* Shows the tool name, arguments, and optionally the result.
|
|
|
|
|
* @param {Object} toolCall
|
|
|
|
|
* @param {Object} [result]
|
|
|
|
|
* @returns {HTMLDetailsElement}
|
|
|
|
|
*/
|
|
|
|
|
#createToolCallBlock(toolCall, result = null) {
|
|
|
|
|
const $details =
|
|
|
|
|
this.$tplToolCall.content.cloneNode(true).firstElementChild;
|
|
|
|
|
|
|
|
|
|
$details.querySelector('.collapsible__label').textContent =
|
|
|
|
|
toolCall.function?.name || 'Tool Call';
|
|
|
|
|
|
|
|
|
|
// Arguments section
|
|
|
|
|
const $args = $details.querySelector('[data-args]');
|
|
|
|
|
try {
|
|
|
|
|
const args = JSON.parse(toolCall.function?.arguments || '{}');
|
|
|
|
|
$args.textContent = JSON.stringify(args, null, 2);
|
|
|
|
|
} catch {
|
|
|
|
|
$args.textContent = toolCall.function?.arguments || '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Output section (only if result is available)
|
|
|
|
|
if (result) {
|
|
|
|
|
const $outputSection = $details.querySelector(
|
|
|
|
|
'.collapsible__section--output',
|
|
|
|
|
);
|
|
|
|
|
$outputSection.hidden = false;
|
|
|
|
|
|
|
|
|
|
const $output = $details.querySelector('[data-output]');
|
|
|
|
|
try {
|
2026-06-26 14:22:05 +00:00
|
|
|
const output = JSON.parse(this.#extractText(result.content) || '{}');
|
2026-02-13 15:03:02 +00:00
|
|
|
$output.textContent = JSON.stringify(output, null, 2);
|
|
|
|
|
} catch {
|
2026-06-26 14:22:05 +00:00
|
|
|
$output.textContent = this.#extractText(result.content) || '';
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $details;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #appendDebugRow appends a label/value pair to a debug list.
|
|
|
|
|
* @param {HTMLDListElement} $dl
|
|
|
|
|
* @param {string} label
|
|
|
|
|
* @param {string} value
|
|
|
|
|
*/
|
|
|
|
|
#appendDebugRow($dl, label, value) {
|
|
|
|
|
const $row = this.$tplDebugRow.content.cloneNode(true);
|
|
|
|
|
$row.querySelector('dt').textContent = label;
|
|
|
|
|
$row.querySelector('dd').textContent = value;
|
|
|
|
|
$dl.appendChild($row);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 14:22:05 +00:00
|
|
|
/**
|
|
|
|
|
* #extractText extracts plain text from a message content field.
|
|
|
|
|
* Content can be a string or an array of ContentPart objects.
|
|
|
|
|
* @param {string|Array} content
|
|
|
|
|
* @returns {string}
|
|
|
|
|
*/
|
|
|
|
|
#extractText(content) {
|
|
|
|
|
if (typeof content === 'string') return content;
|
|
|
|
|
if (Array.isArray(content)) {
|
|
|
|
|
return content
|
|
|
|
|
.filter((p) => p.type === ContentTypeText)
|
|
|
|
|
.map((p) => p.text)
|
|
|
|
|
.join('\n');
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
// ====================
|
|
|
|
|
// UTILITIES
|
|
|
|
|
// ====================
|
|
|
|
|
/**
|
|
|
|
|
* #icon creates an SVG icon element referencing the icon sprite.
|
|
|
|
|
* @param {string} name
|
|
|
|
|
* @returns {SVGSVGElement}
|
|
|
|
|
*/
|
|
|
|
|
#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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #toBase64 converts a Blob to a base64-encoded string.
|
|
|
|
|
* @param {Blob} blob
|
|
|
|
|
* @returns {Promise<string>}
|
|
|
|
|
*/
|
|
|
|
|
async #toBase64(blob) {
|
|
|
|
|
const buffer = await blob.arrayBuffer();
|
|
|
|
|
return new Uint8Array(buffer).toBase64();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #copyToClipboard copies text and shows a brief success indicator.
|
|
|
|
|
* @param {HTMLButtonElement} $button - The button to update with feedback
|
|
|
|
|
* @param {string} text - The text to copy
|
|
|
|
|
*/
|
|
|
|
|
async #copyToClipboard($button, text) {
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
|
$button.replaceChildren(this.#icon('check'));
|
|
|
|
|
$button.classList.add('message__action-btn--success');
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
$button.replaceChildren(this.#icon('copy'));
|
|
|
|
|
$button.classList.remove('message__action-btn--success');
|
|
|
|
|
}, 1500);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('failed to copy:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-09 23:17:55 +00:00
|
|
|
|
|
|
|
|
// ====================
|
|
|
|
|
// WAKE LOCK
|
|
|
|
|
// ====================
|
|
|
|
|
/**
|
|
|
|
|
* #initWakeLock performs feature detection for the Screen Wake Lock API.
|
|
|
|
|
*/
|
|
|
|
|
#initWakeLock() {
|
|
|
|
|
if (!('wakeLock' in navigator)) {
|
|
|
|
|
console.debug('Screen Wake Lock API not supported');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #acquireWakeLock requests a screen wake lock. Idempotent.
|
|
|
|
|
*/
|
|
|
|
|
async #acquireWakeLock() {
|
|
|
|
|
if (this.wakeLock) return;
|
|
|
|
|
if (!('wakeLock' in navigator)) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
this.wakeLock = await navigator.wakeLock.request('screen');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.debug('wake lock acquire failed:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #releaseWakeLock releases the active wake lock. Idempotent.
|
|
|
|
|
*/
|
|
|
|
|
async #releaseWakeLock() {
|
|
|
|
|
if (!this.wakeLock) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await this.wakeLock.release();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.debug('wake lock release failed:', e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.wakeLock = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* #handleVisibilityChange reacquires the wake lock when the tab becomes
|
|
|
|
|
* visible, if the app is currently active (recording or processing).
|
|
|
|
|
*/
|
|
|
|
|
#handleVisibilityChange() {
|
|
|
|
|
if (this.document.visibilityState !== 'visible') return;
|
|
|
|
|
if (!this.isRecording && !this.isProcessing) return;
|
|
|
|
|
this.#acquireWakeLock();
|
|
|
|
|
}
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Initialize
|
|
|
|
|
new Odidere({ document });
|