MessageInput.svelte 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import { v4 as uuidv4 } from 'uuid';
  4. import { tick, getContext, onMount, onDestroy } from 'svelte';
  5. const i18n = getContext('i18n');
  6. import { config, mobile, settings, socket } from '$lib/stores';
  7. import { blobToFile, compressImage } from '$lib/utils';
  8. import Tooltip from '../common/Tooltip.svelte';
  9. import RichTextInput from '../common/RichTextInput.svelte';
  10. import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
  11. import InputMenu from './MessageInput/InputMenu.svelte';
  12. import { uploadFile } from '$lib/apis/files';
  13. import { WEBUI_API_BASE_URL } from '$lib/constants';
  14. import FileItem from '../common/FileItem.svelte';
  15. import Image from '../common/Image.svelte';
  16. import { transcribeAudio } from '$lib/apis/audio';
  17. import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
  18. export let placeholder = $i18n.t('Send a Message');
  19. export let transparentBackground = false;
  20. let draggedOver = false;
  21. let recording = false;
  22. let content = '';
  23. let files = [];
  24. let filesInputElement;
  25. let inputFiles;
  26. export let typingUsers = [];
  27. export let onSubmit: Function;
  28. export let onChange: Function;
  29. export let scrollEnd = true;
  30. export let scrollToBottom: Function;
  31. const screenCaptureHandler = async () => {
  32. try {
  33. // Request screen media
  34. const mediaStream = await navigator.mediaDevices.getDisplayMedia({
  35. video: { cursor: 'never' },
  36. audio: false
  37. });
  38. // Once the user selects a screen, temporarily create a video element
  39. const video = document.createElement('video');
  40. video.srcObject = mediaStream;
  41. // Ensure the video loads without affecting user experience or tab switching
  42. await video.play();
  43. // Set up the canvas to match the video dimensions
  44. const canvas = document.createElement('canvas');
  45. canvas.width = video.videoWidth;
  46. canvas.height = video.videoHeight;
  47. // Grab a single frame from the video stream using the canvas
  48. const context = canvas.getContext('2d');
  49. context.drawImage(video, 0, 0, canvas.width, canvas.height);
  50. // Stop all video tracks (stop screen sharing) after capturing the image
  51. mediaStream.getTracks().forEach((track) => track.stop());
  52. // bring back focus to this current tab, so that the user can see the screen capture
  53. window.focus();
  54. // Convert the canvas to a Base64 image URL
  55. const imageUrl = canvas.toDataURL('image/png');
  56. // Add the captured image to the files array to render it
  57. files = [...files, { type: 'image', url: imageUrl }];
  58. // Clean memory: Clear video srcObject
  59. video.srcObject = null;
  60. } catch (error) {
  61. // Handle any errors (e.g., user cancels screen sharing)
  62. console.error('Error capturing screen:', error);
  63. }
  64. };
  65. const inputFilesHandler = async (inputFiles) => {
  66. inputFiles.forEach((file) => {
  67. console.log('Processing file:', {
  68. name: file.name,
  69. type: file.type,
  70. size: file.size,
  71. extension: file.name.split('.').at(-1)
  72. });
  73. if (
  74. ($config?.file?.max_size ?? null) !== null &&
  75. file.size > ($config?.file?.max_size ?? 0) * 1024 * 1024
  76. ) {
  77. console.log('File exceeds max size limit:', {
  78. fileSize: file.size,
  79. maxSize: ($config?.file?.max_size ?? 0) * 1024 * 1024
  80. });
  81. toast.error(
  82. $i18n.t(`File size should not exceed {{maxSize}} MB.`, {
  83. maxSize: $config?.file?.max_size
  84. })
  85. );
  86. return;
  87. }
  88. if (['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type'])) {
  89. let reader = new FileReader();
  90. reader.onload = async (event) => {
  91. let imageUrl = event.target.result;
  92. if ($settings?.imageCompression ?? false) {
  93. const width = $settings?.imageCompressionSize?.width ?? null;
  94. const height = $settings?.imageCompressionSize?.height ?? null;
  95. if (width || height) {
  96. imageUrl = await compressImage(imageUrl, width, height);
  97. }
  98. }
  99. files = [
  100. ...files,
  101. {
  102. type: 'image',
  103. url: `${imageUrl}`
  104. }
  105. ];
  106. };
  107. reader.readAsDataURL(file);
  108. } else {
  109. uploadFileHandler(file);
  110. }
  111. });
  112. };
  113. const uploadFileHandler = async (file) => {
  114. const tempItemId = uuidv4();
  115. const fileItem = {
  116. type: 'file',
  117. file: '',
  118. id: null,
  119. url: '',
  120. name: file.name,
  121. collection_name: '',
  122. status: 'uploading',
  123. size: file.size,
  124. error: '',
  125. itemId: tempItemId
  126. };
  127. if (fileItem.size == 0) {
  128. toast.error($i18n.t('You cannot upload an empty file.'));
  129. return null;
  130. }
  131. files = [...files, fileItem];
  132. // Check if the file is an audio file and transcribe/convert it to text file
  133. if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) {
  134. const res = await transcribeAudio(localStorage.token, file).catch((error) => {
  135. toast.error(error);
  136. return null;
  137. });
  138. if (res) {
  139. console.log(res);
  140. const blob = new Blob([res.text], { type: 'text/plain' });
  141. file = blobToFile(blob, `${file.name}.txt`);
  142. fileItem.name = file.name;
  143. fileItem.size = file.size;
  144. }
  145. }
  146. try {
  147. // During the file upload, file content is automatically extracted.
  148. const uploadedFile = await uploadFile(localStorage.token, file);
  149. if (uploadedFile) {
  150. console.log('File upload completed:', {
  151. id: uploadedFile.id,
  152. name: fileItem.name,
  153. collection: uploadedFile?.meta?.collection_name
  154. });
  155. if (uploadedFile.error) {
  156. console.warn('File upload warning:', uploadedFile.error);
  157. toast.warning(uploadedFile.error);
  158. }
  159. fileItem.status = 'uploaded';
  160. fileItem.file = uploadedFile;
  161. fileItem.id = uploadedFile.id;
  162. fileItem.collection_name =
  163. uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
  164. fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
  165. files = files;
  166. } else {
  167. files = files.filter((item) => item?.itemId !== tempItemId);
  168. }
  169. } catch (e) {
  170. toast.error(e);
  171. files = files.filter((item) => item?.itemId !== tempItemId);
  172. }
  173. };
  174. const handleKeyDown = (event: KeyboardEvent) => {
  175. if (event.key === 'Escape') {
  176. console.log('Escape');
  177. draggedOver = false;
  178. }
  179. };
  180. const onDragOver = (e) => {
  181. e.preventDefault();
  182. // Check if a file is being draggedOver.
  183. if (e.dataTransfer?.types?.includes('Files')) {
  184. draggedOver = true;
  185. } else {
  186. draggedOver = false;
  187. }
  188. };
  189. const onDragLeave = () => {
  190. draggedOver = false;
  191. };
  192. const onDrop = async (e) => {
  193. e.preventDefault();
  194. console.log(e);
  195. if (e.dataTransfer?.files) {
  196. const inputFiles = Array.from(e.dataTransfer?.files);
  197. if (inputFiles && inputFiles.length > 0) {
  198. console.log(inputFiles);
  199. inputFilesHandler(inputFiles);
  200. }
  201. }
  202. draggedOver = false;
  203. };
  204. const submitHandler = async () => {
  205. if (content === '') {
  206. return;
  207. }
  208. onSubmit({
  209. content,
  210. data: {
  211. files: files
  212. }
  213. });
  214. content = '';
  215. files = [];
  216. await tick();
  217. const chatInputElement = document.getElementById('chat-input');
  218. chatInputElement?.focus();
  219. };
  220. $: if (content) {
  221. onChange();
  222. }
  223. onMount(async () => {
  224. window.setTimeout(() => {
  225. const chatInput = document.getElementById('chat-input');
  226. chatInput?.focus();
  227. }, 0);
  228. window.addEventListener('keydown', handleKeyDown);
  229. await tick();
  230. const dropzoneElement = document.getElementById('channel-container');
  231. dropzoneElement?.addEventListener('dragover', onDragOver);
  232. dropzoneElement?.addEventListener('drop', onDrop);
  233. dropzoneElement?.addEventListener('dragleave', onDragLeave);
  234. });
  235. onDestroy(() => {
  236. console.log('destroy');
  237. window.removeEventListener('keydown', handleKeyDown);
  238. const dropzoneElement = document.getElementById('channel-container');
  239. if (dropzoneElement) {
  240. dropzoneElement?.removeEventListener('dragover', onDragOver);
  241. dropzoneElement?.removeEventListener('drop', onDrop);
  242. dropzoneElement?.removeEventListener('dragleave', onDragLeave);
  243. }
  244. });
  245. </script>
  246. <FilesOverlay show={draggedOver} />
  247. <input
  248. bind:this={filesInputElement}
  249. bind:files={inputFiles}
  250. type="file"
  251. hidden
  252. multiple
  253. on:change={async () => {
  254. if (inputFiles && inputFiles.length > 0) {
  255. inputFilesHandler(Array.from(inputFiles));
  256. } else {
  257. toast.error($i18n.t(`File not found.`));
  258. }
  259. filesInputElement.value = '';
  260. }}
  261. />
  262. <div class="{transparentBackground ? 'bg-transparent' : 'bg-white dark:bg-gray-900'} ">
  263. <div
  264. class="{($settings?.widescreenMode ?? null)
  265. ? 'max-w-full'
  266. : 'max-w-6xl'} px-2.5 mx-auto inset-x-0 relative"
  267. >
  268. <div class="absolute top-0 left-0 right-0 mx-auto inset-x-0 bg-transparent flex justify-center">
  269. <div class="flex flex-col px-3 w-full">
  270. <div class="relative">
  271. {#if scrollEnd === false}
  272. <div
  273. class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none"
  274. >
  275. <button
  276. class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full pointer-events-auto"
  277. on:click={() => {
  278. scrollEnd = true;
  279. scrollToBottom();
  280. }}
  281. >
  282. <svg
  283. xmlns="http://www.w3.org/2000/svg"
  284. viewBox="0 0 20 20"
  285. fill="currentColor"
  286. class="w-5 h-5"
  287. >
  288. <path
  289. fill-rule="evenodd"
  290. 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"
  291. clip-rule="evenodd"
  292. />
  293. </svg>
  294. </button>
  295. </div>
  296. {/if}
  297. </div>
  298. <div class="relative">
  299. <div class=" -mt-5 bg-gradient-to-t from-white dark:from-gray-900">
  300. {#if typingUsers.length > 0}
  301. <div class=" text-xs px-4 mb-1">
  302. <span class=" font-medium text-black dark:text-white">
  303. {typingUsers.map((user) => user.name).join(', ')}
  304. </span>
  305. {$i18n.t('is typing...')}
  306. </div>
  307. {/if}
  308. </div>
  309. </div>
  310. </div>
  311. </div>
  312. <div class="">
  313. {#if recording}
  314. <VoiceRecording
  315. bind:recording
  316. on:cancel={async () => {
  317. recording = false;
  318. await tick();
  319. document.getElementById('chat-input')?.focus();
  320. }}
  321. on:confirm={async (e) => {
  322. const { text, filename } = e.detail;
  323. content = `${content}${text} `;
  324. recording = false;
  325. await tick();
  326. document.getElementById('chat-input')?.focus();
  327. }}
  328. />
  329. {:else}
  330. <form
  331. class="w-full flex gap-1.5"
  332. on:submit|preventDefault={() => {
  333. submitHandler();
  334. }}
  335. >
  336. <div
  337. class="flex-1 flex flex-col relative w-full rounded-3xl px-1 bg-gray-50 dark:bg-gray-400/5 dark:text-gray-100"
  338. dir={$settings?.chatDirection ?? 'LTR'}
  339. >
  340. {#if files.length > 0}
  341. <div class="mx-1 mt-2.5 mb-1 flex flex-wrap gap-2">
  342. {#each files as file, fileIdx}
  343. {#if file.type === 'image'}
  344. <div class=" relative group">
  345. <div class="relative">
  346. <Image
  347. src={file.url}
  348. alt="input"
  349. imageClassName=" h-16 w-16 rounded-xl object-cover"
  350. />
  351. </div>
  352. <div class=" absolute -top-1 -right-1">
  353. <button
  354. class=" bg-gray-400 text-white border border-white rounded-full group-hover:visible invisible transition"
  355. type="button"
  356. on:click={() => {
  357. files.splice(fileIdx, 1);
  358. files = files;
  359. }}
  360. >
  361. <svg
  362. xmlns="http://www.w3.org/2000/svg"
  363. viewBox="0 0 20 20"
  364. fill="currentColor"
  365. class="w-4 h-4"
  366. >
  367. <path
  368. 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"
  369. />
  370. </svg>
  371. </button>
  372. </div>
  373. </div>
  374. {:else}
  375. <FileItem
  376. item={file}
  377. name={file.name}
  378. type={file.type}
  379. size={file?.size}
  380. loading={file.status === 'uploading'}
  381. dismissible={true}
  382. edit={true}
  383. on:dismiss={() => {
  384. files.splice(fileIdx, 1);
  385. files = files;
  386. }}
  387. on:click={() => {
  388. console.log(file);
  389. }}
  390. />
  391. {/if}
  392. {/each}
  393. </div>
  394. {/if}
  395. <div class=" flex">
  396. <div class="ml-1 self-end mb-1.5 flex space-x-1">
  397. <InputMenu
  398. {screenCaptureHandler}
  399. uploadFilesHandler={() => {
  400. filesInputElement.click();
  401. }}
  402. >
  403. <button
  404. class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-2 outline-none focus:outline-none"
  405. type="button"
  406. aria-label="More"
  407. >
  408. <svg
  409. xmlns="http://www.w3.org/2000/svg"
  410. viewBox="0 0 20 20"
  411. fill="currentColor"
  412. class="size-5"
  413. >
  414. <path
  415. d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
  416. />
  417. </svg>
  418. </button>
  419. </InputMenu>
  420. </div>
  421. {#if $settings?.richTextInput ?? true}
  422. <div
  423. class="scrollbar-hidden text-left bg-transparent dark:text-gray-100 outline-none w-full py-2.5 px-1 rounded-xl resize-none h-fit max-h-80 overflow-auto"
  424. >
  425. <RichTextInput
  426. bind:value={content}
  427. id="chat-input"
  428. messageInput={true}
  429. shiftEnter={!$mobile ||
  430. !(
  431. 'ontouchstart' in window ||
  432. navigator.maxTouchPoints > 0 ||
  433. navigator.msMaxTouchPoints > 0
  434. )}
  435. {placeholder}
  436. largeTextAsFile={$settings?.largeTextAsFile ?? false}
  437. on:keydown={async (e) => {
  438. e = e.detail.event;
  439. const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
  440. if (
  441. !$mobile ||
  442. !(
  443. 'ontouchstart' in window ||
  444. navigator.maxTouchPoints > 0 ||
  445. navigator.msMaxTouchPoints > 0
  446. )
  447. ) {
  448. // Prevent Enter key from creating a new line
  449. // Uses keyCode '13' for Enter key for chinese/japanese keyboards
  450. if (e.keyCode === 13 && !e.shiftKey) {
  451. e.preventDefault();
  452. }
  453. // Submit the content when Enter key is pressed
  454. if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
  455. submitHandler();
  456. }
  457. }
  458. if (e.key === 'Escape') {
  459. console.log('Escape');
  460. }
  461. }}
  462. on:paste={async (e) => {
  463. e = e.detail.event;
  464. console.log(e);
  465. }}
  466. />
  467. </div>
  468. {:else}
  469. <textarea
  470. id="chat-input"
  471. class="scrollbar-hidden bg-transparent dark:text-gray-100 outline-none w-full py-3 px-1 rounded-xl resize-none h-[48px]"
  472. {placeholder}
  473. bind:value={content}
  474. on:keydown={async (e) => {
  475. e = e.detail.event;
  476. const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
  477. if (
  478. !$mobile ||
  479. !(
  480. 'ontouchstart' in window ||
  481. navigator.maxTouchPoints > 0 ||
  482. navigator.msMaxTouchPoints > 0
  483. )
  484. ) {
  485. // Prevent Enter key from creating a new line
  486. // Uses keyCode '13' for Enter key for chinese/japanese keyboards
  487. if (e.keyCode === 13 && !e.shiftKey) {
  488. e.preventDefault();
  489. }
  490. // Submit the content when Enter key is pressed
  491. if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
  492. submitHandler();
  493. }
  494. }
  495. if (e.key === 'Escape') {
  496. console.log('Escape');
  497. }
  498. }}
  499. rows="1"
  500. on:input={async (e) => {
  501. e.target.style.height = '';
  502. e.target.style.height = Math.min(e.target.scrollHeight, 320) + 'px';
  503. }}
  504. on:focus={async (e) => {
  505. e.target.style.height = '';
  506. e.target.style.height = Math.min(e.target.scrollHeight, 320) + 'px';
  507. }}
  508. />
  509. {/if}
  510. <div class="self-end mb-1.5 flex space-x-1 mr-1">
  511. {#if content === ''}
  512. <Tooltip content={$i18n.t('Record voice')}>
  513. <button
  514. id="voice-input-button"
  515. class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 mr-0.5 self-center"
  516. type="button"
  517. on:click={async () => {
  518. try {
  519. let stream = await navigator.mediaDevices
  520. .getUserMedia({ audio: true })
  521. .catch(function (err) {
  522. toast.error(
  523. $i18n.t(`Permission denied when accessing microphone: {{error}}`, {
  524. error: err
  525. })
  526. );
  527. return null;
  528. });
  529. if (stream) {
  530. recording = true;
  531. const tracks = stream.getTracks();
  532. tracks.forEach((track) => track.stop());
  533. }
  534. stream = null;
  535. } catch {
  536. toast.error($i18n.t('Permission denied when accessing microphone'));
  537. }
  538. }}
  539. aria-label="Voice Input"
  540. >
  541. <svg
  542. xmlns="http://www.w3.org/2000/svg"
  543. viewBox="0 0 20 20"
  544. fill="currentColor"
  545. class="w-5 h-5 translate-y-[0.5px]"
  546. >
  547. <path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
  548. <path
  549. 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"
  550. />
  551. </svg>
  552. </button>
  553. </Tooltip>
  554. {/if}
  555. <div class=" flex items-center">
  556. <div class=" flex items-center">
  557. <Tooltip content={$i18n.t('Send message')}>
  558. <button
  559. id="send-message-button"
  560. class="{content !== ''
  561. ? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
  562. : 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
  563. type="submit"
  564. disabled={content === ''}
  565. >
  566. <svg
  567. xmlns="http://www.w3.org/2000/svg"
  568. viewBox="0 0 16 16"
  569. fill="currentColor"
  570. class="size-6"
  571. >
  572. <path
  573. fill-rule="evenodd"
  574. 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"
  575. clip-rule="evenodd"
  576. />
  577. </svg>
  578. </button>
  579. </Tooltip>
  580. </div>
  581. </div>
  582. </div>
  583. </div>
  584. </div>
  585. </form>
  586. {/if}
  587. </div>
  588. </div>
  589. </div>