MessageInput.svelte 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768
  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. showSidebar,
  15. models,
  16. config,
  17. showCallOverlay,
  18. tools,
  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 { WEBUI_BASE_URL, WEBUI_API_BASE_URL, PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
  44. import InputMenu from './MessageInput/InputMenu.svelte';
  45. import VoiceRecording from './MessageInput/VoiceRecording.svelte';
  46. import FilesOverlay from './MessageInput/FilesOverlay.svelte';
  47. import ToolServersModal from './ToolServersModal.svelte';
  48. import RichTextInput from '../common/RichTextInput.svelte';
  49. import Tooltip from '../common/Tooltip.svelte';
  50. import FileItem from '../common/FileItem.svelte';
  51. import Image from '../common/Image.svelte';
  52. import XMark from '../icons/XMark.svelte';
  53. import Headphone from '../icons/Headphone.svelte';
  54. import GlobeAlt from '../icons/GlobeAlt.svelte';
  55. import Photo from '../icons/Photo.svelte';
  56. import Wrench from '../icons/Wrench.svelte';
  57. import CommandLine from '../icons/CommandLine.svelte';
  58. import Sparkles from '../icons/Sparkles.svelte';
  59. import InputVariablesModal from './MessageInput/InputVariablesModal.svelte';
  60. import Voice from '../icons/Voice.svelte';
  61. import Terminal from '../icons/Terminal.svelte';
  62. import IntegrationsMenu from './MessageInput/IntegrationsMenu.svelte';
  63. import Component from '../icons/Component.svelte';
  64. import PlusAlt from '../icons/PlusAlt.svelte';
  65. import { KokoroWorker } from '$lib/workers/KokoroWorker';
  66. import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
  67. import CommandSuggestionList from './MessageInput/CommandSuggestionList.svelte';
  68. const i18n = getContext('i18n');
  69. export let onChange: Function = () => {};
  70. export let createMessagePair: Function;
  71. export let stopResponse: Function;
  72. export let autoScroll = false;
  73. export let generating = false;
  74. export let atSelectedModel: Model | undefined = undefined;
  75. export let selectedModels: [''];
  76. let selectedModelIds = [];
  77. $: selectedModelIds = atSelectedModel !== undefined ? [atSelectedModel.id] : selectedModels;
  78. export let history;
  79. export let taskIds = null;
  80. export let prompt = '';
  81. export let files = [];
  82. export let toolServers = [];
  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 = toolServers.length + selectedToolIds.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. });
  752. onDestroy(() => {
  753. console.log('destroy');
  754. window.removeEventListener('keydown', onKeyDown);
  755. window.removeEventListener('keyup', onKeyUp);
  756. window.removeEventListener('focus', onFocus);
  757. window.removeEventListener('blur', onBlur);
  758. const dropzoneElement = document.getElementById('chat-container');
  759. if (dropzoneElement) {
  760. dropzoneElement?.removeEventListener('dragover', onDragOver);
  761. dropzoneElement?.removeEventListener('drop', onDrop);
  762. dropzoneElement?.removeEventListener('dragleave', onDragLeave);
  763. }
  764. });
  765. </script>
  766. <FilesOverlay show={dragged} />
  767. <ToolServersModal bind:show={showTools} {selectedToolIds} />
  768. <InputVariablesModal
  769. bind:show={showInputVariablesModal}
  770. variables={inputVariables}
  771. onSave={inputVariablesModalCallback}
  772. />
  773. {#if loaded}
  774. <div class="w-full font-primary">
  775. <div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
  776. <div
  777. class="flex flex-col px-3 {($settings?.widescreenMode ?? null)
  778. ? 'max-w-full'
  779. : 'max-w-6xl'} w-full"
  780. >
  781. <div class="relative">
  782. {#if autoScroll === false && history?.currentId}
  783. <div
  784. class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none"
  785. >
  786. <button
  787. class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full pointer-events-auto"
  788. on:click={() => {
  789. autoScroll = true;
  790. scrollToBottom();
  791. }}
  792. >
  793. <svg
  794. xmlns="http://www.w3.org/2000/svg"
  795. viewBox="0 0 20 20"
  796. fill="currentColor"
  797. class="w-5 h-5"
  798. >
  799. <path
  800. fill-rule="evenodd"
  801. 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"
  802. clip-rule="evenodd"
  803. />
  804. </svg>
  805. </button>
  806. </div>
  807. {/if}
  808. </div>
  809. </div>
  810. </div>
  811. <div class="bg-transparent">
  812. <div
  813. class="{($settings?.widescreenMode ?? null)
  814. ? 'max-w-full'
  815. : 'max-w-6xl'} px-2.5 mx-auto inset-x-0"
  816. >
  817. <div class="">
  818. <input
  819. bind:this={filesInputElement}
  820. bind:files={inputFiles}
  821. type="file"
  822. hidden
  823. multiple
  824. on:change={async () => {
  825. if (inputFiles && inputFiles.length > 0) {
  826. const _inputFiles = Array.from(inputFiles);
  827. inputFilesHandler(_inputFiles);
  828. } else {
  829. toast.error($i18n.t(`File not found.`));
  830. }
  831. filesInputElement.value = '';
  832. }}
  833. />
  834. {#if recording}
  835. <VoiceRecording
  836. bind:recording
  837. onCancel={async () => {
  838. recording = false;
  839. await tick();
  840. document.getElementById('chat-input')?.focus();
  841. }}
  842. onConfirm={async (data) => {
  843. const { text, filename } = data;
  844. recording = false;
  845. await tick();
  846. insertTextAtCursor(text);
  847. await tick();
  848. document.getElementById('chat-input')?.focus();
  849. if ($settings?.speechAutoSend ?? false) {
  850. dispatch('submit', prompt);
  851. }
  852. }}
  853. />
  854. {:else}
  855. <form
  856. class="w-full flex flex-col gap-1.5"
  857. on:submit|preventDefault={() => {
  858. // check if selectedModels support image input
  859. dispatch('submit', prompt);
  860. }}
  861. >
  862. <div
  863. class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border {$temporaryChatEnabled
  864. ? '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'
  865. : ' 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"
  866. dir={$settings?.chatDirection ?? 'auto'}
  867. >
  868. {#if atSelectedModel !== undefined}
  869. <div class="px-3 pt-3 text-left w-full flex flex-col z-10">
  870. <div class="flex items-center justify-between w-full">
  871. <div class="pl-[1px] flex items-center gap-2 text-sm dark:text-gray-500">
  872. <img
  873. crossorigin="anonymous"
  874. alt="model profile"
  875. class="size-3.5 max-w-[28px] object-cover rounded-full"
  876. src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
  877. ?.profile_image_url ??
  878. ($i18n.language === 'dg-DG'
  879. ? `${WEBUI_BASE_URL}/doge.png`
  880. : `${WEBUI_BASE_URL}/static/favicon.png`)}
  881. />
  882. <div class="translate-y-[0.5px]">
  883. <span class="">{atSelectedModel.name}</span>
  884. </div>
  885. </div>
  886. <div>
  887. <button
  888. class="flex items-center dark:text-gray-500"
  889. on:click={() => {
  890. atSelectedModel = undefined;
  891. }}
  892. >
  893. <XMark />
  894. </button>
  895. </div>
  896. </div>
  897. </div>
  898. {/if}
  899. {#if files.length > 0}
  900. <div class="mx-2 mt-2.5 pb-1.5 flex items-center flex-wrap gap-2">
  901. {#each files as file, fileIdx}
  902. {#if file.type === 'image'}
  903. <div class=" relative group">
  904. <div class="relative flex items-center">
  905. <Image
  906. src={file.url}
  907. alt=""
  908. imageClassName=" size-10 rounded-xl object-cover"
  909. />
  910. {#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
  911. <Tooltip
  912. className=" absolute top-1 left-1"
  913. content={$i18n.t('{{ models }}', {
  914. models: [
  915. ...(atSelectedModel ? [atSelectedModel] : selectedModels)
  916. ]
  917. .filter((id) => !visionCapableModels.includes(id))
  918. .join(', ')
  919. })}
  920. >
  921. <svg
  922. xmlns="http://www.w3.org/2000/svg"
  923. viewBox="0 0 24 24"
  924. fill="currentColor"
  925. aria-hidden="true"
  926. class="size-4 fill-yellow-300"
  927. >
  928. <path
  929. fill-rule="evenodd"
  930. 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"
  931. clip-rule="evenodd"
  932. />
  933. </svg>
  934. </Tooltip>
  935. {/if}
  936. </div>
  937. <div class=" absolute -top-1 -right-1">
  938. <button
  939. class=" bg-white text-black border border-white rounded-full {($settings?.highContrastMode ??
  940. false)
  941. ? ''
  942. : 'outline-hidden focus:outline-hidden group-hover:visible invisible transition'}"
  943. type="button"
  944. aria-label={$i18n.t('Remove file')}
  945. on:click={() => {
  946. files.splice(fileIdx, 1);
  947. files = files;
  948. }}
  949. >
  950. <svg
  951. xmlns="http://www.w3.org/2000/svg"
  952. viewBox="0 0 20 20"
  953. fill="currentColor"
  954. aria-hidden="true"
  955. class="size-4"
  956. >
  957. <path
  958. 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"
  959. />
  960. </svg>
  961. </button>
  962. </div>
  963. </div>
  964. {:else}
  965. <FileItem
  966. item={file}
  967. name={file.name}
  968. type={file.type}
  969. size={file?.size}
  970. loading={file.status === 'uploading'}
  971. dismissible={true}
  972. edit={true}
  973. small={true}
  974. modal={['file', 'collection'].includes(file?.type)}
  975. on:dismiss={async () => {
  976. // Remove from UI state
  977. files.splice(fileIdx, 1);
  978. files = files;
  979. }}
  980. on:click={() => {
  981. console.log(file);
  982. }}
  983. />
  984. {/if}
  985. {/each}
  986. </div>
  987. {/if}
  988. <div class="px-2.5">
  989. <div
  990. 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-80 overflow-auto {files.length ===
  991. 0
  992. ? atSelectedModel !== undefined
  993. ? 'pt-1.5'
  994. : 'pt-2.5'
  995. : ''}"
  996. id="chat-input-container"
  997. >
  998. {#if suggestions}
  999. {#key $settings?.richTextInput ?? true}
  1000. {#key $settings?.showFormattingToolbar ?? false}
  1001. <RichTextInput
  1002. bind:this={chatInputElement}
  1003. id="chat-input"
  1004. onChange={(e) => {
  1005. prompt = e.md;
  1006. command = getCommand();
  1007. }}
  1008. json={true}
  1009. richText={$settings?.richTextInput ?? true}
  1010. messageInput={true}
  1011. showFormattingToolbar={$settings?.showFormattingToolbar ?? false}
  1012. floatingMenuPlacement={'top-start'}
  1013. insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false}
  1014. shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
  1015. (!$mobile ||
  1016. !(
  1017. 'ontouchstart' in window ||
  1018. navigator.maxTouchPoints > 0 ||
  1019. navigator.msMaxTouchPoints > 0
  1020. ))}
  1021. placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
  1022. largeTextAsFile={($settings?.largeTextAsFile ?? false) && !shiftKey}
  1023. autocomplete={$config?.features?.enable_autocomplete_generation &&
  1024. ($settings?.promptAutocomplete ?? false)}
  1025. generateAutoCompletion={async (text) => {
  1026. if (selectedModelIds.length === 0 || !selectedModelIds.at(0)) {
  1027. toast.error($i18n.t('Please select a model first.'));
  1028. }
  1029. const res = await generateAutoCompletion(
  1030. localStorage.token,
  1031. selectedModelIds.at(0),
  1032. text,
  1033. history?.currentId
  1034. ? createMessagesList(history, history.currentId)
  1035. : null
  1036. ).catch((error) => {
  1037. console.log(error);
  1038. return null;
  1039. });
  1040. console.log(res);
  1041. return res;
  1042. }}
  1043. {suggestions}
  1044. oncompositionstart={() => (isComposing = true)}
  1045. oncompositionend={(e) => {
  1046. compositionEndedAt = e.timeStamp;
  1047. isComposing = false;
  1048. }}
  1049. on:keydown={async (e) => {
  1050. e = e.detail.event;
  1051. const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
  1052. const suggestionsContainerElement =
  1053. document.getElementById('suggestions-container');
  1054. if (e.key === 'Escape') {
  1055. stopResponse();
  1056. }
  1057. // Command/Ctrl + Shift + Enter to submit a message pair
  1058. if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
  1059. e.preventDefault();
  1060. createMessagePair(prompt);
  1061. }
  1062. // Check if Ctrl + R is pressed
  1063. if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
  1064. e.preventDefault();
  1065. console.log('regenerate');
  1066. const regenerateButton = [
  1067. ...document.getElementsByClassName('regenerate-response-button')
  1068. ]?.at(-1);
  1069. regenerateButton?.click();
  1070. }
  1071. if (prompt === '' && e.key == 'ArrowUp') {
  1072. e.preventDefault();
  1073. const userMessageElement = [
  1074. ...document.getElementsByClassName('user-message')
  1075. ]?.at(-1);
  1076. if (userMessageElement) {
  1077. userMessageElement.scrollIntoView({ block: 'center' });
  1078. const editButton = [
  1079. ...document.getElementsByClassName('edit-user-message-button')
  1080. ]?.at(-1);
  1081. editButton?.click();
  1082. }
  1083. }
  1084. if (!suggestionsContainerElement) {
  1085. if (
  1086. !$mobile ||
  1087. !(
  1088. 'ontouchstart' in window ||
  1089. navigator.maxTouchPoints > 0 ||
  1090. navigator.msMaxTouchPoints > 0
  1091. )
  1092. ) {
  1093. if (inOrNearComposition(e)) {
  1094. return;
  1095. }
  1096. // Uses keyCode '13' for Enter key for chinese/japanese keyboards.
  1097. //
  1098. // Depending on the user's settings, it will send the message
  1099. // either when Enter is pressed or when Ctrl+Enter is pressed.
  1100. const enterPressed =
  1101. ($settings?.ctrlEnterToSend ?? false)
  1102. ? (e.key === 'Enter' || e.keyCode === 13) && isCtrlPressed
  1103. : (e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey;
  1104. if (enterPressed) {
  1105. e.preventDefault();
  1106. if (prompt !== '' || files.length > 0) {
  1107. dispatch('submit', prompt);
  1108. }
  1109. }
  1110. }
  1111. }
  1112. if (e.key === 'Escape') {
  1113. console.log('Escape');
  1114. atSelectedModel = undefined;
  1115. selectedToolIds = [];
  1116. selectedFilterIds = [];
  1117. webSearchEnabled = false;
  1118. imageGenerationEnabled = false;
  1119. codeInterpreterEnabled = false;
  1120. }
  1121. }}
  1122. on:paste={async (e) => {
  1123. e = e.detail.event;
  1124. console.log(e);
  1125. const clipboardData = e.clipboardData || window.clipboardData;
  1126. if (clipboardData && clipboardData.items) {
  1127. for (const item of clipboardData.items) {
  1128. if (item.type.indexOf('image') !== -1) {
  1129. const blob = item.getAsFile();
  1130. const reader = new FileReader();
  1131. reader.onload = function (e) {
  1132. files = [
  1133. ...files,
  1134. {
  1135. type: 'image',
  1136. url: `${e.target.result}`
  1137. }
  1138. ];
  1139. };
  1140. reader.readAsDataURL(blob);
  1141. } else if (item?.kind === 'file') {
  1142. const file = item.getAsFile();
  1143. if (file) {
  1144. const _files = [file];
  1145. await inputFilesHandler(_files);
  1146. e.preventDefault();
  1147. }
  1148. } else if (item.type === 'text/plain') {
  1149. if (($settings?.largeTextAsFile ?? false) && !shiftKey) {
  1150. const text = clipboardData.getData('text/plain');
  1151. if (text.length > PASTED_TEXT_CHARACTER_LIMIT) {
  1152. e.preventDefault();
  1153. const blob = new Blob([text], { type: 'text/plain' });
  1154. const file = new File(
  1155. [blob],
  1156. `Pasted_Text_${Date.now()}.txt`,
  1157. {
  1158. type: 'text/plain'
  1159. }
  1160. );
  1161. await uploadFileHandler(file, true);
  1162. }
  1163. }
  1164. }
  1165. }
  1166. }
  1167. }}
  1168. />
  1169. {/key}
  1170. {/key}
  1171. {/if}
  1172. </div>
  1173. </div>
  1174. <div class=" flex justify-between mt-0.5 mb-2.5 mx-0.5 max-w-full" dir="ltr">
  1175. <div class="ml-1 self-end flex items-center flex-1 max-w-[80%]">
  1176. <InputMenu
  1177. bind:files
  1178. selectedModels={atSelectedModel ? [atSelectedModel.id] : selectedModels}
  1179. {fileUploadCapableModels}
  1180. {screenCaptureHandler}
  1181. {inputFilesHandler}
  1182. uploadFilesHandler={() => {
  1183. filesInputElement.click();
  1184. }}
  1185. uploadGoogleDriveHandler={async () => {
  1186. try {
  1187. const fileData = await createPicker();
  1188. if (fileData) {
  1189. const file = new File([fileData.blob], fileData.name, {
  1190. type: fileData.blob.type
  1191. });
  1192. await uploadFileHandler(file);
  1193. } else {
  1194. console.log('No file was selected from Google Drive');
  1195. }
  1196. } catch (error) {
  1197. console.error('Google Drive Error:', error);
  1198. toast.error(
  1199. $i18n.t('Error accessing Google Drive: {{error}}', {
  1200. error: error.message
  1201. })
  1202. );
  1203. }
  1204. }}
  1205. uploadOneDriveHandler={async (authorityType) => {
  1206. try {
  1207. const fileData = await pickAndDownloadFile(authorityType);
  1208. if (fileData) {
  1209. const file = new File([fileData.blob], fileData.name, {
  1210. type: fileData.blob.type || 'application/octet-stream'
  1211. });
  1212. await uploadFileHandler(file);
  1213. } else {
  1214. console.log('No file was selected from OneDrive');
  1215. }
  1216. } catch (error) {
  1217. console.error('OneDrive Error:', error);
  1218. }
  1219. }}
  1220. onClose={async () => {
  1221. await tick();
  1222. const chatInput = document.getElementById('chat-input');
  1223. chatInput?.focus();
  1224. }}
  1225. >
  1226. <div
  1227. 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"
  1228. >
  1229. <PlusAlt className="size-5.5" />
  1230. </div>
  1231. </InputMenu>
  1232. <div class="flex self-center w-[1px] h-4 mx-1 bg-gray-50 dark:bg-gray-800" />
  1233. {#if showWebSearchButton || showImageGenerationButton || showCodeInterpreterButton || showToolsButton || (toggleFilters && toggleFilters.length > 0)}
  1234. <IntegrationsMenu
  1235. selectedModels={atSelectedModel ? [atSelectedModel.id] : selectedModels}
  1236. {toggleFilters}
  1237. {showWebSearchButton}
  1238. {showImageGenerationButton}
  1239. {showCodeInterpreterButton}
  1240. bind:selectedToolIds
  1241. bind:selectedFilterIds
  1242. bind:webSearchEnabled
  1243. bind:imageGenerationEnabled
  1244. bind:codeInterpreterEnabled
  1245. onClose={async () => {
  1246. await tick();
  1247. const chatInput = document.getElementById('chat-input');
  1248. chatInput?.focus();
  1249. }}
  1250. >
  1251. <div
  1252. 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"
  1253. >
  1254. <Component className="size-4.5" strokeWidth="1.5" />
  1255. </div>
  1256. </IntegrationsMenu>
  1257. {/if}
  1258. <div class="ml-1 flex gap-1.5">
  1259. {#if showToolsButton}
  1260. <Tooltip
  1261. content={$i18n.t('{{COUNT}} Available Tools', {
  1262. COUNT: toolServers.length + selectedToolIds.length
  1263. })}
  1264. >
  1265. <button
  1266. 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"
  1267. aria-label="Available Tools"
  1268. type="button"
  1269. on:click={() => {
  1270. showTools = !showTools;
  1271. }}
  1272. >
  1273. <Wrench className="size-4" strokeWidth="1.75" />
  1274. <span class="text-sm">
  1275. {toolServers.length + selectedToolIds.length}
  1276. </span>
  1277. </button>
  1278. </Tooltip>
  1279. {/if}
  1280. {#each selectedFilterIds as filterId}
  1281. {@const filter = toggleFilters.find((f) => f.id === filterId)}
  1282. {#if filter}
  1283. <Tooltip content={filter?.name} placement="top">
  1284. <button
  1285. on:click|preventDefault={() => {
  1286. selectedFilterIds = selectedFilterIds.filter(
  1287. (id) => id !== filterId
  1288. );
  1289. }}
  1290. type="button"
  1291. 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 hover:bg-gray-50 dark:hover:bg-gray-800 {selectedFilterIds.includes(
  1292. filterId
  1293. )
  1294. ? 'text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-400/10 border border-sky-200/40 dark:border-sky-500/20'
  1295. : 'bg-transparent text-gray-600 dark:text-gray-300 '} capitalize"
  1296. >
  1297. {#if filter?.icon}
  1298. <div class="size-4 items-center flex justify-center">
  1299. <img
  1300. src={filter.icon}
  1301. class="size-3.5 {filter.icon.includes('svg')
  1302. ? 'dark:invert-[80%]'
  1303. : ''}"
  1304. style="fill: currentColor;"
  1305. alt={filter.name}
  1306. />
  1307. </div>
  1308. {:else}
  1309. <Sparkles className="size-4" strokeWidth="1.75" />
  1310. {/if}
  1311. <div class="hidden group-hover:block">
  1312. <XMark className="size-4" strokeWidth="1.75" />
  1313. </div>
  1314. </button>
  1315. </Tooltip>
  1316. {/if}
  1317. {/each}
  1318. {#if webSearchEnabled}
  1319. <Tooltip content={$i18n.t('Web Search')} placement="top">
  1320. <button
  1321. on:click|preventDefault={() => (webSearchEnabled = !webSearchEnabled)}
  1322. type="button"
  1323. 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 hover:bg-gray-50 dark:hover:bg-gray-800 {webSearchEnabled ||
  1324. ($settings?.webSearch ?? false) === 'always'
  1325. ? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-400/10 border border-sky-200/40 dark:border-sky-500/20'
  1326. : 'bg-transparent text-gray-600 dark:text-gray-300 '}"
  1327. >
  1328. <GlobeAlt className="size-4" strokeWidth="1.75" />
  1329. <div class="hidden group-hover:block">
  1330. <XMark className="size-4" strokeWidth="1.75" />
  1331. </div>
  1332. </button>
  1333. </Tooltip>
  1334. {/if}
  1335. {#if imageGenerationEnabled}
  1336. <Tooltip content={$i18n.t('Image')} placement="top">
  1337. <button
  1338. on:click|preventDefault={() =>
  1339. (imageGenerationEnabled = !imageGenerationEnabled)}
  1340. type="button"
  1341. 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 hover:bg-gray-50 dark:hover:bg-gray-800 {imageGenerationEnabled
  1342. ? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-400/10 border border-sky-200/40 dark:border-sky-500/20'
  1343. : 'bg-transparent text-gray-600 dark:text-gray-300 '}"
  1344. >
  1345. <Photo className="size-4" strokeWidth="1.75" />
  1346. <div class="hidden group-hover:block">
  1347. <XMark className="size-4" strokeWidth="1.75" />
  1348. </div>
  1349. </button>
  1350. </Tooltip>
  1351. {/if}
  1352. {#if codeInterpreterEnabled}
  1353. <Tooltip content={$i18n.t('Code Interpreter')} placement="top">
  1354. <button
  1355. aria-label={codeInterpreterEnabled
  1356. ? $i18n.t('Disable Code Interpreter')
  1357. : $i18n.t('Enable Code Interpreter')}
  1358. aria-pressed={codeInterpreterEnabled}
  1359. on:click|preventDefault={() =>
  1360. (codeInterpreterEnabled = !codeInterpreterEnabled)}
  1361. type="button"
  1362. class=" group p-[7px] flex gap-1.5 items-center text-sm transition-colors duration-300 max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {codeInterpreterEnabled
  1363. ? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-400/10 border border-sky-200/40 dark:border-sky-500/20'
  1364. : 'bg-transparent text-gray-600 dark:text-gray-300 '} {($settings?.highContrastMode ??
  1365. false)
  1366. ? 'm-1'
  1367. : 'focus:outline-hidden rounded-full'}"
  1368. >
  1369. <Terminal className="size-3.5" strokeWidth="2" />
  1370. <div class="hidden group-hover:block">
  1371. <XMark className="size-4" strokeWidth="1.75" />
  1372. </div>
  1373. </button>
  1374. </Tooltip>
  1375. {/if}
  1376. </div>
  1377. </div>
  1378. <div class="self-end flex space-x-1 mr-1 shrink-0">
  1379. {#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
  1380. <!-- {$i18n.t('Record voice')} -->
  1381. <Tooltip content={$i18n.t('Dictate')}>
  1382. <button
  1383. id="voice-input-button"
  1384. 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"
  1385. type="button"
  1386. on:click={async () => {
  1387. try {
  1388. let stream = await navigator.mediaDevices
  1389. .getUserMedia({ audio: true })
  1390. .catch(function (err) {
  1391. toast.error(
  1392. $i18n.t(
  1393. `Permission denied when accessing microphone: {{error}}`,
  1394. {
  1395. error: err
  1396. }
  1397. )
  1398. );
  1399. return null;
  1400. });
  1401. if (stream) {
  1402. recording = true;
  1403. const tracks = stream.getTracks();
  1404. tracks.forEach((track) => track.stop());
  1405. }
  1406. stream = null;
  1407. } catch {
  1408. toast.error($i18n.t('Permission denied when accessing microphone'));
  1409. }
  1410. }}
  1411. aria-label="Voice Input"
  1412. >
  1413. <svg
  1414. xmlns="http://www.w3.org/2000/svg"
  1415. viewBox="0 0 20 20"
  1416. fill="currentColor"
  1417. class="w-5 h-5 translate-y-[0.5px]"
  1418. >
  1419. <path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
  1420. <path
  1421. 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"
  1422. />
  1423. </svg>
  1424. </button>
  1425. </Tooltip>
  1426. {/if}
  1427. {#if (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true) || generating}
  1428. <div class=" flex items-center">
  1429. <Tooltip content={$i18n.t('Stop')}>
  1430. <button
  1431. 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"
  1432. on:click={() => {
  1433. stopResponse();
  1434. }}
  1435. >
  1436. <svg
  1437. xmlns="http://www.w3.org/2000/svg"
  1438. viewBox="0 0 24 24"
  1439. fill="currentColor"
  1440. class="size-5"
  1441. >
  1442. <path
  1443. fill-rule="evenodd"
  1444. 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"
  1445. clip-rule="evenodd"
  1446. />
  1447. </svg>
  1448. </button>
  1449. </Tooltip>
  1450. </div>
  1451. {:else if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
  1452. <div class=" flex items-center">
  1453. <!-- {$i18n.t('Call')} -->
  1454. <Tooltip content={$i18n.t('Voice mode')}>
  1455. <button
  1456. 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"
  1457. type="button"
  1458. on:click={async () => {
  1459. if (selectedModels.length > 1) {
  1460. toast.error($i18n.t('Select only one model to call'));
  1461. return;
  1462. }
  1463. if ($config.audio.stt.engine === 'web') {
  1464. toast.error(
  1465. $i18n.t('Call feature is not supported when using Web STT engine')
  1466. );
  1467. return;
  1468. }
  1469. // check if user has access to getUserMedia
  1470. try {
  1471. let stream = await navigator.mediaDevices.getUserMedia({
  1472. audio: true
  1473. });
  1474. // If the user grants the permission, proceed to show the call overlay
  1475. if (stream) {
  1476. const tracks = stream.getTracks();
  1477. tracks.forEach((track) => track.stop());
  1478. }
  1479. stream = null;
  1480. if ($settings.audio?.tts?.engine === 'browser-kokoro') {
  1481. // If the user has not initialized the TTS worker, initialize it
  1482. if (!$TTSWorker) {
  1483. await TTSWorker.set(
  1484. new KokoroWorker({
  1485. dtype: $settings.audio?.tts?.engineConfig?.dtype ?? 'fp32'
  1486. })
  1487. );
  1488. await $TTSWorker.init();
  1489. }
  1490. }
  1491. showCallOverlay.set(true);
  1492. showControls.set(true);
  1493. } catch (err) {
  1494. // If the user denies the permission or an error occurs, show an error message
  1495. toast.error(
  1496. $i18n.t('Permission denied when accessing media devices')
  1497. );
  1498. }
  1499. }}
  1500. aria-label={$i18n.t('Voice mode')}
  1501. >
  1502. <Voice className="size-5" strokeWidth="2.5" />
  1503. </button>
  1504. </Tooltip>
  1505. </div>
  1506. {:else}
  1507. <div class=" flex items-center">
  1508. <Tooltip content={$i18n.t('Send message')}>
  1509. <button
  1510. id="send-message-button"
  1511. class="{!(prompt === '' && files.length === 0)
  1512. ? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
  1513. : 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
  1514. type="submit"
  1515. disabled={prompt === '' && files.length === 0}
  1516. >
  1517. <svg
  1518. xmlns="http://www.w3.org/2000/svg"
  1519. viewBox="0 0 16 16"
  1520. fill="currentColor"
  1521. class="size-5"
  1522. >
  1523. <path
  1524. fill-rule="evenodd"
  1525. 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"
  1526. clip-rule="evenodd"
  1527. />
  1528. </svg>
  1529. </button>
  1530. </Tooltip>
  1531. </div>
  1532. {/if}
  1533. </div>
  1534. </div>
  1535. </div>
  1536. {#if $config?.license_metadata?.input_footer}
  1537. <div class=" text-xs text-gray-500 text-center line-clamp-1 marked">
  1538. {@html DOMPurify.sanitize(marked($config?.license_metadata?.input_footer))}
  1539. </div>
  1540. {:else}
  1541. <div class="mb-1" />
  1542. {/if}
  1543. </form>
  1544. {/if}
  1545. </div>
  1546. </div>
  1547. </div>
  1548. </div>
  1549. {/if}