MessageInput.svelte 17 KB

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