MessageInput.svelte 57 KB

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