MessageInput.svelte 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  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.info('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.error('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. try {
  136. // During the file upload, file content is automatically extracted.
  137. const uploadedFile = await uploadFile(localStorage.token, file);
  138. if (uploadedFile) {
  139. console.info('File upload completed:', {
  140. id: uploadedFile.id,
  141. name: fileItem.name,
  142. collection: uploadedFile?.meta?.collection_name
  143. });
  144. if (uploadedFile.error) {
  145. console.error('File upload warning:', uploadedFile.error);
  146. toast.warning(uploadedFile.error);
  147. }
  148. fileItem.status = 'uploaded';
  149. fileItem.file = uploadedFile;
  150. fileItem.id = uploadedFile.id;
  151. fileItem.collection_name =
  152. uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
  153. fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
  154. files = files;
  155. } else {
  156. files = files.filter((item) => item?.itemId !== tempItemId);
  157. }
  158. } catch (e) {
  159. toast.error(`${e}`);
  160. files = files.filter((item) => item?.itemId !== tempItemId);
  161. }
  162. };
  163. const handleKeyDown = (event: KeyboardEvent) => {
  164. if (event.key === 'Escape') {
  165. draggedOver = false;
  166. }
  167. };
  168. const onDragOver = (e) => {
  169. e.preventDefault();
  170. // Check if a file is being draggedOver.
  171. if (e.dataTransfer?.types?.includes('Files')) {
  172. draggedOver = true;
  173. } else {
  174. draggedOver = false;
  175. }
  176. };
  177. const onDragLeave = () => {
  178. draggedOver = false;
  179. };
  180. const onDrop = async (e) => {
  181. e.preventDefault();
  182. if (e.dataTransfer?.files) {
  183. const inputFiles = Array.from(e.dataTransfer?.files);
  184. if (inputFiles && inputFiles.length > 0) {
  185. console.log(inputFiles);
  186. inputFilesHandler(inputFiles);
  187. }
  188. }
  189. draggedOver = false;
  190. };
  191. const submitHandler = async () => {
  192. if (content === '' && files.length === 0) {
  193. return;
  194. }
  195. onSubmit({
  196. content,
  197. data: {
  198. files: files
  199. }
  200. });
  201. content = '';
  202. files = [];
  203. await tick();
  204. const chatInputElement = document.getElementById(`chat-input-${id}`);
  205. chatInputElement?.focus();
  206. };
  207. $: if (content) {
  208. onChange();
  209. }
  210. onMount(async () => {
  211. window.setTimeout(() => {
  212. const chatInput = document.getElementById(`chat-input-${id}`);
  213. chatInput?.focus();
  214. }, 0);
  215. window.addEventListener('keydown', handleKeyDown);
  216. await tick();
  217. const dropzoneElement = document.getElementById('channel-container');
  218. dropzoneElement?.addEventListener('dragover', onDragOver);
  219. dropzoneElement?.addEventListener('drop', onDrop);
  220. dropzoneElement?.addEventListener('dragleave', onDragLeave);
  221. });
  222. onDestroy(() => {
  223. window.removeEventListener('keydown', handleKeyDown);
  224. const dropzoneElement = document.getElementById('channel-container');
  225. if (dropzoneElement) {
  226. dropzoneElement?.removeEventListener('dragover', onDragOver);
  227. dropzoneElement?.removeEventListener('drop', onDrop);
  228. dropzoneElement?.removeEventListener('dragleave', onDragLeave);
  229. }
  230. });
  231. </script>
  232. <FilesOverlay show={draggedOver} />
  233. <input
  234. bind:this={filesInputElement}
  235. bind:files={inputFiles}
  236. type="file"
  237. hidden
  238. multiple
  239. on:change={async () => {
  240. if (inputFiles && inputFiles.length > 0) {
  241. inputFilesHandler(Array.from(inputFiles));
  242. } else {
  243. toast.error($i18n.t(`File not found.`));
  244. }
  245. filesInputElement.value = '';
  246. }}
  247. />
  248. <div class="bg-transparent">
  249. <div
  250. class="{($settings?.widescreenMode ?? null)
  251. ? 'max-w-full'
  252. : 'max-w-6xl'} px-2.5 mx-auto inset-x-0 relative"
  253. >
  254. <div class="absolute top-0 left-0 right-0 mx-auto inset-x-0 bg-transparent flex justify-center">
  255. <div class="flex flex-col px-3 w-full">
  256. <div class="relative">
  257. {#if scrollEnd === false}
  258. <div
  259. class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none"
  260. >
  261. <button
  262. class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full pointer-events-auto"
  263. on:click={() => {
  264. scrollEnd = true;
  265. scrollToBottom();
  266. }}
  267. >
  268. <svg
  269. xmlns="http://www.w3.org/2000/svg"
  270. viewBox="0 0 20 20"
  271. fill="currentColor"
  272. class="w-5 h-5"
  273. >
  274. <path
  275. fill-rule="evenodd"
  276. 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"
  277. clip-rule="evenodd"
  278. />
  279. </svg>
  280. </button>
  281. </div>
  282. {/if}
  283. </div>
  284. <div class="relative">
  285. <div class=" -mt-5">
  286. {#if typingUsers.length > 0}
  287. <div class=" text-xs px-4 mb-1">
  288. <span class=" font-normal text-black dark:text-white">
  289. {typingUsers.map((user) => user.name).join(', ')}
  290. </span>
  291. {$i18n.t('is typing...')}
  292. </div>
  293. {/if}
  294. </div>
  295. </div>
  296. </div>
  297. </div>
  298. <div class="">
  299. {#if recording}
  300. <VoiceRecording
  301. bind:recording
  302. onCancel={async () => {
  303. recording = false;
  304. await tick();
  305. document.getElementById(`chat-input-${id}`)?.focus();
  306. }}
  307. onConfirm={async (data) => {
  308. const { text, filename } = data;
  309. content = `${content}${text} `;
  310. recording = false;
  311. await tick();
  312. document.getElementById(`chat-input-${id}`)?.focus();
  313. }}
  314. />
  315. {:else}
  316. <form
  317. class="w-full flex gap-1.5"
  318. on:submit|preventDefault={() => {
  319. submitHandler();
  320. }}
  321. >
  322. <div
  323. 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"
  324. dir={$settings?.chatDirection ?? 'auto'}
  325. >
  326. {#if files.length > 0}
  327. <div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2">
  328. {#each files as file, fileIdx}
  329. {#if file.type === 'image'}
  330. <div class=" relative group">
  331. <div class="relative">
  332. <Image
  333. src={file.url}
  334. alt="input"
  335. imageClassName=" h-16 w-16 rounded-xl object-cover"
  336. />
  337. </div>
  338. <div class=" absolute -top-1 -right-1">
  339. <button
  340. class=" bg-white text-black border border-white rounded-full group-hover:visible invisible transition"
  341. type="button"
  342. on:click={() => {
  343. files.splice(fileIdx, 1);
  344. files = files;
  345. }}
  346. >
  347. <svg
  348. xmlns="http://www.w3.org/2000/svg"
  349. viewBox="0 0 20 20"
  350. fill="currentColor"
  351. class="w-4 h-4"
  352. >
  353. <path
  354. 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"
  355. />
  356. </svg>
  357. </button>
  358. </div>
  359. </div>
  360. {:else}
  361. <FileItem
  362. item={file}
  363. name={file.name}
  364. type={file.type}
  365. size={file?.size}
  366. loading={file.status === 'uploading'}
  367. dismissible={true}
  368. edit={true}
  369. on:dismiss={() => {
  370. files.splice(fileIdx, 1);
  371. files = files;
  372. }}
  373. on:click={() => {
  374. console.log(file);
  375. }}
  376. />
  377. {/if}
  378. {/each}
  379. </div>
  380. {/if}
  381. <div class="px-2.5">
  382. <div
  383. 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"
  384. >
  385. <RichTextInput
  386. bind:value={content}
  387. id={`chat-input-${id}`}
  388. messageInput={true}
  389. shiftEnter={!$mobile ||
  390. !(
  391. 'ontouchstart' in window ||
  392. navigator.maxTouchPoints > 0 ||
  393. navigator.msMaxTouchPoints > 0
  394. )}
  395. {placeholder}
  396. largeTextAsFile={$settings?.largeTextAsFile ?? false}
  397. on:keydown={async (e) => {
  398. e = e.detail.event;
  399. const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
  400. if (
  401. !$mobile ||
  402. !(
  403. 'ontouchstart' in window ||
  404. navigator.maxTouchPoints > 0 ||
  405. navigator.msMaxTouchPoints > 0
  406. )
  407. ) {
  408. // Prevent Enter key from creating a new line
  409. // Uses keyCode '13' for Enter key for chinese/japanese keyboards
  410. if (e.keyCode === 13 && !e.shiftKey) {
  411. e.preventDefault();
  412. }
  413. // Submit the content when Enter key is pressed
  414. if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
  415. submitHandler();
  416. }
  417. }
  418. if (e.key === 'Escape') {
  419. console.info('Escape');
  420. }
  421. }}
  422. on:paste={async (e) => {
  423. e = e.detail.event;
  424. console.info(e);
  425. }}
  426. />
  427. </div>
  428. </div>
  429. <div class=" flex justify-between mb-2.5 mt-1.5 mx-0.5">
  430. <div class="ml-1 self-end flex space-x-1">
  431. <InputMenu
  432. {screenCaptureHandler}
  433. uploadFilesHandler={() => {
  434. filesInputElement.click();
  435. }}
  436. >
  437. <button
  438. 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"
  439. type="button"
  440. aria-label="More"
  441. >
  442. <svg
  443. xmlns="http://www.w3.org/2000/svg"
  444. viewBox="0 0 20 20"
  445. fill="currentColor"
  446. class="size-5"
  447. >
  448. <path
  449. 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"
  450. />
  451. </svg>
  452. </button>
  453. </InputMenu>
  454. </div>
  455. <div class="self-end flex space-x-1 mr-1">
  456. {#if content === ''}
  457. <Tooltip content={$i18n.t('Record voice')}>
  458. <button
  459. id="voice-input-button"
  460. 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"
  461. type="button"
  462. on:click={async () => {
  463. try {
  464. let stream = await navigator.mediaDevices
  465. .getUserMedia({ audio: true })
  466. .catch(function (err) {
  467. toast.error(
  468. $i18n.t(`Permission denied when accessing microphone: {{error}}`, {
  469. error: err
  470. })
  471. );
  472. return null;
  473. });
  474. if (stream) {
  475. recording = true;
  476. const tracks = stream.getTracks();
  477. tracks.forEach((track) => track.stop());
  478. }
  479. stream = null;
  480. } catch {
  481. toast.error($i18n.t('Permission denied when accessing microphone'));
  482. }
  483. }}
  484. aria-label="Voice Input"
  485. >
  486. <svg
  487. xmlns="http://www.w3.org/2000/svg"
  488. viewBox="0 0 20 20"
  489. fill="currentColor"
  490. class="w-5 h-5 translate-y-[0.5px]"
  491. >
  492. <path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
  493. <path
  494. 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"
  495. />
  496. </svg>
  497. </button>
  498. </Tooltip>
  499. {/if}
  500. <div class=" flex items-center">
  501. <div class=" flex items-center">
  502. <Tooltip content={$i18n.t('Send message')}>
  503. <button
  504. id="send-message-button"
  505. class="{content !== '' || files.length !== 0
  506. ? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
  507. : 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
  508. type="submit"
  509. disabled={content === '' && files.length === 0}
  510. >
  511. <svg
  512. xmlns="http://www.w3.org/2000/svg"
  513. viewBox="0 0 16 16"
  514. fill="currentColor"
  515. class="size-5"
  516. >
  517. <path
  518. fill-rule="evenodd"
  519. 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"
  520. clip-rule="evenodd"
  521. />
  522. </svg>
  523. </button>
  524. </Tooltip>
  525. </div>
  526. </div>
  527. </div>
  528. </div>
  529. </div>
  530. </form>
  531. {/if}
  532. </div>
  533. </div>
  534. </div>