MessageInput.svelte 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import { onMount, tick, getContext } from 'svelte';
  4. import { type Model, mobile, settings, showSidebar, models, config } from '$lib/stores';
  5. import { blobToFile, calculateSHA256, findWordIndices } from '$lib/utils';
  6. import {
  7. uploadDocToVectorDB,
  8. uploadWebToVectorDB,
  9. uploadYoutubeTranscriptionToVectorDB
  10. } from '$lib/apis/rag';
  11. import { SUPPORTED_FILE_TYPE, SUPPORTED_FILE_EXTENSIONS, WEBUI_BASE_URL } from '$lib/constants';
  12. import { transcribeAudio } from '$lib/apis/audio';
  13. import Prompts from './MessageInput/PromptCommands.svelte';
  14. import Suggestions from './MessageInput/Suggestions.svelte';
  15. import AddFilesPlaceholder from '../AddFilesPlaceholder.svelte';
  16. import Documents from './MessageInput/Documents.svelte';
  17. import Models from './MessageInput/Models.svelte';
  18. import Tooltip from '../common/Tooltip.svelte';
  19. import XMark from '$lib/components/icons/XMark.svelte';
  20. import InputMenu from './MessageInput/InputMenu.svelte';
  21. import { t } from 'i18next';
  22. const i18n = getContext('i18n');
  23. export let submitPrompt: Function;
  24. export let stopResponse: Function;
  25. export let autoScroll = true;
  26. export let atSelectedModel: Model | undefined;
  27. export let selectedModels: [''];
  28. let chatTextAreaElement: HTMLTextAreaElement;
  29. let filesInputElement;
  30. let promptsElement;
  31. let documentsElement;
  32. let modelsElement;
  33. let inputFiles;
  34. let dragged = false;
  35. let user = null;
  36. let chatInputPlaceholder = '';
  37. export let files = [];
  38. export let speechRecognitionEnabled = true;
  39. export let webSearchEnabled = false;
  40. export let prompt = '';
  41. export let messages = [];
  42. let speechRecognition;
  43. let visionCapableModels = [];
  44. $: visionCapableModels = [...(atSelectedModel ? [atSelectedModel] : selectedModels)].filter(
  45. (model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.vision ?? true
  46. );
  47. $: if (prompt) {
  48. if (chatTextAreaElement) {
  49. chatTextAreaElement.style.height = '';
  50. chatTextAreaElement.style.height = Math.min(chatTextAreaElement.scrollHeight, 200) + 'px';
  51. }
  52. }
  53. let mediaRecorder;
  54. let audioChunks = [];
  55. let isRecording = false;
  56. const MIN_DECIBELS = -45;
  57. const scrollToBottom = () => {
  58. const element = document.getElementById('messages-container');
  59. element.scrollTop = element.scrollHeight;
  60. };
  61. const startRecording = async () => {
  62. const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  63. mediaRecorder = new MediaRecorder(stream);
  64. mediaRecorder.onstart = () => {
  65. isRecording = true;
  66. console.log('Recording started');
  67. };
  68. mediaRecorder.ondataavailable = (event) => audioChunks.push(event.data);
  69. mediaRecorder.onstop = async () => {
  70. isRecording = false;
  71. console.log('Recording stopped');
  72. // Create a blob from the audio chunks
  73. const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
  74. const file = blobToFile(audioBlob, 'recording.wav');
  75. const res = await transcribeAudio(localStorage.token, file).catch((error) => {
  76. toast.error(error);
  77. return null;
  78. });
  79. if (res) {
  80. prompt = res.text;
  81. await tick();
  82. chatTextAreaElement?.focus();
  83. if (prompt !== '' && $settings?.speechAutoSend === true) {
  84. submitPrompt(prompt, user);
  85. }
  86. }
  87. // saveRecording(audioBlob);
  88. audioChunks = [];
  89. };
  90. // Start recording
  91. mediaRecorder.start();
  92. // Monitor silence
  93. monitorSilence(stream);
  94. };
  95. const monitorSilence = (stream) => {
  96. const audioContext = new AudioContext();
  97. const audioStreamSource = audioContext.createMediaStreamSource(stream);
  98. const analyser = audioContext.createAnalyser();
  99. analyser.minDecibels = MIN_DECIBELS;
  100. audioStreamSource.connect(analyser);
  101. const bufferLength = analyser.frequencyBinCount;
  102. const domainData = new Uint8Array(bufferLength);
  103. let lastSoundTime = Date.now();
  104. const detectSound = () => {
  105. analyser.getByteFrequencyData(domainData);
  106. if (domainData.some((value) => value > 0)) {
  107. lastSoundTime = Date.now();
  108. }
  109. if (isRecording && Date.now() - lastSoundTime > 3000) {
  110. mediaRecorder.stop();
  111. audioContext.close();
  112. return;
  113. }
  114. window.requestAnimationFrame(detectSound);
  115. };
  116. window.requestAnimationFrame(detectSound);
  117. };
  118. const saveRecording = (blob) => {
  119. const url = URL.createObjectURL(blob);
  120. const a = document.createElement('a');
  121. document.body.appendChild(a);
  122. a.style = 'display: none';
  123. a.href = url;
  124. a.download = 'recording.wav';
  125. a.click();
  126. window.URL.revokeObjectURL(url);
  127. };
  128. const speechRecognitionHandler = () => {
  129. // Check if SpeechRecognition is supported
  130. if (isRecording) {
  131. if (speechRecognition) {
  132. speechRecognition.stop();
  133. }
  134. if (mediaRecorder) {
  135. mediaRecorder.stop();
  136. }
  137. } else {
  138. isRecording = true;
  139. if ($settings?.audio?.STTEngine ?? '' !== '') {
  140. startRecording();
  141. } else {
  142. if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
  143. // Create a SpeechRecognition object
  144. speechRecognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
  145. // Set continuous to true for continuous recognition
  146. speechRecognition.continuous = true;
  147. // Set the timeout for turning off the recognition after inactivity (in milliseconds)
  148. const inactivityTimeout = 3000; // 3 seconds
  149. let timeoutId;
  150. // Start recognition
  151. speechRecognition.start();
  152. // Event triggered when speech is recognized
  153. speechRecognition.onresult = async (event) => {
  154. // Clear the inactivity timeout
  155. clearTimeout(timeoutId);
  156. // Handle recognized speech
  157. console.log(event);
  158. const transcript = event.results[Object.keys(event.results).length - 1][0].transcript;
  159. prompt = `${prompt}${transcript}`;
  160. await tick();
  161. chatTextAreaElement?.focus();
  162. // Restart the inactivity timeout
  163. timeoutId = setTimeout(() => {
  164. console.log('Speech recognition turned off due to inactivity.');
  165. speechRecognition.stop();
  166. }, inactivityTimeout);
  167. };
  168. // Event triggered when recognition is ended
  169. speechRecognition.onend = function () {
  170. // Restart recognition after it ends
  171. console.log('recognition ended');
  172. isRecording = false;
  173. if (prompt !== '' && $settings?.speechAutoSend === true) {
  174. submitPrompt(prompt, user);
  175. }
  176. };
  177. // Event triggered when an error occurs
  178. speechRecognition.onerror = function (event) {
  179. console.log(event);
  180. toast.error($i18n.t(`Speech recognition error: {{error}}`, { error: event.error }));
  181. isRecording = false;
  182. };
  183. } else {
  184. toast.error($i18n.t('SpeechRecognition API is not supported in this browser.'));
  185. }
  186. }
  187. }
  188. };
  189. const uploadDoc = async (file) => {
  190. console.log(file);
  191. const doc = {
  192. type: 'doc',
  193. name: file.name,
  194. collection_name: '',
  195. upload_status: false,
  196. error: ''
  197. };
  198. try {
  199. files = [...files, doc];
  200. if (['audio/mpeg', 'audio/wav'].includes(file['type'])) {
  201. const res = await transcribeAudio(localStorage.token, file).catch((error) => {
  202. toast.error(error);
  203. return null;
  204. });
  205. if (res) {
  206. console.log(res);
  207. const blob = new Blob([res.text], { type: 'text/plain' });
  208. file = blobToFile(blob, `${file.name}.txt`);
  209. }
  210. }
  211. const res = await uploadDocToVectorDB(localStorage.token, '', file);
  212. if (res) {
  213. doc.upload_status = true;
  214. doc.collection_name = res.collection_name;
  215. files = files;
  216. }
  217. } catch (e) {
  218. // Remove the failed doc from the files array
  219. files = files.filter((f) => f.name !== file.name);
  220. toast.error(e);
  221. }
  222. };
  223. const uploadWeb = async (url) => {
  224. console.log(url);
  225. const doc = {
  226. type: 'doc',
  227. name: url,
  228. collection_name: '',
  229. upload_status: false,
  230. url: url,
  231. error: ''
  232. };
  233. try {
  234. files = [...files, doc];
  235. const res = await uploadWebToVectorDB(localStorage.token, '', url);
  236. if (res) {
  237. doc.upload_status = true;
  238. doc.collection_name = res.collection_name;
  239. files = files;
  240. }
  241. } catch (e) {
  242. // Remove the failed doc from the files array
  243. files = files.filter((f) => f.name !== url);
  244. toast.error(e);
  245. }
  246. };
  247. const uploadYoutubeTranscription = async (url) => {
  248. console.log(url);
  249. const doc = {
  250. type: 'doc',
  251. name: url,
  252. collection_name: '',
  253. upload_status: false,
  254. url: url,
  255. error: ''
  256. };
  257. try {
  258. files = [...files, doc];
  259. const res = await uploadYoutubeTranscriptionToVectorDB(localStorage.token, url);
  260. if (res) {
  261. doc.upload_status = true;
  262. doc.collection_name = res.collection_name;
  263. files = files;
  264. }
  265. } catch (e) {
  266. // Remove the failed doc from the files array
  267. files = files.filter((f) => f.name !== url);
  268. toast.error(e);
  269. }
  270. };
  271. onMount(() => {
  272. window.setTimeout(() => chatTextAreaElement?.focus(), 0);
  273. const dropZone = document.querySelector('body');
  274. const handleKeyDown = (event: KeyboardEvent) => {
  275. if (event.key === 'Escape') {
  276. console.log('Escape');
  277. dragged = false;
  278. }
  279. };
  280. const onDragOver = (e) => {
  281. e.preventDefault();
  282. dragged = true;
  283. };
  284. const onDragLeave = () => {
  285. dragged = false;
  286. };
  287. const onDrop = async (e) => {
  288. e.preventDefault();
  289. console.log(e);
  290. if (e.dataTransfer?.files) {
  291. const inputFiles = Array.from(e.dataTransfer?.files);
  292. if (inputFiles && inputFiles.length > 0) {
  293. inputFiles.forEach((file) => {
  294. console.log(file, file.name.split('.').at(-1));
  295. if (['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type'])) {
  296. if (visionCapableModels.length === 0) {
  297. toast.error($i18n.t('Selected model(s) do not support image inputs'));
  298. return;
  299. }
  300. let reader = new FileReader();
  301. reader.onload = (event) => {
  302. files = [
  303. ...files,
  304. {
  305. type: 'image',
  306. url: `${event.target.result}`
  307. }
  308. ];
  309. };
  310. reader.readAsDataURL(file);
  311. } else if (
  312. SUPPORTED_FILE_TYPE.includes(file['type']) ||
  313. SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1))
  314. ) {
  315. uploadDoc(file);
  316. } else {
  317. toast.error(
  318. $i18n.t(
  319. `Unknown File Type '{{file_type}}', but accepting and treating as plain text`,
  320. { file_type: file['type'] }
  321. )
  322. );
  323. uploadDoc(file);
  324. }
  325. });
  326. } else {
  327. toast.error($i18n.t(`File not found.`));
  328. }
  329. }
  330. dragged = false;
  331. };
  332. window.addEventListener('keydown', handleKeyDown);
  333. dropZone?.addEventListener('dragover', onDragOver);
  334. dropZone?.addEventListener('drop', onDrop);
  335. dropZone?.addEventListener('dragleave', onDragLeave);
  336. return () => {
  337. window.removeEventListener('keydown', handleKeyDown);
  338. dropZone?.removeEventListener('dragover', onDragOver);
  339. dropZone?.removeEventListener('drop', onDrop);
  340. dropZone?.removeEventListener('dragleave', onDragLeave);
  341. };
  342. });
  343. </script>
  344. {#if dragged}
  345. <div
  346. class="fixed {$showSidebar
  347. ? 'left-0 md:left-[260px] md:w-[calc(100%-260px)]'
  348. : 'left-0'} w-full h-full flex z-50 touch-none pointer-events-none"
  349. id="dropzone"
  350. role="region"
  351. aria-label="Drag and Drop Container"
  352. >
  353. <div class="absolute w-full h-full backdrop-blur bg-gray-800/40 flex justify-center">
  354. <div class="m-auto pt-64 flex flex-col justify-center">
  355. <div class="max-w-md">
  356. <AddFilesPlaceholder />
  357. </div>
  358. </div>
  359. </div>
  360. </div>
  361. {/if}
  362. <div class="w-full">
  363. <div class=" -mb-0.5 mx-auto inset-x-0 bg-transparent flex justify-center">
  364. <div class="flex flex-col max-w-6xl px-2.5 md:px-6 w-full">
  365. <div class="relative">
  366. {#if autoScroll === false && messages.length > 0}
  367. <div class=" absolute -top-12 left-0 right-0 flex justify-center z-30">
  368. <button
  369. class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full"
  370. on:click={() => {
  371. autoScroll = true;
  372. scrollToBottom();
  373. }}
  374. >
  375. <svg
  376. xmlns="http://www.w3.org/2000/svg"
  377. viewBox="0 0 20 20"
  378. fill="currentColor"
  379. class="w-5 h-5"
  380. >
  381. <path
  382. fill-rule="evenodd"
  383. d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
  384. clip-rule="evenodd"
  385. />
  386. </svg>
  387. </button>
  388. </div>
  389. {/if}
  390. </div>
  391. <div class="w-full relative">
  392. {#if prompt.charAt(0) === '/'}
  393. <Prompts bind:this={promptsElement} bind:prompt />
  394. {:else if prompt.charAt(0) === '#'}
  395. <Documents
  396. bind:this={documentsElement}
  397. bind:prompt
  398. on:youtube={(e) => {
  399. console.log(e);
  400. uploadYoutubeTranscription(e.detail);
  401. }}
  402. on:url={(e) => {
  403. console.log(e);
  404. uploadWeb(e.detail);
  405. }}
  406. on:select={(e) => {
  407. console.log(e);
  408. files = [
  409. ...files,
  410. {
  411. type: e?.detail?.type ?? 'doc',
  412. ...e.detail,
  413. upload_status: true
  414. }
  415. ];
  416. }}
  417. />
  418. {/if}
  419. <Models
  420. bind:this={modelsElement}
  421. bind:prompt
  422. bind:user
  423. bind:chatInputPlaceholder
  424. {messages}
  425. on:select={(e) => {
  426. atSelectedModel = e.detail;
  427. chatTextAreaElement?.focus();
  428. }}
  429. />
  430. {#if atSelectedModel !== undefined}
  431. <div
  432. class="px-3 py-2.5 text-left w-full flex justify-between items-center absolute bottom-0 left-0 right-0 bg-gradient-to-t from-50% from-white dark:from-gray-900"
  433. >
  434. <div class="flex items-center gap-2 text-sm dark:text-gray-500">
  435. <img
  436. crossorigin="anonymous"
  437. alt="model profile"
  438. class="size-5 max-w-[28px] object-cover rounded-full"
  439. src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
  440. ?.profile_image_url ??
  441. ($i18n.language === 'dg-DG'
  442. ? `/doge.png`
  443. : `${WEBUI_BASE_URL}/static/favicon.png`)}
  444. />
  445. <div>
  446. Talking to <span class=" font-medium">{atSelectedModel.name}</span>
  447. </div>
  448. </div>
  449. <div>
  450. <button
  451. class="flex items-center"
  452. on:click={() => {
  453. atSelectedModel = undefined;
  454. }}
  455. >
  456. <XMark />
  457. </button>
  458. </div>
  459. </div>
  460. {/if}
  461. </div>
  462. </div>
  463. </div>
  464. <div class="bg-white dark:bg-gray-900">
  465. <div class="max-w-6xl px-2.5 md:px-6 mx-auto inset-x-0">
  466. <div class=" pb-2">
  467. <input
  468. bind:this={filesInputElement}
  469. bind:files={inputFiles}
  470. type="file"
  471. hidden
  472. multiple
  473. on:change={async () => {
  474. if (inputFiles && inputFiles.length > 0) {
  475. const _inputFiles = Array.from(inputFiles);
  476. _inputFiles.forEach((file) => {
  477. if (['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type'])) {
  478. if (visionCapableModels.length === 0) {
  479. toast.error($i18n.t('Selected model(s) do not support image inputs'));
  480. inputFiles = null;
  481. filesInputElement.value = '';
  482. return;
  483. }
  484. let reader = new FileReader();
  485. reader.onload = (event) => {
  486. files = [
  487. ...files,
  488. {
  489. type: 'image',
  490. url: `${event.target.result}`
  491. }
  492. ];
  493. inputFiles = null;
  494. filesInputElement.value = '';
  495. };
  496. reader.readAsDataURL(file);
  497. } else if (
  498. SUPPORTED_FILE_TYPE.includes(file['type']) ||
  499. SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1))
  500. ) {
  501. uploadDoc(file);
  502. filesInputElement.value = '';
  503. } else {
  504. toast.error(
  505. $i18n.t(
  506. `Unknown File Type '{{file_type}}', but accepting and treating as plain text`,
  507. { file_type: file['type'] }
  508. )
  509. );
  510. uploadDoc(file);
  511. filesInputElement.value = '';
  512. }
  513. });
  514. } else {
  515. toast.error($i18n.t(`File not found.`));
  516. }
  517. }}
  518. />
  519. <form
  520. dir={$settings?.chatDirection ?? 'LTR'}
  521. class=" flex flex-col relative w-full rounded-3xl px-1.5 bg-gray-50 dark:bg-gray-850 dark:text-gray-100"
  522. on:submit|preventDefault={() => {
  523. // check if selectedModels support image input
  524. submitPrompt(prompt, user);
  525. }}
  526. >
  527. {#if files.length > 0}
  528. <div class="mx-2 mt-2 mb-1 flex flex-wrap gap-2">
  529. {#each files as file, fileIdx}
  530. <div class=" relative group">
  531. {#if file.type === 'image'}
  532. <div class="relative">
  533. <img src={file.url} alt="input" class=" h-16 w-16 rounded-xl object-cover" />
  534. {#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
  535. <Tooltip
  536. className=" absolute top-1 left-1"
  537. content={$i18n.t('{{ models }}', {
  538. models: [...(atSelectedModel ? [atSelectedModel] : selectedModels)]
  539. .filter((id) => !visionCapableModels.includes(id))
  540. .join(', ')
  541. })}
  542. >
  543. <svg
  544. xmlns="http://www.w3.org/2000/svg"
  545. viewBox="0 0 24 24"
  546. fill="currentColor"
  547. class="size-4 fill-yellow-300"
  548. >
  549. <path
  550. fill-rule="evenodd"
  551. d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
  552. clip-rule="evenodd"
  553. />
  554. </svg>
  555. </Tooltip>
  556. {/if}
  557. </div>
  558. {:else if file.type === 'doc'}
  559. <div
  560. class="h-16 w-[15rem] flex items-center space-x-3 px-2.5 dark:bg-gray-600 rounded-xl border border-gray-200 dark:border-none"
  561. >
  562. <div class="p-2.5 bg-red-400 text-white rounded-lg">
  563. {#if file.upload_status}
  564. <svg
  565. xmlns="http://www.w3.org/2000/svg"
  566. viewBox="0 0 24 24"
  567. fill="currentColor"
  568. class="w-6 h-6"
  569. >
  570. <path
  571. fill-rule="evenodd"
  572. d="M5.625 1.5c-1.036 0-1.875.84-1.875 1.875v17.25c0 1.035.84 1.875 1.875 1.875h12.75c1.035 0 1.875-.84 1.875-1.875V12.75A3.75 3.75 0 0 0 16.5 9h-1.875a1.875 1.875 0 0 1-1.875-1.875V5.25A3.75 3.75 0 0 0 9 1.5H5.625ZM7.5 15a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-7.5A.75.75 0 0 1 7.5 15Zm.75 2.25a.75.75 0 0 0 0 1.5H12a.75.75 0 0 0 0-1.5H8.25Z"
  573. clip-rule="evenodd"
  574. />
  575. <path
  576. d="M12.971 1.816A5.23 5.23 0 0 1 14.25 5.25v1.875c0 .207.168.375.375.375H16.5a5.23 5.23 0 0 1 3.434 1.279 9.768 9.768 0 0 0-6.963-6.963Z"
  577. />
  578. </svg>
  579. {:else}
  580. <svg
  581. class=" w-6 h-6 translate-y-[0.5px]"
  582. fill="currentColor"
  583. viewBox="0 0 24 24"
  584. xmlns="http://www.w3.org/2000/svg"
  585. ><style>
  586. .spinner_qM83 {
  587. animation: spinner_8HQG 1.05s infinite;
  588. }
  589. .spinner_oXPr {
  590. animation-delay: 0.1s;
  591. }
  592. .spinner_ZTLf {
  593. animation-delay: 0.2s;
  594. }
  595. @keyframes spinner_8HQG {
  596. 0%,
  597. 57.14% {
  598. animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
  599. transform: translate(0);
  600. }
  601. 28.57% {
  602. animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
  603. transform: translateY(-6px);
  604. }
  605. 100% {
  606. transform: translate(0);
  607. }
  608. }
  609. </style><circle class="spinner_qM83" cx="4" cy="12" r="2.5" /><circle
  610. class="spinner_qM83 spinner_oXPr"
  611. cx="12"
  612. cy="12"
  613. r="2.5"
  614. /><circle
  615. class="spinner_qM83 spinner_ZTLf"
  616. cx="20"
  617. cy="12"
  618. r="2.5"
  619. /></svg
  620. >
  621. {/if}
  622. </div>
  623. <div class="flex flex-col justify-center -space-y-0.5">
  624. <div class=" dark:text-gray-100 text-sm font-medium line-clamp-1">
  625. {file.name}
  626. </div>
  627. <div class=" text-gray-500 text-sm">{$i18n.t('Document')}</div>
  628. </div>
  629. </div>
  630. {:else if file.type === 'collection'}
  631. <div
  632. class="h-16 w-[15rem] flex items-center space-x-3 px-2.5 dark:bg-gray-600 rounded-xl border border-gray-200 dark:border-none"
  633. >
  634. <div class="p-2.5 bg-red-400 text-white rounded-lg">
  635. <svg
  636. xmlns="http://www.w3.org/2000/svg"
  637. viewBox="0 0 24 24"
  638. fill="currentColor"
  639. class="w-6 h-6"
  640. >
  641. <path
  642. d="M7.5 3.375c0-1.036.84-1.875 1.875-1.875h.375a3.75 3.75 0 0 1 3.75 3.75v1.875C13.5 8.161 14.34 9 15.375 9h1.875A3.75 3.75 0 0 1 21 12.75v3.375C21 17.16 20.16 18 19.125 18h-9.75A1.875 1.875 0 0 1 7.5 16.125V3.375Z"
  643. />
  644. <path
  645. d="M15 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 17.25 7.5h-1.875A.375.375 0 0 1 15 7.125V5.25ZM4.875 6H6v10.125A3.375 3.375 0 0 0 9.375 19.5H16.5v1.125c0 1.035-.84 1.875-1.875 1.875h-9.75A1.875 1.875 0 0 1 3 20.625V7.875C3 6.839 3.84 6 4.875 6Z"
  646. />
  647. </svg>
  648. </div>
  649. <div class="flex flex-col justify-center -space-y-0.5">
  650. <div class=" dark:text-gray-100 text-sm font-medium line-clamp-1">
  651. {file?.title ?? `#${file.name}`}
  652. </div>
  653. <div class=" text-gray-500 text-sm">{$i18n.t('Collection')}</div>
  654. </div>
  655. </div>
  656. {/if}
  657. <div class=" absolute -top-1 -right-1">
  658. <button
  659. class=" bg-gray-400 text-white border border-white rounded-full group-hover:visible invisible transition"
  660. type="button"
  661. on:click={() => {
  662. files.splice(fileIdx, 1);
  663. files = files;
  664. }}
  665. >
  666. <svg
  667. xmlns="http://www.w3.org/2000/svg"
  668. viewBox="0 0 20 20"
  669. fill="currentColor"
  670. class="w-4 h-4"
  671. >
  672. <path
  673. d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
  674. />
  675. </svg>
  676. </button>
  677. </div>
  678. </div>
  679. {/each}
  680. </div>
  681. {/if}
  682. <div class=" flex">
  683. <div class=" ml-1 self-end mb-2 flex space-x-1">
  684. <InputMenu
  685. bind:webSearchEnabled
  686. uploadFilesHandler={() => {
  687. filesInputElement.click();
  688. }}
  689. onClose={async () => {
  690. await tick();
  691. chatTextAreaElement?.focus();
  692. }}
  693. >
  694. <button
  695. class="bg-gray-50 hover:bg-gray-100 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-none focus:outline-none"
  696. type="button"
  697. >
  698. <svg
  699. xmlns="http://www.w3.org/2000/svg"
  700. viewBox="0 0 16 16"
  701. fill="currentColor"
  702. class="size-5"
  703. >
  704. <path
  705. d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
  706. />
  707. </svg>
  708. </button>
  709. </InputMenu>
  710. </div>
  711. <textarea
  712. id="chat-textarea"
  713. bind:this={chatTextAreaElement}
  714. class="scrollbar-hidden bg-gray-50 dark:bg-gray-850 dark:text-gray-100 outline-none w-full py-3 px-3 rounded-xl resize-none h-[48px]"
  715. placeholder={chatInputPlaceholder !== ''
  716. ? chatInputPlaceholder
  717. : isRecording
  718. ? $i18n.t('Listening...')
  719. : $i18n.t('Send a Message')}
  720. bind:value={prompt}
  721. on:keypress={(e) => {
  722. if (
  723. !$mobile ||
  724. !(
  725. 'ontouchstart' in window ||
  726. navigator.maxTouchPoints > 0 ||
  727. navigator.msMaxTouchPoints > 0
  728. )
  729. ) {
  730. // Prevent Enter key from creating a new line
  731. if (e.key === 'Enter' && !e.shiftKey) {
  732. e.preventDefault();
  733. }
  734. // Submit the prompt when Enter key is pressed
  735. if (prompt !== '' && e.key === 'Enter' && !e.shiftKey) {
  736. submitPrompt(prompt, user);
  737. }
  738. }
  739. }}
  740. on:keydown={async (e) => {
  741. const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
  742. // Check if Ctrl + R is pressed
  743. if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
  744. e.preventDefault();
  745. console.log('regenerate');
  746. const regenerateButton = [
  747. ...document.getElementsByClassName('regenerate-response-button')
  748. ]?.at(-1);
  749. regenerateButton?.click();
  750. }
  751. if (prompt === '' && e.key == 'ArrowUp') {
  752. e.preventDefault();
  753. const userMessageElement = [
  754. ...document.getElementsByClassName('user-message')
  755. ]?.at(-1);
  756. const editButton = [
  757. ...document.getElementsByClassName('edit-user-message-button')
  758. ]?.at(-1);
  759. console.log(userMessageElement);
  760. userMessageElement.scrollIntoView({ block: 'center' });
  761. editButton?.click();
  762. }
  763. if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowUp') {
  764. e.preventDefault();
  765. (promptsElement || documentsElement || modelsElement).selectUp();
  766. const commandOptionButton = [
  767. ...document.getElementsByClassName('selected-command-option-button')
  768. ]?.at(-1);
  769. commandOptionButton.scrollIntoView({ block: 'center' });
  770. }
  771. if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowDown') {
  772. e.preventDefault();
  773. (promptsElement || documentsElement || modelsElement).selectDown();
  774. const commandOptionButton = [
  775. ...document.getElementsByClassName('selected-command-option-button')
  776. ]?.at(-1);
  777. commandOptionButton.scrollIntoView({ block: 'center' });
  778. }
  779. if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Enter') {
  780. e.preventDefault();
  781. const commandOptionButton = [
  782. ...document.getElementsByClassName('selected-command-option-button')
  783. ]?.at(-1);
  784. if (e.shiftKey) {
  785. prompt = `${prompt}\n`;
  786. } else if (commandOptionButton) {
  787. commandOptionButton?.click();
  788. } else {
  789. document.getElementById('send-message-button')?.click();
  790. }
  791. }
  792. if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Tab') {
  793. e.preventDefault();
  794. const commandOptionButton = [
  795. ...document.getElementsByClassName('selected-command-option-button')
  796. ]?.at(-1);
  797. commandOptionButton?.click();
  798. } else if (e.key === 'Tab') {
  799. const words = findWordIndices(prompt);
  800. if (words.length > 0) {
  801. const word = words.at(0);
  802. const fullPrompt = prompt;
  803. prompt = prompt.substring(0, word?.endIndex + 1);
  804. await tick();
  805. e.target.scrollTop = e.target.scrollHeight;
  806. prompt = fullPrompt;
  807. await tick();
  808. e.preventDefault();
  809. e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
  810. }
  811. e.target.style.height = '';
  812. e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
  813. }
  814. if (e.key === 'Escape') {
  815. console.log('Escape');
  816. atSelectedModel = undefined;
  817. }
  818. }}
  819. rows="1"
  820. on:input={(e) => {
  821. e.target.style.height = '';
  822. e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
  823. user = null;
  824. }}
  825. on:focus={(e) => {
  826. e.target.style.height = '';
  827. e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
  828. }}
  829. on:paste={(e) => {
  830. const clipboardData = e.clipboardData || window.clipboardData;
  831. if (clipboardData && clipboardData.items) {
  832. for (const item of clipboardData.items) {
  833. if (item.type.indexOf('image') !== -1) {
  834. const blob = item.getAsFile();
  835. const reader = new FileReader();
  836. reader.onload = function (e) {
  837. files = [
  838. ...files,
  839. {
  840. type: 'image',
  841. url: `${e.target.result}`
  842. }
  843. ];
  844. };
  845. reader.readAsDataURL(blob);
  846. }
  847. }
  848. }
  849. }}
  850. />
  851. <div class="self-end mb-2 flex space-x-1 mr-1">
  852. {#if messages.length == 0 || messages.at(-1).done == true}
  853. <Tooltip content={$i18n.t('Record voice')}>
  854. {#if speechRecognitionEnabled}
  855. <button
  856. id="voice-input-button"
  857. class=" text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 transition rounded-full p-1.5 mr-0.5 self-center"
  858. type="button"
  859. on:click={() => {
  860. speechRecognitionHandler();
  861. }}
  862. >
  863. {#if isRecording}
  864. <svg
  865. class=" w-5 h-5 translate-y-[0.5px]"
  866. fill="currentColor"
  867. viewBox="0 0 24 24"
  868. xmlns="http://www.w3.org/2000/svg"
  869. ><style>
  870. .spinner_qM83 {
  871. animation: spinner_8HQG 1.05s infinite;
  872. }
  873. .spinner_oXPr {
  874. animation-delay: 0.1s;
  875. }
  876. .spinner_ZTLf {
  877. animation-delay: 0.2s;
  878. }
  879. @keyframes spinner_8HQG {
  880. 0%,
  881. 57.14% {
  882. animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
  883. transform: translate(0);
  884. }
  885. 28.57% {
  886. animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
  887. transform: translateY(-6px);
  888. }
  889. 100% {
  890. transform: translate(0);
  891. }
  892. }
  893. </style><circle class="spinner_qM83" cx="4" cy="12" r="2.5" /><circle
  894. class="spinner_qM83 spinner_oXPr"
  895. cx="12"
  896. cy="12"
  897. r="2.5"
  898. /><circle
  899. class="spinner_qM83 spinner_ZTLf"
  900. cx="20"
  901. cy="12"
  902. r="2.5"
  903. /></svg
  904. >
  905. {:else}
  906. <svg
  907. xmlns="http://www.w3.org/2000/svg"
  908. viewBox="0 0 20 20"
  909. fill="currentColor"
  910. class="w-5 h-5 translate-y-[0.5px]"
  911. >
  912. <path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
  913. <path
  914. d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
  915. />
  916. </svg>
  917. {/if}
  918. </button>
  919. {/if}
  920. </Tooltip>
  921. <Tooltip content={$i18n.t('Send message')}>
  922. <button
  923. id="send-message-button"
  924. class="{prompt !== ''
  925. ? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
  926. : 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
  927. type="submit"
  928. disabled={prompt === ''}
  929. >
  930. <svg
  931. xmlns="http://www.w3.org/2000/svg"
  932. viewBox="0 0 16 16"
  933. fill="currentColor"
  934. class="w-5 h-5"
  935. >
  936. <path
  937. fill-rule="evenodd"
  938. d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
  939. clip-rule="evenodd"
  940. />
  941. </svg>
  942. </button>
  943. </Tooltip>
  944. {:else}
  945. <button
  946. class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
  947. on:click={stopResponse}
  948. >
  949. <svg
  950. xmlns="http://www.w3.org/2000/svg"
  951. viewBox="0 0 24 24"
  952. fill="currentColor"
  953. class="w-5 h-5"
  954. >
  955. <path
  956. fill-rule="evenodd"
  957. d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
  958. clip-rule="evenodd"
  959. />
  960. </svg>
  961. </button>
  962. {/if}
  963. </div>
  964. </div>
  965. </form>
  966. <div class="mt-1.5 text-xs text-gray-500 text-center">
  967. {$i18n.t('LLMs can make mistakes. Verify important information.')}
  968. </div>
  969. </div>
  970. </div>
  971. </div>
  972. </div>
  973. <style>
  974. .scrollbar-hidden:active::-webkit-scrollbar-thumb,
  975. .scrollbar-hidden:focus::-webkit-scrollbar-thumb,
  976. .scrollbar-hidden:hover::-webkit-scrollbar-thumb {
  977. visibility: visible;
  978. }
  979. .scrollbar-hidden::-webkit-scrollbar-thumb {
  980. visibility: hidden;
  981. }
  982. </style>