T3Chat OpenAI TTS & STT

Adds OpenAI text-to-speech and speech-to-text to T3Chat

  1. // ==UserScript==
  2. // @name T3Chat OpenAI TTS & STT
  3. // @namespace https://github.com/cameron/t3chat-userscripts
  4. // @version 0.1.2
  5. // @description Adds OpenAI text-to-speech and speech-to-text to T3Chat
  6. // @match https://t3.chat/*
  7. // @match https://*.t3.chat/*
  8. // @run-at document-idle
  9. // @grant none
  10. // @license MIT
  11. // ==/UserScript==
  12.  
  13. (() => {
  14. 'use strict';
  15.  
  16. const CONFIG = {
  17. apiBaseUrl: 'https://api.openai.com/v1',
  18. ttsModel: 'tts-1',
  19. ttsVoice: 'alloy',
  20. sttModel: 'whisper-1',
  21. maxRecordingTime: 60000,
  22. currentVersion: '0.1.2',
  23. storageKeys: {
  24. t3chatApiKey: 'apikey:openai',
  25. ttsEnabled: 't3chat-tts-enabled',
  26. sttEnabled: 't3chat-stt-enabled',
  27. ttsVoice: 't3chat-tts-voice',
  28. sttMethod: 't3chat-stt-method',
  29. version: 't3chat-tts-stt-version'
  30. }
  31. };
  32.  
  33. if (localStorage.getItem(CONFIG.storageKeys.version) !== CONFIG.currentVersion) {
  34. localStorage.removeItem(CONFIG.storageKeys.sttMethod);
  35. localStorage.setItem(CONFIG.storageKeys.version, CONFIG.currentVersion);
  36. }
  37.  
  38. const SELECTORS = {
  39. chatInput: [
  40. '#chat-input',
  41. 'textarea[aria-describedby="chat-input-description"]',
  42. 'textarea[placeholder*="message"]',
  43. 'textarea[data-testid="chat-input"]'
  44. ],
  45. messageContainer: '[role="article"], .message, div[class*="message"]',
  46. messageContent: '.prose, .message-content, div[class*="prose"], p, div[class*="text"]',
  47. messageActionsContainer:
  48. 'div[class*="absolute"][class*="flex"][class*="items-center"][class*="gap"], div.absolute.left-0[class*="-ml-0"][class*="mt-2"], div.absolute.right-0[class*="mt-"]',
  49. sendButton: 'button[type="submit"][aria-label*="Message"], button[aria-label*="send" i]'
  50. };
  51.  
  52. const getT3ChatApiKey = () => {
  53. const key = localStorage.getItem(CONFIG.storageKeys.t3chatApiKey);
  54. return key?.startsWith('sk-') ? key : null;
  55. };
  56.  
  57. const state = {
  58. get apiKey() {
  59. return getT3ChatApiKey();
  60. },
  61. ttsEnabled: localStorage.getItem(CONFIG.storageKeys.ttsEnabled) !== 'false',
  62. sttEnabled: localStorage.getItem(CONFIG.storageKeys.sttEnabled) !== 'false',
  63. sttMethod: localStorage.getItem(CONFIG.storageKeys.sttMethod) || 'openai',
  64. ttsVoice: localStorage.getItem(CONFIG.storageKeys.ttsVoice) || CONFIG.ttsVoice,
  65. isRecording: false,
  66. mediaRecorder: null,
  67. audioChunks: [],
  68. currentAudio: null,
  69. recordingMimeType: '',
  70. speechRecognition: null
  71. };
  72.  
  73. if (localStorage.getItem(CONFIG.storageKeys.ttsEnabled) === null) {
  74. localStorage.setItem(CONFIG.storageKeys.ttsEnabled, 'true');
  75. state.ttsEnabled = true;
  76. }
  77. if (localStorage.getItem(CONFIG.storageKeys.sttEnabled) === null) {
  78. localStorage.setItem(CONFIG.storageKeys.sttEnabled, 'true');
  79. state.sttEnabled = true;
  80. }
  81.  
  82. const findChatInput = () =>
  83. SELECTORS.chatInput
  84. .map((s) => document.querySelector(s))
  85. .find((el) => el && el.tagName === 'TEXTAREA');
  86.  
  87. const findInputContainer = () => {
  88. const input = findChatInput();
  89. if (!input) return null;
  90. const sendBtn =
  91. document.querySelector(SELECTORS.sendButton) ||
  92. input.parentElement?.querySelector('button[type="submit"]') ||
  93. input.parentElement?.querySelector('button[aria-label*="send" i]');
  94. return sendBtn ? sendBtn.parentElement : input.closest('div[class*="flex"]') || input.parentElement;
  95. };
  96.  
  97. const injectStyles = () => {
  98. if (document.querySelector('#t3chat-tts-stt-styles')) return;
  99. const style = document.createElement('style');
  100. style.id = 't3chat-tts-stt-styles';
  101. style.textContent = `
  102. .t3-tts-btn,.t3-stt-btn,.t3-settings-btn{
  103. display:flex;align-items:center;justify-content:center;width:32px;height:32px;border:1px solid hsl(var(--border));
  104. border-radius:6px;background:hsl(var(--background));color:hsl(var(--foreground));cursor:pointer;
  105. transition:all .2s ease;position:relative;flex-shrink:0
  106. }
  107. .t3-tts-btn:hover,.t3-stt-btn:hover,.t3-settings-btn:hover{background:hsl(var(--muted));border-color:hsl(var(--ring))}
  108. .t3-stt-btn.recording{background:#ef4444;color:#fff;animation:pulse 1s infinite}
  109. .t3-tts-btn.speaking{background:#3b82f6;color:#fff}
  110. .t3-tts-btn.disabled,.t3-stt-btn.disabled{opacity:.5;cursor:not-allowed}
  111. @keyframes pulse{0%,100%{opacity:1}50%{opacity:.7}}
  112. .t3-tooltip{position:absolute;bottom:100%;left:50%;transform:translateX(-50%);background:hsl(var(--foreground));
  113. color:hsl(var(--background));padding:4px 8px;border-radius:4px;font-size:12px;white-space:nowrap;opacity:0;
  114. pointer-events:none;transition:opacity .2s ease;margin-bottom:4px;z-index:1000}
  115. .t3-stt-btn:hover .t3-tooltip,.t3-settings-btn:hover .t3-tooltip{opacity:1}
  116. button[aria-label="Speak message"].speaking{background:#3b82f6!important;color:#fff!important}
  117. button[aria-label="Speak message"]{width:32px!important;height:32px!important;min-width:32px!important;min-height:32px!important;
  118. display:flex!important;align-items:center!important;justify-content:center!important}
  119. button[aria-label="Speak message"] .relative,button[aria-label="Speak message"] svg{width:24px!important;height:24px!important}
  120. `;
  121. document.head.appendChild(style);
  122. };
  123.  
  124. const callOpenAI = async (endpoint, data, options = {}) => {
  125. if (!state.apiKey) throw new Error('OpenAI API key not configured');
  126. const res = await fetch(`${CONFIG.apiBaseUrl}${endpoint}`, {
  127. method: 'POST',
  128. headers: {
  129. Authorization: `Bearer ${state.apiKey}`,
  130. 'Content-Type': 'application/json',
  131. ...options.headers
  132. },
  133. body: JSON.stringify(data),
  134. ...options
  135. });
  136. if (!res.ok) {
  137. const err = await res.json().catch(() => ({ error: { message: `HTTP ${res.status}` } }));
  138. throw new Error(err.error?.message || `HTTP ${res.status}`);
  139. }
  140. return res;
  141. };
  142.  
  143. const textToSpeech = async (text) => {
  144. const res = await callOpenAI('/audio/speech', {
  145. model: CONFIG.ttsModel,
  146. voice: state.ttsVoice,
  147. input: text.slice(0, 4096)
  148. });
  149. const blob = await res.blob();
  150. const url = URL.createObjectURL(blob);
  151. if (state.currentAudio) {
  152. state.currentAudio.pause();
  153. URL.revokeObjectURL(state.currentAudio.src);
  154. }
  155. state.currentAudio = new Audio(url);
  156. return state.currentAudio;
  157. };
  158.  
  159. const speechToText = async (blob) => {
  160. const mime = blob.type.toLowerCase();
  161. const ext =
  162. mime.includes('wav')
  163. ? 'wav'
  164. : mime.includes('mp4')
  165. ? 'mp4'
  166. : mime.includes('mp3')
  167. ? 'mp3'
  168. : mime.includes('ogg')
  169. ? 'ogg'
  170. : 'webm';
  171.  
  172. const form = new FormData();
  173. form.append('file', blob, `audio.${ext}`);
  174. form.append('model', CONFIG.sttModel);
  175.  
  176. const res = await fetch(`${CONFIG.apiBaseUrl}/audio/transcriptions`, {
  177. method: 'POST',
  178. headers: { Authorization: `Bearer ${state.apiKey}` },
  179. body: form
  180. });
  181. if (!res.ok) {
  182. const txt = await res.text();
  183. throw new Error(`STT failed: ${txt}`);
  184. }
  185. const json = await res.json();
  186. return json.text;
  187. };
  188.  
  189. const initSpeechRecognition = () => {
  190. const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
  191. if (!SR) return null;
  192. const rec = new SR();
  193. rec.continuous = false;
  194. rec.interimResults = false;
  195. rec.maxAlternatives = 1;
  196. rec.lang = 'en-US';
  197.  
  198. rec.onstart = () => {
  199. state.isRecording = true;
  200. updateSTTButton();
  201. };
  202. rec.onresult = (e) => {
  203. const txt = e.results[0][0].transcript;
  204. const input = findChatInput();
  205. if (input && txt.trim()) {
  206. input.value = (input.value + ' ' + txt).trim();
  207. input.dispatchEvent(new Event('input', { bubbles: true }));
  208. input.focus();
  209. }
  210. };
  211. rec.onerror = rec.onend = () => {
  212. state.isRecording = false;
  213. updateSTTButton();
  214. };
  215. return rec;
  216. };
  217.  
  218. const startRecording = async () => {
  219. if (state.sttMethod === 'browser') return startBrowserSpeechRecognition();
  220. try {
  221. const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  222. const types = [
  223. 'audio/wav',
  224. 'audio/mp4',
  225. 'audio/webm;codecs=opus',
  226. 'audio/webm',
  227. 'audio/ogg;codecs=opus',
  228. 'audio/mp3'
  229. ];
  230. const type = types.find((t) => MediaRecorder.isTypeSupported(t)) || '';
  231. if (!type) throw new Error('No supported audio MIME type found');
  232.  
  233. state.mediaRecorder = new MediaRecorder(stream, { mimeType: type });
  234. state.audioChunks = [];
  235. state.recordingMimeType = type;
  236.  
  237. state.mediaRecorder.ondataavailable = (e) => e.data.size && state.audioChunks.push(e.data);
  238. state.mediaRecorder.onstop = async () => {
  239. const blob = new Blob(state.audioChunks, { type: state.recordingMimeType });
  240. try {
  241. const txt = await speechToText(blob);
  242. const input = findChatInput();
  243. if (input && txt.trim()) {
  244. input.value = (input.value + ' ' + txt).trim();
  245. input.dispatchEvent(new Event('input', { bubbles: true }));
  246. input.focus();
  247. }
  248. } finally {
  249. stream.getTracks().forEach((t) => t.stop());
  250. state.isRecording = false;
  251. updateSTTButton();
  252. }
  253. };
  254. state.mediaRecorder.start();
  255. state.isRecording = true;
  256. updateSTTButton();
  257. setTimeout(() => state.isRecording && stopRecording(), CONFIG.maxRecordingTime);
  258. } catch (err) {}
  259. };
  260.  
  261. const startBrowserSpeechRecognition = () => {
  262. if (!state.speechRecognition) state.speechRecognition = initSpeechRecognition();
  263. state.speechRecognition?.start();
  264. };
  265.  
  266. const stopRecording = () => {
  267. if (state.sttMethod === 'browser') {
  268. state.speechRecognition?.stop();
  269. } else {
  270. state.mediaRecorder?.stop();
  271. }
  272. };
  273.  
  274. const createButton = (cls, svg, tooltip) => {
  275. const btn = document.createElement('button');
  276. btn.className = cls;
  277. btn.innerHTML = `${svg}<div class="t3-tooltip">${tooltip}</div>`;
  278. return btn;
  279. };
  280.  
  281. const createTTSButton = () => {
  282. const svg =
  283. '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5,6 9,2 9,2 15,6 15,11 19,11 5"></polygon><path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path><path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path></svg>';
  284. const btn = createButton('t3-tts-btn', svg, 'Text to Speech');
  285. btn.addEventListener('click', async () => {
  286. const input = findChatInput();
  287. if (input?.value.trim()) await speakText(input.value.trim());
  288. });
  289. return btn;
  290. };
  291.  
  292. const createSTTButton = () => {
  293. const svg =
  294. '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" x2="12" y1="19" y2="22"></line><line x1="8" x2="16" y1="22" y2="22"></line></svg>';
  295. const btn = createButton('t3-stt-btn', svg, 'Speech to Text');
  296. btn.addEventListener('click', () => (state.isRecording ? stopRecording() : startRecording()));
  297. return btn;
  298. };
  299.  
  300. const createSettingsButton = () => {
  301. const svg =
  302. '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path><circle cx="12" cy="12" r="3"></circle></svg>';
  303. const btn = createButton('t3-settings-btn', svg, 'TTS/STT Settings');
  304. btn.addEventListener('click', showSettingsModal);
  305. return btn;
  306. };
  307.  
  308. const createMessageSpeakButton = (msg) => {
  309. const btn = document.createElement('button');
  310. btn.className =
  311. 'inline-flex items-center justify-center text-xs rounded-lg p-0 hover:bg-muted/40';
  312. btn.setAttribute('aria-label', 'Speak message');
  313. btn.innerHTML =
  314. '<div class="relative" style="width:24px;height:24px"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5,6 9,2 9,2 15,6 15,11 19,11 5"></polygon><path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg></div>';
  315. btn.addEventListener('click', () => {
  316. const text = msg.textContent.trim();
  317. if (!text) return;
  318. btn.classList.add('speaking');
  319. speakText(text).finally(() => btn.classList.remove('speaking'));
  320. });
  321. return btn;
  322. };
  323.  
  324. const speakText = async (txt) => {
  325. try {
  326. const audio = await textToSpeech(txt);
  327. await audio.play();
  328. } catch (err) {}
  329. };
  330.  
  331. const updateSTTButton = () => {
  332. const btn = document.querySelector('.t3-stt-btn');
  333. if (!btn) return;
  334. btn.classList.toggle('recording', state.isRecording);
  335. const tip = btn.querySelector('.t3-tooltip');
  336. if (tip) tip.textContent = state.isRecording ? 'Stop Recording' : 'Speech to Text';
  337. };
  338.  
  339. const showSettingsModal = () => {
  340. const hasKey = !!state.apiKey;
  341. const modal = document.createElement('div');
  342. modal.className = 't3-settings-modal';
  343. modal.innerHTML = `
  344. <style>
  345. .t3-settings-modal{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:10000}
  346. .t3-settings-content{background:hsl(var(--background));border:1px solid hsl(var(--border));border-radius:8px;padding:24px;min-width:400px;max-width:500px}
  347. .t3-settings-title{font-size:18px;font-weight:600;margin-bottom:16px;color:hsl(var(--foreground))}
  348. .t3-form-group{margin-bottom:16px}
  349. .t3-form-label{display:block;font-size:14px;font-weight:500;margin-bottom:4px;color:hsl(var(--foreground))}
  350. .t3-form-select,.t3-form-input{width:100%;padding:8px 12px;border:1px solid hsl(var(--border));border-radius:6px;background:hsl(var(--background));color:hsl(var(--foreground));font-size:14px}
  351. .t3-form-checkbox{display:flex;align-items:center;gap:8px}
  352. .t3-button-group{display:flex;gap:8px;justify-content:flex-end;margin-top:20px}
  353. .t3-btn{padding:8px 16px;border-radius:6px;border:1px solid hsl(var(--border));background:hsl(var(--background));color:hsl(var(--foreground));cursor:pointer;font-size:14px;transition:all .2s ease}
  354. .t3-btn:hover{background:hsl(var(--muted))}
  355. .t3-btn.primary{background:hsl(var(--primary));color:hsl(var(--primary-foreground));border-color:hsl(var(--primary))}
  356. .t3-btn.primary:hover{opacity:.9}
  357. .t3-api-key-status{padding:12px;border-radius:6px;background:hsl(var(--muted));border:1px solid hsl(var(--border))}
  358. .t3-api-status{font-weight:500;margin-top:4px}
  359. .t3-api-status.connected{color:#22c55e}
  360. .t3-api-status.disconnected{color:#ef4444}
  361. .t3-form-help{font-size:12px;color:hsl(var(--muted-foreground));margin-top:8px}
  362. </style>
  363. <div class="t3-settings-content">
  364. <div class="t3-settings-title">TTS & STT Settings</div>
  365. <div class="t3-form-group">
  366. <div class="t3-api-key-status">
  367. <div class="t3-form-label">OpenAI API Key Status</div>
  368. <div class="t3-api-status ${hasKey ? 'connected' : 'disconnected'}">
  369. ${hasKey ? '✅ Connected' : '❌ Not configured'}
  370. </div>
  371. ${hasKey ? '' : '<p class="t3-form-help">Add your OpenAI key in T3Chat settings.</p>'}
  372. </div>
  373. </div>
  374. <div class="t3-form-group">
  375. <label class="t3-form-label">STT Method</label>
  376. <select class="t3-form-select" id="stt-method-select">
  377. <option value="browser" ${state.sttMethod === 'browser' ? 'selected' : ''}>Browser</option>
  378. <option value="openai" ${state.sttMethod === 'openai' ? 'selected' : ''} ${!hasKey ? 'disabled' : ''}>OpenAI Whisper</option>
  379. </select>
  380. </div>
  381. <div class="t3-form-group">
  382. <label class="t3-form-label">TTS Voice</label>
  383. <select class="t3-form-select" id="voice-select" ${!hasKey ? 'disabled' : ''}>
  384. ${['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']
  385. .map((v) => `<option value="${v}" ${state.ttsVoice === v ? 'selected' : ''}>${v[0].toUpperCase() + v.slice(1)}</option>`)
  386. .join('')}
  387. </select>
  388. </div>
  389. <div class="t3-form-group">
  390. <label class="t3-form-checkbox"><input type="checkbox" id="tts-enabled" ${state.ttsEnabled ? 'checked' : ''}><span>Enable Text-to-Speech</span></label>
  391. </div>
  392. <div class="t3-form-group">
  393. <label class="t3-form-checkbox"><input type="checkbox" id="stt-enabled" ${state.sttEnabled ? 'checked' : ''}><span>Enable Speech-to-Text</span></label>
  394. </div>
  395. <div class="t3-button-group">
  396. <button class="t3-btn" id="cancel-settings">Cancel</button>
  397. <button class="t3-btn primary" id="save-settings">Save</button>
  398. </div>
  399. </div>`;
  400. modal.addEventListener('click', (e) => e.target === modal && modal.remove());
  401. modal.querySelector('#cancel-settings').addEventListener('click', () => modal.remove());
  402. modal.querySelector('#save-settings').addEventListener('click', () => {
  403. const voice = modal.querySelector('#voice-select').value;
  404. const ttsEnabled = modal.querySelector('#tts-enabled').checked;
  405. const sttEnabled = modal.querySelector('#stt-enabled').checked;
  406. const method = modal.querySelector('#stt-method-select').value;
  407. state.ttsVoice = voice;
  408. state.ttsEnabled = ttsEnabled;
  409. state.sttEnabled = sttEnabled;
  410. state.sttMethod = method;
  411. localStorage.setItem(CONFIG.storageKeys.ttsVoice, voice);
  412. localStorage.setItem(CONFIG.storageKeys.ttsEnabled, ttsEnabled);
  413. localStorage.setItem(CONFIG.storageKeys.sttEnabled, sttEnabled);
  414. localStorage.setItem(CONFIG.storageKeys.sttMethod, method);
  415. updateControlsVisibility();
  416. modal.remove();
  417. });
  418. document.body.appendChild(modal);
  419. };
  420.  
  421. const updateControlsVisibility = () => {
  422. const stt = document.querySelector('.t3-stt-btn');
  423. if (!stt) return;
  424. stt.style.display = state.sttEnabled ? 'flex' : 'none';
  425. stt.classList.toggle('disabled', !state.apiKey);
  426. };
  427.  
  428. const addControlsToInput = () => {
  429. const container = findInputContainer();
  430. if (!container || container.querySelector('.t3-settings-btn')) return;
  431. const sendBtn =
  432. container.querySelector(SELECTORS.sendButton) ||
  433. container.querySelector('button[type="submit"]') ||
  434. container.querySelector('button[aria-label*="send" i]');
  435.  
  436. const settingsBtn = createSettingsButton();
  437. if (sendBtn) container.insertBefore(settingsBtn, sendBtn);
  438. else container.appendChild(settingsBtn);
  439.  
  440. if (state.sttEnabled) {
  441. const sttBtn = createSTTButton();
  442. sendBtn ? container.insertBefore(sttBtn, sendBtn) : container.appendChild(sttBtn);
  443. }
  444. updateControlsVisibility();
  445. };
  446.  
  447. const processMessage = (msg) => {
  448. const content = msg.querySelector(SELECTORS.messageContent);
  449. if (!content || !content.textContent.trim() || !state.ttsEnabled) return;
  450. let actions =
  451. msg.parentElement?.querySelector(SELECTORS.messageActionsContainer) ||
  452. msg.querySelector(SELECTORS.messageActionsContainer);
  453. if (!actions) actions = msg.parentElement?.querySelector('div[class*="absolute"][class*="flex"]');
  454. if (!actions || actions.querySelector('button[aria-label="Speak message"]')) return;
  455. const speakBtn = createMessageSpeakButton(content);
  456. const genTxt = actions.querySelector('span[class*="select-none"]');
  457. if (genTxt) actions.insertBefore(speakBtn, genTxt);
  458. else {
  459. const first = actions.querySelector('button');
  460. first?.nextSibling ? actions.insertBefore(speakBtn, first.nextSibling) : actions.appendChild(speakBtn);
  461. }
  462. msg.setAttribute('data-tts-added', 'true');
  463. };
  464.  
  465. const addTTSToMessages = () => {
  466. document
  467. .querySelectorAll(`${SELECTORS.messageContainer}:not([data-tts-added])`)
  468. .forEach(processMessage);
  469. };
  470.  
  471. const initialize = () => {
  472. injectStyles();
  473. addControlsToInput();
  474. addTTSToMessages();
  475. new MutationObserver(() => {
  476. addControlsToInput();
  477. addTTSToMessages();
  478. }).observe(document.documentElement, { childList: true, subtree: true });
  479. setTimeout(addTTSToMessages, 2000);
  480. };
  481.  
  482. document.readyState === 'loading'
  483. ? document.addEventListener('DOMContentLoaded', initialize)
  484. : initialize();
  485. })();