MessageInput.svelte 17 KB

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