1
0

MessageInput.svelte 61 KB

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