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

1465 lines
44 KiB
JavaScript
Raw Normal View History

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