MessageInput.svelte 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  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 (['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type'])) {
  90. let reader = new FileReader();
  91. reader.onload = async (event) => {
  92. let imageUrl = event.target.result;
  93. if ($settings?.imageCompression ?? false) {
  94. const width = $settings?.imageCompressionSize?.width ?? null;
  95. const height = $settings?.imageCompressionSize?.height ?? null;
  96. if (width || height) {
  97. imageUrl = await compressImage(imageUrl, width, height);
  98. }
  99. }
  100. files = [
  101. ...files,
  102. {
  103. type: 'image',
  104. url: `${imageUrl}`
  105. }
  106. ];
  107. };
  108. reader.readAsDataURL(file);
  109. } else {
  110. uploadFileHandler(file);
  111. }
  112. });
  113. };
  114. const uploadFileHandler = async (file) => {
  115. const tempItemId = uuidv4();
  116. const fileItem = {
  117. type: 'file',
  118. file: '',
  119. id: null,
  120. url: '',
  121. name: file.name,
  122. collection_name: '',
  123. status: 'uploading',
  124. size: file.size,
  125. error: '',
  126. itemId: tempItemId
  127. };
  128. if (fileItem.size == 0) {
  129. toast.error($i18n.t('You cannot upload an empty file.'));
  130. return null;
  131. }
  132. files = [...files, fileItem];
  133. // Check if the file is an audio file and transcribe/convert it to text file
  134. if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) {
  135. const res = await transcribeAudio(localStorage.token, file).catch((error) => {
  136. toast.error(`${error}`);
  137. return null;
  138. });
  139. if (res) {
  140. console.log(res);
  141. const blob = new Blob([res.text], { type: 'text/plain' });
  142. file = blobToFile(blob, `${file.name}.txt`);
  143. fileItem.name = file.name;
  144. fileItem.size = file.size;
  145. }
  146. }
  147. try {
  148. // During the file upload, file content is automatically extracted.
  149. const uploadedFile = await uploadFile(localStorage.token, file);
  150. if (uploadedFile) {
  151. console.log('File upload completed:', {
  152. id: uploadedFile.id,
  153. name: fileItem.name,
  154. collection: uploadedFile?.meta?.collection_name
  155. });
  156. if (uploadedFile.error) {
  157. console.warn('File upload warning:', uploadedFile.error);
  158. toast.warning(uploadedFile.error);
  159. }
  160. fileItem.status = 'uploaded';
  161. fileItem.file = uploadedFile;
  162. fileItem.id = uploadedFile.id;
  163. fileItem.collection_name =
  164. uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
  165. fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
  166. files = files;
  167. } else {
  168. files = files.filter((item) => item?.itemId !== tempItemId);
  169. }
  170. } catch (e) {
  171. toast.error(`${e}`);
  172. files = files.filter((item) => item?.itemId !== tempItemId);
  173. }
  174. };
  175. const handleKeyDown = (event: KeyboardEvent) => {
  176. if (event.key === 'Escape') {
  177. console.log('Escape');
  178. draggedOver = false;
  179. }
  180. };
  181. const onDragOver = (e) => {
  182. e.preventDefault();
  183. // Check if a file is being draggedOver.
  184. if (e.dataTransfer?.types?.includes('Files')) {
  185. draggedOver = true;
  186. } else {
  187. draggedOver = false;
  188. }
  189. };
  190. const onDragLeave = () => {
  191. draggedOver = false;
  192. };
  193. const onDrop = async (e) => {
  194. e.preventDefault();
  195. console.log(e);
  196. if (e.dataTransfer?.files) {
  197. const inputFiles = Array.from(e.dataTransfer?.files);
  198. if (inputFiles && inputFiles.length > 0) {
  199. console.log(inputFiles);
  200. inputFilesHandler(inputFiles);
  201. }
  202. }
  203. draggedOver = false;
  204. };
  205. const submitHandler = async () => {
  206. if (content === '' && files.length === 0) {
  207. return;
  208. }
  209. onSubmit({
  210. content,
  211. data: {
  212. files: files
  213. }
  214. });
  215. content = '';
  216. files = [];
  217. await tick();
  218. const chatInputElement = document.getElementById(`chat-input-${id}`);
  219. chatInputElement?.focus();
  220. };
  221. $: if (content) {
  222. onChange();
  223. }
  224. onMount(async () => {
  225. window.setTimeout(() => {
  226. const chatInput = document.getElementById(`chat-input-${id}`);
  227. chatInput?.focus();
  228. }, 0);
  229. window.addEventListener('keydown', handleKeyDown);
  230. await tick();
  231. const dropzoneElement = document.getElementById('channel-container');
  232. dropzoneElement?.addEventListener('dragover', onDragOver);
  233. dropzoneElement?.addEventListener('drop', onDrop);
  234. dropzoneElement?.addEventListener('dragleave', onDragLeave);
  235. });
  236. onDestroy(() => {
  237. console.log('destroy');
  238. window.removeEventListener('keydown', handleKeyDown);
  239. const dropzoneElement = document.getElementById('channel-container');
  240. if (dropzoneElement) {
  241. dropzoneElement?.removeEventListener('dragover', onDragOver);
  242. dropzoneElement?.removeEventListener('drop', onDrop);
  243. dropzoneElement?.removeEventListener('dragleave', onDragLeave);
  244. }
  245. });
  246. </script>
  247. <FilesOverlay show={draggedOver} />
  248. <input
  249. bind:this={filesInputElement}
  250. bind:files={inputFiles}
  251. type="file"
  252. hidden
  253. multiple
  254. on:change={async () => {
  255. if (inputFiles && inputFiles.length > 0) {
  256. inputFilesHandler(Array.from(inputFiles));
  257. } else {
  258. toast.error($i18n.t(`File not found.`));
  259. }
  260. filesInputElement.value = '';
  261. }}
  262. />
  263. <div class="bg-transparent">
  264. <div
  265. class="{($settings?.widescreenMode ?? null)
  266. ? 'max-w-full'
  267. : 'max-w-6xl'} px-2.5 mx-auto inset-x-0 relative"
  268. >
  269. <div class="absolute top-0 left-0 right-0 mx-auto inset-x-0 bg-transparent flex justify-center">
  270. <div class="flex flex-col px-3 w-full">
  271. <div class="relative">
  272. {#if scrollEnd === false}
  273. <div
  274. class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none"
  275. >
  276. <button
  277. class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full pointer-events-auto"
  278. on:click={() => {
  279. scrollEnd = true;
  280. scrollToBottom();
  281. }}
  282. >
  283. <svg
  284. xmlns="http://www.w3.org/2000/svg"
  285. viewBox="0 0 20 20"
  286. fill="currentColor"
  287. class="w-5 h-5"
  288. >
  289. <path
  290. fill-rule="evenodd"
  291. 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"
  292. clip-rule="evenodd"
  293. />
  294. </svg>
  295. </button>
  296. </div>
  297. {/if}
  298. </div>
  299. <div class="relative">
  300. <div class=" -mt-5">
  301. {#if typingUsers.length > 0}
  302. <div class=" text-xs px-4 mb-1">
  303. <span class=" font-normal text-black dark:text-white">
  304. {typingUsers.map((user) => user.name).join(', ')}
  305. </span>
  306. {$i18n.t('is typing...')}
  307. </div>
  308. {/if}
  309. </div>
  310. </div>
  311. </div>
  312. </div>
  313. <div class="">
  314. {#if recording}
  315. <VoiceRecording
  316. bind:recording
  317. on:cancel={async () => {
  318. recording = false;
  319. await tick();
  320. document.getElementById(`chat-input-${id}`)?.focus();
  321. }}
  322. on:confirm={async (e) => {
  323. const { text, filename } = e.detail;
  324. content = `${content}${text} `;
  325. recording = false;
  326. await tick();
  327. document.getElementById(`chat-input-${id}`)?.focus();
  328. }}
  329. />
  330. {:else}
  331. <form
  332. class="w-full flex gap-1.5"
  333. on:submit|preventDefault={() => {
  334. submitHandler();
  335. }}
  336. >
  337. <div
  338. 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"
  339. dir={$settings?.chatDirection ?? 'LTR'}
  340. >
  341. {#if files.length > 0}
  342. <div class="mx-1 mt-2.5 mb-1 flex flex-wrap gap-2">
  343. {#each files as file, fileIdx}
  344. {#if file.type === 'image'}
  345. <div class=" relative group">
  346. <div class="relative">
  347. <Image
  348. src={file.url}
  349. alt="input"
  350. imageClassName=" h-16 w-16 rounded-xl object-cover"
  351. />
  352. </div>
  353. <div class=" absolute -top-1 -right-1">
  354. <button
  355. class=" bg-gray-400 text-white border border-white rounded-full group-hover:visible invisible transition"
  356. type="button"
  357. on:click={() => {
  358. files.splice(fileIdx, 1);
  359. files = files;
  360. }}
  361. >
  362. <svg
  363. xmlns="http://www.w3.org/2000/svg"
  364. viewBox="0 0 20 20"
  365. fill="currentColor"
  366. class="w-4 h-4"
  367. >
  368. <path
  369. 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"
  370. />
  371. </svg>
  372. </button>
  373. </div>
  374. </div>
  375. {:else}
  376. <FileItem
  377. item={file}
  378. name={file.name}
  379. type={file.type}
  380. size={file?.size}
  381. loading={file.status === 'uploading'}
  382. dismissible={true}
  383. edit={true}
  384. on:dismiss={() => {
  385. files.splice(fileIdx, 1);
  386. files = files;
  387. }}
  388. on:click={() => {
  389. console.log(file);
  390. }}
  391. />
  392. {/if}
  393. {/each}
  394. </div>
  395. {/if}
  396. <div class=" flex">
  397. <div class="ml-1 self-end mb-1.5 flex space-x-1">
  398. <InputMenu
  399. {screenCaptureHandler}
  400. uploadFilesHandler={() => {
  401. filesInputElement.click();
  402. }}
  403. >
  404. <button
  405. 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"
  406. type="button"
  407. aria-label="More"
  408. >
  409. <svg
  410. xmlns="http://www.w3.org/2000/svg"
  411. viewBox="0 0 20 20"
  412. fill="currentColor"
  413. class="size-5"
  414. >
  415. <path
  416. 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"
  417. />
  418. </svg>
  419. </button>
  420. </InputMenu>
  421. </div>
  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-${id}`}
  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. <div class="self-end mb-1.5 flex space-x-1 mr-1">
  469. {#if content === ''}
  470. <Tooltip content={$i18n.t('Record voice')}>
  471. <button
  472. id="voice-input-button"
  473. 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"
  474. type="button"
  475. on:click={async () => {
  476. try {
  477. let stream = await navigator.mediaDevices
  478. .getUserMedia({ audio: true })
  479. .catch(function (err) {
  480. toast.error(
  481. $i18n.t(`Permission denied when accessing microphone: {{error}}`, {
  482. error: err
  483. })
  484. );
  485. return null;
  486. });
  487. if (stream) {
  488. recording = true;
  489. const tracks = stream.getTracks();
  490. tracks.forEach((track) => track.stop());
  491. }
  492. stream = null;
  493. } catch {
  494. toast.error($i18n.t('Permission denied when accessing microphone'));
  495. }
  496. }}
  497. aria-label="Voice Input"
  498. >
  499. <svg
  500. xmlns="http://www.w3.org/2000/svg"
  501. viewBox="0 0 20 20"
  502. fill="currentColor"
  503. class="w-5 h-5 translate-y-[0.5px]"
  504. >
  505. <path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
  506. <path
  507. 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"
  508. />
  509. </svg>
  510. </button>
  511. </Tooltip>
  512. {/if}
  513. <div class=" flex items-center">
  514. <div class=" flex items-center">
  515. <Tooltip content={$i18n.t('Send message')}>
  516. <button
  517. id="send-message-button"
  518. class="{content !== '' || files.length !== 0
  519. ? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
  520. : 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
  521. type="submit"
  522. disabled={content === '' && files.length === 0}
  523. >
  524. <svg
  525. xmlns="http://www.w3.org/2000/svg"
  526. viewBox="0 0 16 16"
  527. fill="currentColor"
  528. class="size-6"
  529. >
  530. <path
  531. fill-rule="evenodd"
  532. 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"
  533. clip-rule="evenodd"
  534. />
  535. </svg>
  536. </button>
  537. </Tooltip>
  538. </div>
  539. </div>
  540. </div>
  541. </div>
  542. </div>
  543. </form>
  544. {/if}
  545. </div>
  546. </div>
  547. </div>