MessageInput.svelte 54 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606
  1. <script lang="ts">
  2. import DOMPurify from 'dompurify';
  3. import { marked } from 'marked';
  4. import heic2any from 'heic2any';
  5. import { toast } from 'svelte-sonner';
  6. import { v4 as uuidv4 } from 'uuid';
  7. import { createPicker, getAuthToken } from '$lib/utils/google-drive-picker';
  8. import { pickAndDownloadFile } from '$lib/utils/onedrive-file-picker';
  9. import { onMount, tick, getContext, createEventDispatcher, onDestroy } from 'svelte';
  10. const dispatch = createEventDispatcher();
  11. import {
  12. type Model,
  13. mobile,
  14. settings,
  15. showSidebar,
  16. models,
  17. config,
  18. showCallOverlay,
  19. tools,
  20. user as _user,
  21. showControls,
  22. TTSWorker
  23. } from '$lib/stores';
  24. import {
  25. blobToFile,
  26. compressImage,
  27. createMessagesList,
  28. extractCurlyBraceWords
  29. } from '$lib/utils';
  30. import { uploadFile } from '$lib/apis/files';
  31. import { generateAutoCompletion } from '$lib/apis';
  32. import { deleteFileById } from '$lib/apis/files';
  33. import { WEBUI_BASE_URL, WEBUI_API_BASE_URL, PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
  34. import InputMenu from './MessageInput/InputMenu.svelte';
  35. import VoiceRecording from './MessageInput/VoiceRecording.svelte';
  36. import FilesOverlay from './MessageInput/FilesOverlay.svelte';
  37. import Commands from './MessageInput/Commands.svelte';
  38. import ToolServersModal from './ToolServersModal.svelte';
  39. import RichTextInput from '../common/RichTextInput.svelte';
  40. import Tooltip from '../common/Tooltip.svelte';
  41. import FileItem from '../common/FileItem.svelte';
  42. import Image from '../common/Image.svelte';
  43. import XMark from '../icons/XMark.svelte';
  44. import Headphone from '../icons/Headphone.svelte';
  45. import GlobeAlt from '../icons/GlobeAlt.svelte';
  46. import Photo from '../icons/Photo.svelte';
  47. import Wrench from '../icons/Wrench.svelte';
  48. import CommandLine from '../icons/CommandLine.svelte';
  49. import Sparkles from '../icons/Sparkles.svelte';
  50. import { KokoroWorker } from '$lib/workers/KokoroWorker';
  51. const i18n = getContext('i18n');
  52. export let transparentBackground = false;
  53. export let onChange: Function = () => {};
  54. export let createMessagePair: Function;
  55. export let stopResponse: Function;
  56. export let autoScroll = false;
  57. export let atSelectedModel: Model | undefined = undefined;
  58. export let selectedModels: [''];
  59. let selectedModelIds = [];
  60. $: selectedModelIds = atSelectedModel !== undefined ? [atSelectedModel.id] : selectedModels;
  61. export let history;
  62. export let taskIds = null;
  63. export let prompt = '';
  64. export let files = [];
  65. export let toolServers = [];
  66. export let selectedToolIds = [];
  67. export let selectedFilterIds = [];
  68. export let imageGenerationEnabled = false;
  69. export let webSearchEnabled = false;
  70. export let codeInterpreterEnabled = false;
  71. $: onChange({
  72. prompt,
  73. files: files
  74. .filter((file) => file.type !== 'image')
  75. .map((file) => {
  76. return {
  77. ...file,
  78. user: undefined,
  79. access_control: undefined
  80. };
  81. }),
  82. selectedToolIds,
  83. selectedFilterIds,
  84. imageGenerationEnabled,
  85. webSearchEnabled,
  86. codeInterpreterEnabled
  87. });
  88. let showTools = false;
  89. let loaded = false;
  90. let recording = false;
  91. let isComposing = false;
  92. let chatInputContainerElement;
  93. let chatInputElement;
  94. let filesInputElement;
  95. let commandsElement;
  96. let inputFiles;
  97. let dragged = false;
  98. let shiftKey = false;
  99. let user = null;
  100. export let placeholder = '';
  101. let visionCapableModels = [];
  102. $: visionCapableModels = (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).filter(
  103. (model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.vision ?? true
  104. );
  105. let fileUploadCapableModels = [];
  106. $: fileUploadCapableModels = (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).filter(
  107. (model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.file_upload ?? true
  108. );
  109. let webSearchCapableModels = [];
  110. $: webSearchCapableModels = (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).filter(
  111. (model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.web_search ?? true
  112. );
  113. let imageGenerationCapableModels = [];
  114. $: imageGenerationCapableModels = (
  115. atSelectedModel?.id ? [atSelectedModel.id] : selectedModels
  116. ).filter(
  117. (model) =>
  118. $models.find((m) => m.id === model)?.info?.meta?.capabilities?.image_generation ?? true
  119. );
  120. let codeInterpreterCapableModels = [];
  121. $: codeInterpreterCapableModels = (
  122. atSelectedModel?.id ? [atSelectedModel.id] : selectedModels
  123. ).filter(
  124. (model) =>
  125. $models.find((m) => m.id === model)?.info?.meta?.capabilities?.code_interpreter ?? true
  126. );
  127. let toggleFilters = [];
  128. $: toggleFilters = (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels)
  129. .map((id) => ($models.find((model) => model.id === id) || {})?.filters ?? [])
  130. .reduce((acc, filters) => acc.filter((f1) => filters.some((f2) => f2.id === f1.id)));
  131. let showToolsButton = false;
  132. $: showToolsButton = toolServers.length + selectedToolIds.length > 0;
  133. let showWebSearchButton = false;
  134. $: showWebSearchButton =
  135. (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).length ===
  136. webSearchCapableModels.length &&
  137. $config?.features?.enable_web_search &&
  138. ($_user.role === 'admin' || $_user?.permissions?.features?.web_search);
  139. let showImageGenerationButton = false;
  140. $: showImageGenerationButton =
  141. (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).length ===
  142. imageGenerationCapableModels.length &&
  143. $config?.features?.enable_image_generation &&
  144. ($_user.role === 'admin' || $_user?.permissions?.features?.image_generation);
  145. let showCodeInterpreterButton = false;
  146. $: showCodeInterpreterButton =
  147. (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).length ===
  148. codeInterpreterCapableModels.length &&
  149. $config?.features?.enable_code_interpreter &&
  150. ($_user.role === 'admin' || $_user?.permissions?.features?.code_interpreter);
  151. const scrollToBottom = () => {
  152. const element = document.getElementById('messages-container');
  153. element.scrollTo({
  154. top: element.scrollHeight,
  155. behavior: 'smooth'
  156. });
  157. };
  158. const screenCaptureHandler = async () => {
  159. try {
  160. // Request screen media
  161. const mediaStream = await navigator.mediaDevices.getDisplayMedia({
  162. video: { cursor: 'never' },
  163. audio: false
  164. });
  165. // Once the user selects a screen, temporarily create a video element
  166. const video = document.createElement('video');
  167. video.srcObject = mediaStream;
  168. // Ensure the video loads without affecting user experience or tab switching
  169. await video.play();
  170. // Set up the canvas to match the video dimensions
  171. const canvas = document.createElement('canvas');
  172. canvas.width = video.videoWidth;
  173. canvas.height = video.videoHeight;
  174. // Grab a single frame from the video stream using the canvas
  175. const context = canvas.getContext('2d');
  176. context.drawImage(video, 0, 0, canvas.width, canvas.height);
  177. // Stop all video tracks (stop screen sharing) after capturing the image
  178. mediaStream.getTracks().forEach((track) => track.stop());
  179. // bring back focus to this current tab, so that the user can see the screen capture
  180. window.focus();
  181. // Convert the canvas to a Base64 image URL
  182. const imageUrl = canvas.toDataURL('image/png');
  183. // Add the captured image to the files array to render it
  184. files = [...files, { type: 'image', url: imageUrl }];
  185. // Clean memory: Clear video srcObject
  186. video.srcObject = null;
  187. } catch (error) {
  188. // Handle any errors (e.g., user cancels screen sharing)
  189. console.error('Error capturing screen:', error);
  190. }
  191. };
  192. const uploadFileHandler = async (file, fullContext: boolean = false) => {
  193. if ($_user?.role !== 'admin' && !($_user?.permissions?.chat?.file_upload ?? true)) {
  194. toast.error($i18n.t('You do not have permission to upload files.'));
  195. return null;
  196. }
  197. const tempItemId = uuidv4();
  198. const fileItem = {
  199. type: 'file',
  200. file: '',
  201. id: null,
  202. url: '',
  203. name: file.name,
  204. collection_name: '',
  205. status: 'uploading',
  206. size: file.size,
  207. error: '',
  208. itemId: tempItemId,
  209. ...(fullContext ? { context: 'full' } : {})
  210. };
  211. if (fileItem.size == 0) {
  212. toast.error($i18n.t('You cannot upload an empty file.'));
  213. return null;
  214. }
  215. files = [...files, fileItem];
  216. try {
  217. // If the file is an audio file, provide the language for STT.
  218. let metadata = null;
  219. if (
  220. (file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
  221. $settings?.audio?.stt?.language
  222. ) {
  223. metadata = {
  224. language: $settings?.audio?.stt?.language
  225. };
  226. }
  227. // During the file upload, file content is automatically extracted.
  228. const uploadedFile = await uploadFile(localStorage.token, file, metadata);
  229. if (uploadedFile) {
  230. console.log('File upload completed:', {
  231. id: uploadedFile.id,
  232. name: fileItem.name,
  233. collection: uploadedFile?.meta?.collection_name
  234. });
  235. if (uploadedFile.error) {
  236. console.warn('File upload warning:', uploadedFile.error);
  237. toast.warning(uploadedFile.error);
  238. }
  239. fileItem.status = 'uploaded';
  240. fileItem.file = uploadedFile;
  241. fileItem.id = uploadedFile.id;
  242. fileItem.collection_name =
  243. uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
  244. fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
  245. files = files;
  246. } else {
  247. files = files.filter((item) => item?.itemId !== tempItemId);
  248. }
  249. } catch (e) {
  250. toast.error(`${e}`);
  251. files = files.filter((item) => item?.itemId !== tempItemId);
  252. }
  253. };
  254. const inputFilesHandler = async (inputFiles) => {
  255. console.log('Input files handler called with:', inputFiles);
  256. if (
  257. ($config?.file?.max_count ?? null) !== null &&
  258. files.length + inputFiles.length > $config?.file?.max_count
  259. ) {
  260. toast.error(
  261. $i18n.t(`You can only chat with a maximum of {{maxCount}} file(s) at a time.`, {
  262. maxCount: $config?.file?.max_count
  263. })
  264. );
  265. return;
  266. }
  267. inputFiles.forEach(async (file) => {
  268. console.log('Processing file:', {
  269. name: file.name,
  270. type: file.type,
  271. size: file.size,
  272. extension: file.name.split('.').at(-1)
  273. });
  274. if (
  275. ($config?.file?.max_size ?? null) !== null &&
  276. file.size > ($config?.file?.max_size ?? 0) * 1024 * 1024
  277. ) {
  278. console.log('File exceeds max size limit:', {
  279. fileSize: file.size,
  280. maxSize: ($config?.file?.max_size ?? 0) * 1024 * 1024
  281. });
  282. toast.error(
  283. $i18n.t(`File size should not exceed {{maxSize}} MB.`, {
  284. maxSize: $config?.file?.max_size
  285. })
  286. );
  287. return;
  288. }
  289. if (file['type'].startsWith('image/')) {
  290. if (visionCapableModels.length === 0) {
  291. toast.error($i18n.t('Selected model(s) do not support image inputs'));
  292. return;
  293. }
  294. const compressImageHandler = async (imageUrl, settings = {}, config = {}) => {
  295. // Quick shortcut so we don’t do unnecessary work.
  296. const settingsCompression = settings?.imageCompression ?? false;
  297. const configWidth = config?.file?.image_compression?.width ?? null;
  298. const configHeight = config?.file?.image_compression?.height ?? null;
  299. // If neither settings nor config wants compression, return original URL.
  300. if (!settingsCompression && !configWidth && !configHeight) {
  301. return imageUrl;
  302. }
  303. // Default to null (no compression unless set)
  304. let width = null;
  305. let height = null;
  306. // If user/settings want compression, pick their preferred size.
  307. if (settingsCompression) {
  308. width = settings?.imageCompressionSize?.width ?? null;
  309. height = settings?.imageCompressionSize?.height ?? null;
  310. }
  311. // Apply config limits as an upper bound if any
  312. if (configWidth && (width === null || width > configWidth)) {
  313. width = configWidth;
  314. }
  315. if (configHeight && (height === null || height > configHeight)) {
  316. height = configHeight;
  317. }
  318. // Do the compression if required
  319. if (width || height) {
  320. return await compressImage(imageUrl, width, height);
  321. }
  322. return imageUrl;
  323. };
  324. let reader = new FileReader();
  325. reader.onload = async (event) => {
  326. let imageUrl = event.target.result;
  327. imageUrl = await compressImageHandler(imageUrl, $settings, $config);
  328. files = [
  329. ...files,
  330. {
  331. type: 'image',
  332. url: `${imageUrl}`
  333. }
  334. ];
  335. };
  336. reader.readAsDataURL(
  337. file['type'] === 'image/heic'
  338. ? await heic2any({ blob: file, toType: 'image/jpeg' })
  339. : file
  340. );
  341. } else {
  342. uploadFileHandler(file);
  343. }
  344. });
  345. };
  346. const onDragOver = (e) => {
  347. e.preventDefault();
  348. // Check if a file is being dragged.
  349. if (e.dataTransfer?.types?.includes('Files')) {
  350. dragged = true;
  351. } else {
  352. dragged = false;
  353. }
  354. };
  355. const onDragLeave = () => {
  356. dragged = false;
  357. };
  358. const onDrop = async (e) => {
  359. e.preventDefault();
  360. console.log(e);
  361. if (e.dataTransfer?.files) {
  362. const inputFiles = Array.from(e.dataTransfer?.files);
  363. if (inputFiles && inputFiles.length > 0) {
  364. console.log(inputFiles);
  365. inputFilesHandler(inputFiles);
  366. }
  367. }
  368. dragged = false;
  369. };
  370. const onKeyDown = (e) => {
  371. if (e.key === 'Shift') {
  372. shiftKey = true;
  373. }
  374. if (e.key === 'Escape') {
  375. console.log('Escape');
  376. dragged = false;
  377. }
  378. };
  379. const onKeyUp = (e) => {
  380. if (e.key === 'Shift') {
  381. shiftKey = false;
  382. }
  383. };
  384. const onFocus = () => {};
  385. const onBlur = () => {
  386. shiftKey = false;
  387. };
  388. onMount(async () => {
  389. loaded = true;
  390. window.setTimeout(() => {
  391. const chatInput = document.getElementById('chat-input');
  392. chatInput?.focus();
  393. }, 0);
  394. window.addEventListener('keydown', onKeyDown);
  395. window.addEventListener('keyup', onKeyUp);
  396. window.addEventListener('focus', onFocus);
  397. window.addEventListener('blur', onBlur);
  398. await tick();
  399. const dropzoneElement = document.getElementById('chat-container');
  400. dropzoneElement?.addEventListener('dragover', onDragOver);
  401. dropzoneElement?.addEventListener('drop', onDrop);
  402. dropzoneElement?.addEventListener('dragleave', onDragLeave);
  403. });
  404. onDestroy(() => {
  405. console.log('destroy');
  406. window.removeEventListener('keydown', onKeyDown);
  407. window.removeEventListener('keyup', onKeyUp);
  408. window.removeEventListener('focus', onFocus);
  409. window.removeEventListener('blur', onBlur);
  410. const dropzoneElement = document.getElementById('chat-container');
  411. if (dropzoneElement) {
  412. dropzoneElement?.removeEventListener('dragover', onDragOver);
  413. dropzoneElement?.removeEventListener('drop', onDrop);
  414. dropzoneElement?.removeEventListener('dragleave', onDragLeave);
  415. }
  416. });
  417. </script>
  418. <FilesOverlay show={dragged} />
  419. <ToolServersModal bind:show={showTools} {selectedToolIds} />
  420. {#if loaded}
  421. <div class="w-full font-primary">
  422. <div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
  423. <div
  424. class="flex flex-col px-3 {($settings?.widescreenMode ?? null)
  425. ? 'max-w-full'
  426. : 'max-w-6xl'} w-full"
  427. >
  428. <div class="relative">
  429. {#if autoScroll === false && history?.currentId}
  430. <div
  431. class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none"
  432. >
  433. <button
  434. class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full pointer-events-auto"
  435. on:click={() => {
  436. autoScroll = true;
  437. scrollToBottom();
  438. }}
  439. >
  440. <svg
  441. xmlns="http://www.w3.org/2000/svg"
  442. viewBox="0 0 20 20"
  443. fill="currentColor"
  444. class="w-5 h-5"
  445. >
  446. <path
  447. fill-rule="evenodd"
  448. 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"
  449. clip-rule="evenodd"
  450. />
  451. </svg>
  452. </button>
  453. </div>
  454. {/if}
  455. </div>
  456. <div class="w-full relative">
  457. {#if atSelectedModel !== undefined}
  458. <div
  459. class="px-3 pb-0.5 pt-1.5 text-left w-full flex flex-col absolute bottom-0 left-0 right-0 bg-linear-to-t from-white dark:from-gray-900 z-10"
  460. >
  461. <div class="flex items-center justify-between w-full">
  462. <div class="pl-[1px] flex items-center gap-2 text-sm dark:text-gray-500">
  463. <img
  464. crossorigin="anonymous"
  465. alt="model profile"
  466. class="size-3.5 max-w-[28px] object-cover rounded-full"
  467. src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
  468. ?.profile_image_url ??
  469. ($i18n.language === 'dg-DG'
  470. ? `/doge.png`
  471. : `${WEBUI_BASE_URL}/static/favicon.png`)}
  472. />
  473. <div class="translate-y-[0.5px]">
  474. Talking to <span class=" font-medium">{atSelectedModel.name}</span>
  475. </div>
  476. </div>
  477. <div>
  478. <button
  479. class="flex items-center dark:text-gray-500"
  480. on:click={() => {
  481. atSelectedModel = undefined;
  482. }}
  483. >
  484. <XMark />
  485. </button>
  486. </div>
  487. </div>
  488. </div>
  489. {/if}
  490. <Commands
  491. bind:this={commandsElement}
  492. bind:prompt
  493. bind:files
  494. on:upload={(e) => {
  495. dispatch('upload', e.detail);
  496. }}
  497. on:select={(e) => {
  498. const data = e.detail;
  499. if (data?.type === 'model') {
  500. atSelectedModel = data.data;
  501. }
  502. const chatInputElement = document.getElementById('chat-input');
  503. chatInputElement?.focus();
  504. }}
  505. />
  506. </div>
  507. </div>
  508. </div>
  509. <div class="{transparentBackground ? 'bg-transparent' : 'bg-white dark:bg-gray-900'} ">
  510. <div
  511. class="{($settings?.widescreenMode ?? null)
  512. ? 'max-w-full'
  513. : 'max-w-6xl'} px-2.5 mx-auto inset-x-0"
  514. >
  515. <div class="">
  516. <input
  517. bind:this={filesInputElement}
  518. bind:files={inputFiles}
  519. type="file"
  520. hidden
  521. multiple
  522. on:change={async () => {
  523. if (inputFiles && inputFiles.length > 0) {
  524. const _inputFiles = Array.from(inputFiles);
  525. inputFilesHandler(_inputFiles);
  526. } else {
  527. toast.error($i18n.t(`File not found.`));
  528. }
  529. filesInputElement.value = '';
  530. }}
  531. />
  532. {#if recording}
  533. <VoiceRecording
  534. bind:recording
  535. onCancel={async () => {
  536. recording = false;
  537. await tick();
  538. document.getElementById('chat-input')?.focus();
  539. }}
  540. onConfirm={async (data) => {
  541. const { text, filename } = data;
  542. prompt = `${prompt}${text} `;
  543. recording = false;
  544. await tick();
  545. document.getElementById('chat-input')?.focus();
  546. if ($settings?.speechAutoSend ?? false) {
  547. dispatch('submit', prompt);
  548. }
  549. }}
  550. />
  551. {:else}
  552. <form
  553. class="w-full flex flex-col gap-1.5"
  554. on:submit|preventDefault={() => {
  555. // check if selectedModels support image input
  556. dispatch('submit', prompt);
  557. }}
  558. >
  559. <div
  560. class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border border-gray-50 dark:border-gray-850 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition px-1 bg-white/90 dark:bg-gray-400/5 dark:text-gray-100"
  561. dir={$settings?.chatDirection ?? 'auto'}
  562. >
  563. {#if files.length > 0}
  564. <div class="mx-2 mt-2.5 -mb-1 flex items-center flex-wrap gap-2">
  565. {#each files as file, fileIdx}
  566. {#if file.type === 'image'}
  567. <div class=" relative group">
  568. <div class="relative flex items-center">
  569. <Image
  570. src={file.url}
  571. alt=""
  572. imageClassName=" size-14 rounded-xl object-cover"
  573. />
  574. {#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
  575. <Tooltip
  576. className=" absolute top-1 left-1"
  577. content={$i18n.t('{{ models }}', {
  578. models: [
  579. ...(atSelectedModel ? [atSelectedModel] : selectedModels)
  580. ]
  581. .filter((id) => !visionCapableModels.includes(id))
  582. .join(', ')
  583. })}
  584. >
  585. <svg
  586. xmlns="http://www.w3.org/2000/svg"
  587. viewBox="0 0 24 24"
  588. fill="currentColor"
  589. aria-hidden="true"
  590. class="size-4 fill-yellow-300"
  591. >
  592. <path
  593. fill-rule="evenodd"
  594. d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
  595. clip-rule="evenodd"
  596. />
  597. </svg>
  598. </Tooltip>
  599. {/if}
  600. </div>
  601. <div class=" absolute -top-1 -right-1">
  602. <button
  603. class=" bg-white text-black border border-white rounded-full {($settings?.highContrastMode ??
  604. false)
  605. ? ''
  606. : 'outline-hidden focus:outline-hidden group-hover:visible invisible transition'}"
  607. type="button"
  608. aria-label={$i18n.t('Remove file')}
  609. on:click={() => {
  610. files.splice(fileIdx, 1);
  611. files = files;
  612. }}
  613. >
  614. <svg
  615. xmlns="http://www.w3.org/2000/svg"
  616. viewBox="0 0 20 20"
  617. fill="currentColor"
  618. aria-hidden="true"
  619. class="size-4"
  620. >
  621. <path
  622. 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"
  623. />
  624. </svg>
  625. </button>
  626. </div>
  627. </div>
  628. {:else}
  629. <FileItem
  630. item={file}
  631. name={file.name}
  632. type={file.type}
  633. size={file?.size}
  634. loading={file.status === 'uploading'}
  635. dismissible={true}
  636. edit={true}
  637. on:dismiss={async () => {
  638. try {
  639. if (file.type !== 'collection' && !file?.collection) {
  640. if (file.id) {
  641. // This will handle both file deletion and Chroma cleanup
  642. await deleteFileById(localStorage.token, file.id);
  643. }
  644. }
  645. } catch (error) {
  646. console.error('Error deleting file:', error);
  647. }
  648. // Remove from UI state
  649. files.splice(fileIdx, 1);
  650. files = files;
  651. }}
  652. on:click={() => {
  653. console.log(file);
  654. }}
  655. />
  656. {/if}
  657. {/each}
  658. </div>
  659. {/if}
  660. <div class="px-2.5">
  661. {#if $settings?.richTextInput ?? true}
  662. <div
  663. class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-2.5 pb-[5px] px-1 resize-none h-fit max-h-80 overflow-auto"
  664. id="chat-input-container"
  665. >
  666. <RichTextInput
  667. bind:this={chatInputElement}
  668. bind:value={prompt}
  669. id="chat-input"
  670. messageInput={true}
  671. shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
  672. (!$mobile ||
  673. !(
  674. 'ontouchstart' in window ||
  675. navigator.maxTouchPoints > 0 ||
  676. navigator.msMaxTouchPoints > 0
  677. ))}
  678. placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
  679. largeTextAsFile={($settings?.largeTextAsFile ?? false) && !shiftKey}
  680. autocomplete={$config?.features?.enable_autocomplete_generation &&
  681. ($settings?.promptAutocomplete ?? false)}
  682. generateAutoCompletion={async (text) => {
  683. if (selectedModelIds.length === 0 || !selectedModelIds.at(0)) {
  684. toast.error($i18n.t('Please select a model first.'));
  685. }
  686. const res = await generateAutoCompletion(
  687. localStorage.token,
  688. selectedModelIds.at(0),
  689. text,
  690. history?.currentId
  691. ? createMessagesList(history, history.currentId)
  692. : null
  693. ).catch((error) => {
  694. console.log(error);
  695. return null;
  696. });
  697. console.log(res);
  698. return res;
  699. }}
  700. oncompositionstart={() => (isComposing = true)}
  701. oncompositionend={() => (isComposing = false)}
  702. on:keydown={async (e) => {
  703. e = e.detail.event;
  704. const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
  705. const commandsContainerElement =
  706. document.getElementById('commands-container');
  707. if (e.key === 'Escape') {
  708. stopResponse();
  709. }
  710. // Command/Ctrl + Shift + Enter to submit a message pair
  711. if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
  712. e.preventDefault();
  713. createMessagePair(prompt);
  714. }
  715. // Check if Ctrl + R is pressed
  716. if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
  717. e.preventDefault();
  718. console.log('regenerate');
  719. const regenerateButton = [
  720. ...document.getElementsByClassName('regenerate-response-button')
  721. ]?.at(-1);
  722. regenerateButton?.click();
  723. }
  724. if (prompt === '' && e.key == 'ArrowUp') {
  725. e.preventDefault();
  726. const userMessageElement = [
  727. ...document.getElementsByClassName('user-message')
  728. ]?.at(-1);
  729. if (userMessageElement) {
  730. userMessageElement.scrollIntoView({ block: 'center' });
  731. const editButton = [
  732. ...document.getElementsByClassName('edit-user-message-button')
  733. ]?.at(-1);
  734. editButton?.click();
  735. }
  736. }
  737. if (commandsContainerElement) {
  738. if (commandsContainerElement && e.key === 'ArrowUp') {
  739. e.preventDefault();
  740. commandsElement.selectUp();
  741. const commandOptionButton = [
  742. ...document.getElementsByClassName('selected-command-option-button')
  743. ]?.at(-1);
  744. commandOptionButton.scrollIntoView({ block: 'center' });
  745. }
  746. if (commandsContainerElement && e.key === 'ArrowDown') {
  747. e.preventDefault();
  748. commandsElement.selectDown();
  749. const commandOptionButton = [
  750. ...document.getElementsByClassName('selected-command-option-button')
  751. ]?.at(-1);
  752. commandOptionButton.scrollIntoView({ block: 'center' });
  753. }
  754. if (commandsContainerElement && e.key === 'Tab') {
  755. e.preventDefault();
  756. const commandOptionButton = [
  757. ...document.getElementsByClassName('selected-command-option-button')
  758. ]?.at(-1);
  759. commandOptionButton?.click();
  760. }
  761. if (commandsContainerElement && e.key === 'Enter') {
  762. e.preventDefault();
  763. const commandOptionButton = [
  764. ...document.getElementsByClassName('selected-command-option-button')
  765. ]?.at(-1);
  766. if (commandOptionButton) {
  767. commandOptionButton?.click();
  768. } else {
  769. document.getElementById('send-message-button')?.click();
  770. }
  771. }
  772. } else {
  773. if (
  774. !$mobile ||
  775. !(
  776. 'ontouchstart' in window ||
  777. navigator.maxTouchPoints > 0 ||
  778. navigator.msMaxTouchPoints > 0
  779. )
  780. ) {
  781. if (isComposing) {
  782. return;
  783. }
  784. // Uses keyCode '13' for Enter key for chinese/japanese keyboards.
  785. //
  786. // Depending on the user's settings, it will send the message
  787. // either when Enter is pressed or when Ctrl+Enter is pressed.
  788. const enterPressed =
  789. ($settings?.ctrlEnterToSend ?? false)
  790. ? (e.key === 'Enter' || e.keyCode === 13) && isCtrlPressed
  791. : (e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey;
  792. if (enterPressed) {
  793. e.preventDefault();
  794. if (prompt !== '' || files.length > 0) {
  795. dispatch('submit', prompt);
  796. }
  797. }
  798. }
  799. }
  800. if (e.key === 'Escape') {
  801. console.log('Escape');
  802. atSelectedModel = undefined;
  803. selectedToolIds = [];
  804. selectedFilterIds = [];
  805. webSearchEnabled = false;
  806. imageGenerationEnabled = false;
  807. codeInterpreterEnabled = false;
  808. }
  809. }}
  810. on:paste={async (e) => {
  811. e = e.detail.event;
  812. console.log(e);
  813. const clipboardData = e.clipboardData || window.clipboardData;
  814. if (clipboardData && clipboardData.items) {
  815. for (const item of clipboardData.items) {
  816. if (item.type.indexOf('image') !== -1) {
  817. const blob = item.getAsFile();
  818. const reader = new FileReader();
  819. reader.onload = function (e) {
  820. files = [
  821. ...files,
  822. {
  823. type: 'image',
  824. url: `${e.target.result}`
  825. }
  826. ];
  827. };
  828. reader.readAsDataURL(blob);
  829. } else if (item.type === 'text/plain') {
  830. if (($settings?.largeTextAsFile ?? false) && !shiftKey) {
  831. const text = clipboardData.getData('text/plain');
  832. if (text.length > PASTED_TEXT_CHARACTER_LIMIT) {
  833. e.preventDefault();
  834. const blob = new Blob([text], { type: 'text/plain' });
  835. const file = new File([blob], `Pasted_Text_${Date.now()}.txt`, {
  836. type: 'text/plain'
  837. });
  838. await uploadFileHandler(file, true);
  839. }
  840. }
  841. }
  842. }
  843. }
  844. }}
  845. />
  846. </div>
  847. {:else}
  848. <textarea
  849. id="chat-input"
  850. dir={$settings?.chatDirection ?? 'auto'}
  851. bind:this={chatInputElement}
  852. class="scrollbar-hidden bg-transparent dark:text-gray-200 outline-hidden w-full pt-3 px-1 resize-none"
  853. placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
  854. bind:value={prompt}
  855. on:compositionstart={() => (isComposing = true)}
  856. on:compositionend={() => (isComposing = false)}
  857. on:keydown={async (e) => {
  858. const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
  859. const commandsContainerElement =
  860. document.getElementById('commands-container');
  861. if (e.key === 'Escape') {
  862. stopResponse();
  863. }
  864. // Command/Ctrl + Shift + Enter to submit a message pair
  865. if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
  866. e.preventDefault();
  867. createMessagePair(prompt);
  868. }
  869. // Check if Ctrl + R is pressed
  870. if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
  871. e.preventDefault();
  872. console.log('regenerate');
  873. const regenerateButton = [
  874. ...document.getElementsByClassName('regenerate-response-button')
  875. ]?.at(-1);
  876. regenerateButton?.click();
  877. }
  878. if (prompt === '' && e.key == 'ArrowUp') {
  879. e.preventDefault();
  880. const userMessageElement = [
  881. ...document.getElementsByClassName('user-message')
  882. ]?.at(-1);
  883. const editButton = [
  884. ...document.getElementsByClassName('edit-user-message-button')
  885. ]?.at(-1);
  886. console.log(userMessageElement);
  887. userMessageElement?.scrollIntoView({ block: 'center' });
  888. editButton?.click();
  889. }
  890. if (commandsContainerElement) {
  891. if (commandsContainerElement && e.key === 'ArrowUp') {
  892. e.preventDefault();
  893. commandsElement.selectUp();
  894. const container = document.getElementById('command-options-container');
  895. const commandOptionButton = [
  896. ...document.getElementsByClassName('selected-command-option-button')
  897. ]?.at(-1);
  898. if (commandOptionButton && container) {
  899. const elTop = commandOptionButton.offsetTop;
  900. const elHeight = commandOptionButton.offsetHeight;
  901. const containerHeight = container.clientHeight;
  902. // Center the selected button in the container
  903. container.scrollTop = elTop - containerHeight / 2 + elHeight / 2;
  904. }
  905. }
  906. if (commandsContainerElement && e.key === 'ArrowDown') {
  907. e.preventDefault();
  908. commandsElement.selectDown();
  909. const container = document.getElementById('command-options-container');
  910. const commandOptionButton = [
  911. ...document.getElementsByClassName('selected-command-option-button')
  912. ]?.at(-1);
  913. if (commandOptionButton && container) {
  914. const elTop = commandOptionButton.offsetTop;
  915. const elHeight = commandOptionButton.offsetHeight;
  916. const containerHeight = container.clientHeight;
  917. // Center the selected button in the container
  918. container.scrollTop = elTop - containerHeight / 2 + elHeight / 2;
  919. }
  920. }
  921. if (commandsContainerElement && e.key === 'Enter') {
  922. e.preventDefault();
  923. const commandOptionButton = [
  924. ...document.getElementsByClassName('selected-command-option-button')
  925. ]?.at(-1);
  926. if (e.shiftKey) {
  927. prompt = `${prompt}\n`;
  928. } else if (commandOptionButton) {
  929. commandOptionButton?.click();
  930. } else {
  931. document.getElementById('send-message-button')?.click();
  932. }
  933. }
  934. if (commandsContainerElement && e.key === 'Tab') {
  935. e.preventDefault();
  936. const commandOptionButton = [
  937. ...document.getElementsByClassName('selected-command-option-button')
  938. ]?.at(-1);
  939. commandOptionButton?.click();
  940. }
  941. } else {
  942. if (
  943. !$mobile ||
  944. !(
  945. 'ontouchstart' in window ||
  946. navigator.maxTouchPoints > 0 ||
  947. navigator.msMaxTouchPoints > 0
  948. )
  949. ) {
  950. if (isComposing) {
  951. return;
  952. }
  953. // Prevent Enter key from creating a new line
  954. const isCtrlPressed = e.ctrlKey || e.metaKey;
  955. const enterPressed =
  956. ($settings?.ctrlEnterToSend ?? false)
  957. ? (e.key === 'Enter' || e.keyCode === 13) && isCtrlPressed
  958. : (e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey;
  959. if (enterPressed) {
  960. e.preventDefault();
  961. }
  962. // Submit the prompt when Enter key is pressed
  963. if ((prompt !== '' || files.length > 0) && enterPressed) {
  964. dispatch('submit', prompt);
  965. }
  966. }
  967. }
  968. if (e.key === 'Tab') {
  969. const words = extractCurlyBraceWords(prompt);
  970. if (words.length > 0) {
  971. const word = words.at(0);
  972. const fullPrompt = prompt;
  973. prompt = prompt.substring(0, word?.endIndex + 1);
  974. await tick();
  975. e.target.scrollTop = e.target.scrollHeight;
  976. prompt = fullPrompt;
  977. await tick();
  978. e.preventDefault();
  979. e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
  980. }
  981. e.target.style.height = '';
  982. e.target.style.height = Math.min(e.target.scrollHeight, 320) + 'px';
  983. }
  984. if (e.key === 'Escape') {
  985. console.log('Escape');
  986. atSelectedModel = undefined;
  987. selectedToolIds = [];
  988. selectedFilterIds = [];
  989. webSearchEnabled = false;
  990. imageGenerationEnabled = false;
  991. codeInterpreterEnabled = false;
  992. }
  993. }}
  994. rows="1"
  995. on:input={async (e) => {
  996. e.target.style.height = '';
  997. e.target.style.height = Math.min(e.target.scrollHeight, 320) + 'px';
  998. }}
  999. on:focus={async (e) => {
  1000. e.target.style.height = '';
  1001. e.target.style.height = Math.min(e.target.scrollHeight, 320) + 'px';
  1002. }}
  1003. on:paste={async (e) => {
  1004. const clipboardData = e.clipboardData || window.clipboardData;
  1005. if (clipboardData && clipboardData.items) {
  1006. for (const item of clipboardData.items) {
  1007. if (item.type.indexOf('image') !== -1) {
  1008. const blob = item.getAsFile();
  1009. const reader = new FileReader();
  1010. reader.onload = function (e) {
  1011. files = [
  1012. ...files,
  1013. {
  1014. type: 'image',
  1015. url: `${e.target.result}`
  1016. }
  1017. ];
  1018. };
  1019. reader.readAsDataURL(blob);
  1020. } else if (item.type === 'text/plain') {
  1021. if (($settings?.largeTextAsFile ?? false) && !shiftKey) {
  1022. const text = clipboardData.getData('text/plain');
  1023. if (text.length > PASTED_TEXT_CHARACTER_LIMIT) {
  1024. e.preventDefault();
  1025. const blob = new Blob([text], { type: 'text/plain' });
  1026. const file = new File([blob], `Pasted_Text_${Date.now()}.txt`, {
  1027. type: 'text/plain'
  1028. });
  1029. await uploadFileHandler(file, true);
  1030. }
  1031. }
  1032. }
  1033. }
  1034. }
  1035. }}
  1036. />
  1037. {/if}
  1038. </div>
  1039. <div class=" flex justify-between mt-0.5 mb-2.5 mx-0.5 max-w-full" dir="ltr">
  1040. <div class="ml-1 self-end flex items-center flex-1 max-w-[80%]">
  1041. <InputMenu
  1042. bind:selectedToolIds
  1043. selectedModels={atSelectedModel ? [atSelectedModel.id] : selectedModels}
  1044. {fileUploadCapableModels}
  1045. {screenCaptureHandler}
  1046. {inputFilesHandler}
  1047. uploadFilesHandler={() => {
  1048. filesInputElement.click();
  1049. }}
  1050. uploadGoogleDriveHandler={async () => {
  1051. try {
  1052. const fileData = await createPicker();
  1053. if (fileData) {
  1054. const file = new File([fileData.blob], fileData.name, {
  1055. type: fileData.blob.type
  1056. });
  1057. await uploadFileHandler(file);
  1058. } else {
  1059. console.log('No file was selected from Google Drive');
  1060. }
  1061. } catch (error) {
  1062. console.error('Google Drive Error:', error);
  1063. toast.error(
  1064. $i18n.t('Error accessing Google Drive: {{error}}', {
  1065. error: error.message
  1066. })
  1067. );
  1068. }
  1069. }}
  1070. uploadOneDriveHandler={async (authorityType) => {
  1071. try {
  1072. const fileData = await pickAndDownloadFile(authorityType);
  1073. if (fileData) {
  1074. const file = new File([fileData.blob], fileData.name, {
  1075. type: fileData.blob.type || 'application/octet-stream'
  1076. });
  1077. await uploadFileHandler(file);
  1078. } else {
  1079. console.log('No file was selected from OneDrive');
  1080. }
  1081. } catch (error) {
  1082. console.error('OneDrive Error:', error);
  1083. }
  1084. }}
  1085. onClose={async () => {
  1086. await tick();
  1087. const chatInput = document.getElementById('chat-input');
  1088. chatInput?.focus();
  1089. }}
  1090. >
  1091. <button
  1092. class="bg-transparent hover:bg-gray-100 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden"
  1093. type="button"
  1094. aria-label={$i18n.t('More Available Tools')}
  1095. >
  1096. <svg
  1097. xmlns="http://www.w3.org/2000/svg"
  1098. viewBox="0 0 20 20"
  1099. aria-hidden="true"
  1100. fill="currentColor"
  1101. class="size-5"
  1102. >
  1103. <path
  1104. 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"
  1105. />
  1106. </svg>
  1107. </button>
  1108. </InputMenu>
  1109. {#if $_user && (showToolsButton || (toggleFilters && toggleFilters.length > 0) || showWebSearchButton || showImageGenerationButton || showCodeInterpreterButton)}
  1110. <div
  1111. class="flex self-center w-[1px] h-4 mx-1.5 bg-gray-50 dark:bg-gray-800"
  1112. />
  1113. <div class="flex gap-1 items-center overflow-x-auto scrollbar-none flex-1">
  1114. {#if showToolsButton}
  1115. <Tooltip
  1116. content={$i18n.t('{{COUNT}} Available Tools', {
  1117. COUNT: toolServers.length + selectedToolIds.length
  1118. })}
  1119. >
  1120. <button
  1121. class="translate-y-[0.5px] flex gap-1 items-center text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg p-1 self-center transition"
  1122. aria-label="Available Tools"
  1123. type="button"
  1124. on:click={() => {
  1125. showTools = !showTools;
  1126. }}
  1127. >
  1128. <Wrench className="size-4" strokeWidth="1.75" />
  1129. <span class="text-sm font-medium text-gray-600 dark:text-gray-300">
  1130. {toolServers.length + selectedToolIds.length}
  1131. </span>
  1132. </button>
  1133. </Tooltip>
  1134. {/if}
  1135. {#each toggleFilters as filter, filterIdx (filter.id)}
  1136. <Tooltip content={filter?.description} placement="top">
  1137. <button
  1138. on:click|preventDefault={() => {
  1139. if (selectedFilterIds.includes(filter.id)) {
  1140. selectedFilterIds = selectedFilterIds.filter(
  1141. (id) => id !== filter.id
  1142. );
  1143. } else {
  1144. selectedFilterIds = [...selectedFilterIds, filter.id];
  1145. }
  1146. }}
  1147. type="button"
  1148. class="px-2 @xl:px-2.5 py-2 flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {selectedFilterIds.includes(
  1149. filter.id
  1150. )
  1151. ? 'text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-200/5'
  1152. : 'bg-transparent text-gray-600 dark:text-gray-300 '} capitalize"
  1153. >
  1154. {#if filter?.icon}
  1155. <div class="size-4 items-center flex justify-center">
  1156. <img
  1157. src={filter.icon}
  1158. class="size-3.5 {filter.icon.includes('svg')
  1159. ? 'dark:invert-[80%]'
  1160. : ''}"
  1161. style="fill: currentColor;"
  1162. alt={filter.name}
  1163. />
  1164. </div>
  1165. {:else}
  1166. <Sparkles className="size-4" strokeWidth="1.75" />
  1167. {/if}
  1168. <span
  1169. class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5"
  1170. >{filter?.name}</span
  1171. >
  1172. </button>
  1173. </Tooltip>
  1174. {/each}
  1175. {#if showWebSearchButton}
  1176. <Tooltip content={$i18n.t('Search the internet')} placement="top">
  1177. <button
  1178. on:click|preventDefault={() => (webSearchEnabled = !webSearchEnabled)}
  1179. type="button"
  1180. class="px-2 @xl:px-2.5 py-2 flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {webSearchEnabled ||
  1181. ($settings?.webSearch ?? false) === 'always'
  1182. ? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-200/5'
  1183. : 'bg-transparent text-gray-600 dark:text-gray-300 '}"
  1184. >
  1185. <GlobeAlt className="size-4" strokeWidth="1.75" />
  1186. <span
  1187. class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5"
  1188. >{$i18n.t('Web Search')}</span
  1189. >
  1190. </button>
  1191. </Tooltip>
  1192. {/if}
  1193. {#if showImageGenerationButton}
  1194. <Tooltip content={$i18n.t('Generate an image')} placement="top">
  1195. <button
  1196. on:click|preventDefault={() =>
  1197. (imageGenerationEnabled = !imageGenerationEnabled)}
  1198. type="button"
  1199. class="px-2 @xl:px-2.5 py-2 flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {imageGenerationEnabled
  1200. ? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-200/5'
  1201. : 'bg-transparent text-gray-600 dark:text-gray-300 '}"
  1202. >
  1203. <Photo className="size-4" strokeWidth="1.75" />
  1204. <span
  1205. class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5"
  1206. >{$i18n.t('Image')}</span
  1207. >
  1208. </button>
  1209. </Tooltip>
  1210. {/if}
  1211. {#if showCodeInterpreterButton}
  1212. <Tooltip content={$i18n.t('Execute code for analysis')} placement="top">
  1213. <button
  1214. aria-label={codeInterpreterEnabled
  1215. ? $i18n.t('Disable Code Interpreter')
  1216. : $i18n.t('Enable Code Interpreter')}
  1217. aria-pressed={codeInterpreterEnabled}
  1218. on:click|preventDefault={() =>
  1219. (codeInterpreterEnabled = !codeInterpreterEnabled)}
  1220. type="button"
  1221. class="px-2 @xl:px-2.5 py-2 flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {codeInterpreterEnabled
  1222. ? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-200/5'
  1223. : 'bg-transparent text-gray-600 dark:text-gray-300 '}"
  1224. >
  1225. <CommandLine className="size-4" strokeWidth="1.75" />
  1226. <span
  1227. class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5"
  1228. >{$i18n.t('Code Interpreter')}</span
  1229. >
  1230. </button>
  1231. </Tooltip>
  1232. {/if}
  1233. </div>
  1234. {/if}
  1235. </div>
  1236. <div class="self-end flex space-x-1 mr-1 shrink-0">
  1237. {#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
  1238. <!-- {$i18n.t('Record voice')} -->
  1239. <Tooltip content={$i18n.t('Dictate')}>
  1240. <button
  1241. id="voice-input-button"
  1242. 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"
  1243. type="button"
  1244. on:click={async () => {
  1245. try {
  1246. let stream = await navigator.mediaDevices
  1247. .getUserMedia({ audio: true })
  1248. .catch(function (err) {
  1249. toast.error(
  1250. $i18n.t(
  1251. `Permission denied when accessing microphone: {{error}}`,
  1252. {
  1253. error: err
  1254. }
  1255. )
  1256. );
  1257. return null;
  1258. });
  1259. if (stream) {
  1260. recording = true;
  1261. const tracks = stream.getTracks();
  1262. tracks.forEach((track) => track.stop());
  1263. }
  1264. stream = null;
  1265. } catch {
  1266. toast.error($i18n.t('Permission denied when accessing microphone'));
  1267. }
  1268. }}
  1269. aria-label="Voice Input"
  1270. >
  1271. <svg
  1272. xmlns="http://www.w3.org/2000/svg"
  1273. viewBox="0 0 20 20"
  1274. fill="currentColor"
  1275. class="w-5 h-5 translate-y-[0.5px]"
  1276. >
  1277. <path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
  1278. <path
  1279. 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"
  1280. />
  1281. </svg>
  1282. </button>
  1283. </Tooltip>
  1284. {/if}
  1285. {#if (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true)}
  1286. <div class=" flex items-center">
  1287. <Tooltip content={$i18n.t('Stop')}>
  1288. <button
  1289. class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
  1290. on:click={() => {
  1291. stopResponse();
  1292. }}
  1293. >
  1294. <svg
  1295. xmlns="http://www.w3.org/2000/svg"
  1296. viewBox="0 0 24 24"
  1297. fill="currentColor"
  1298. class="size-5"
  1299. >
  1300. <path
  1301. fill-rule="evenodd"
  1302. d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
  1303. clip-rule="evenodd"
  1304. />
  1305. </svg>
  1306. </button>
  1307. </Tooltip>
  1308. </div>
  1309. {:else if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
  1310. <div class=" flex items-center">
  1311. <!-- {$i18n.t('Call')} -->
  1312. <Tooltip content={$i18n.t('Voice mode')}>
  1313. <button
  1314. class=" bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full p-1.5 self-center"
  1315. type="button"
  1316. on:click={async () => {
  1317. if (selectedModels.length > 1) {
  1318. toast.error($i18n.t('Select only one model to call'));
  1319. return;
  1320. }
  1321. if ($config.audio.stt.engine === 'web') {
  1322. toast.error(
  1323. $i18n.t('Call feature is not supported when using Web STT engine')
  1324. );
  1325. return;
  1326. }
  1327. // check if user has access to getUserMedia
  1328. try {
  1329. let stream = await navigator.mediaDevices.getUserMedia({
  1330. audio: true
  1331. });
  1332. // If the user grants the permission, proceed to show the call overlay
  1333. if (stream) {
  1334. const tracks = stream.getTracks();
  1335. tracks.forEach((track) => track.stop());
  1336. }
  1337. stream = null;
  1338. if ($settings.audio?.tts?.engine === 'browser-kokoro') {
  1339. // If the user has not initialized the TTS worker, initialize it
  1340. if (!$TTSWorker) {
  1341. await TTSWorker.set(
  1342. new KokoroWorker({
  1343. dtype: $settings.audio?.tts?.engineConfig?.dtype ?? 'fp32'
  1344. })
  1345. );
  1346. await $TTSWorker.init();
  1347. }
  1348. }
  1349. showCallOverlay.set(true);
  1350. showControls.set(true);
  1351. } catch (err) {
  1352. // If the user denies the permission or an error occurs, show an error message
  1353. toast.error(
  1354. $i18n.t('Permission denied when accessing media devices')
  1355. );
  1356. }
  1357. }}
  1358. aria-label={$i18n.t('Voice mode')}
  1359. >
  1360. <Headphone className="size-5" />
  1361. </button>
  1362. </Tooltip>
  1363. </div>
  1364. {:else}
  1365. <div class=" flex items-center">
  1366. <Tooltip content={$i18n.t('Send message')}>
  1367. <button
  1368. id="send-message-button"
  1369. class="{!(prompt === '' && files.length === 0)
  1370. ? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
  1371. : 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
  1372. type="submit"
  1373. disabled={prompt === '' && files.length === 0}
  1374. >
  1375. <svg
  1376. xmlns="http://www.w3.org/2000/svg"
  1377. viewBox="0 0 16 16"
  1378. fill="currentColor"
  1379. class="size-5"
  1380. >
  1381. <path
  1382. fill-rule="evenodd"
  1383. 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"
  1384. clip-rule="evenodd"
  1385. />
  1386. </svg>
  1387. </button>
  1388. </Tooltip>
  1389. </div>
  1390. {/if}
  1391. </div>
  1392. </div>
  1393. </div>
  1394. {#if $config?.license_metadata?.input_footer}
  1395. <div class=" text-xs text-gray-500 text-center line-clamp-1 marked">
  1396. {@html DOMPurify.sanitize(marked($config?.license_metadata?.input_footer))}
  1397. </div>
  1398. {:else}
  1399. <div class="mb-1" />
  1400. {/if}
  1401. </form>
  1402. {/if}
  1403. </div>
  1404. </div>
  1405. </div>
  1406. </div>
  1407. {/if}