ResponseMessage.svelte 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import dayjs from 'dayjs';
  4. import { createEventDispatcher } from 'svelte';
  5. import { onMount, tick, getContext } from 'svelte';
  6. const i18n = getContext<Writable<i18nType>>('i18n');
  7. const dispatch = createEventDispatcher();
  8. import { config, models, settings, user } from '$lib/stores';
  9. import { synthesizeOpenAISpeech } from '$lib/apis/audio';
  10. import { imageGenerations } from '$lib/apis/images';
  11. import {
  12. copyToClipboard as _copyToClipboard,
  13. approximateToHumanReadable,
  14. extractParagraphsForAudio,
  15. extractSentencesForAudio,
  16. cleanText,
  17. getMessageContentParts,
  18. sanitizeResponseContent
  19. } from '$lib/utils';
  20. import { WEBUI_BASE_URL } from '$lib/constants';
  21. import Name from './Name.svelte';
  22. import ProfileImage from './ProfileImage.svelte';
  23. import Skeleton from './Skeleton.svelte';
  24. import Image from '$lib/components/common/Image.svelte';
  25. import Tooltip from '$lib/components/common/Tooltip.svelte';
  26. import RateComment from './RateComment.svelte';
  27. import Spinner from '$lib/components/common/Spinner.svelte';
  28. import WebSearchResults from './ResponseMessage/WebSearchResults.svelte';
  29. import Sparkles from '$lib/components/icons/Sparkles.svelte';
  30. import Markdown from './Markdown.svelte';
  31. import Error from './Error.svelte';
  32. import Citations from './Citations.svelte';
  33. import CodeExecutions from './CodeExecutions.svelte';
  34. import type { Writable } from 'svelte/store';
  35. import type { i18n as i18nType } from 'i18next';
  36. import ContentRenderer from './ContentRenderer.svelte';
  37. import { createNewFeedback, getFeedbackById, updateFeedbackById } from '$lib/apis/evaluations';
  38. import { getChatById } from '$lib/apis/chats';
  39. interface MessageType {
  40. id: string;
  41. model: string;
  42. content: string;
  43. files?: { type: string; url: string }[];
  44. timestamp: number;
  45. role: string;
  46. statusHistory?: {
  47. done: boolean;
  48. action: string;
  49. description: string;
  50. urls?: string[];
  51. query?: string;
  52. }[];
  53. status?: {
  54. done: boolean;
  55. action: string;
  56. description: string;
  57. urls?: string[];
  58. query?: string;
  59. };
  60. done: boolean;
  61. error?: boolean | { content: string };
  62. citations?: string[];
  63. code_executions?: {
  64. uuid: string;
  65. name: string;
  66. code: string;
  67. language?: string;
  68. result?: {
  69. error?: string;
  70. output?: string;
  71. files?: { name: string; url: string }[];
  72. };
  73. }[];
  74. info?: {
  75. openai?: boolean;
  76. prompt_tokens?: number;
  77. completion_tokens?: number;
  78. total_tokens?: number;
  79. eval_count?: number;
  80. eval_duration?: number;
  81. prompt_eval_count?: number;
  82. prompt_eval_duration?: number;
  83. total_duration?: number;
  84. load_duration?: number;
  85. usage?: unknown;
  86. };
  87. annotation?: { type: string; rating: number };
  88. }
  89. export let chatId = '';
  90. export let history;
  91. export let messageId;
  92. let message: MessageType = JSON.parse(JSON.stringify(history.messages[messageId]));
  93. $: if (history.messages) {
  94. if (JSON.stringify(message) !== JSON.stringify(history.messages[messageId])) {
  95. message = JSON.parse(JSON.stringify(history.messages[messageId]));
  96. }
  97. }
  98. export let siblings;
  99. export let showPreviousMessage: Function;
  100. export let showNextMessage: Function;
  101. export let editMessage: Function;
  102. export let rateMessage: Function;
  103. export let continueResponse: Function;
  104. export let regenerateResponse: Function;
  105. export let isLastMessage = true;
  106. export let readOnly = false;
  107. let model = null;
  108. $: model = $models.find((m) => m.id === message.model);
  109. let edit = false;
  110. let editedContent = '';
  111. let editTextAreaElement: HTMLTextAreaElement;
  112. let audioParts: Record<number, HTMLAudioElement | null> = {};
  113. let speaking = false;
  114. let speakingIdx: number | undefined;
  115. let loadingSpeech = false;
  116. let generatingImage = false;
  117. let showRateComment = false;
  118. const copyToClipboard = async (text) => {
  119. const res = await _copyToClipboard(text);
  120. if (res) {
  121. toast.success($i18n.t('Copying to clipboard was successful!'));
  122. }
  123. };
  124. const playAudio = (idx: number) => {
  125. return new Promise<void>((res) => {
  126. speakingIdx = idx;
  127. const audio = audioParts[idx];
  128. if (!audio) {
  129. return res();
  130. }
  131. audio.play();
  132. audio.onended = async () => {
  133. await new Promise((r) => setTimeout(r, 300));
  134. if (Object.keys(audioParts).length - 1 === idx) {
  135. speaking = false;
  136. }
  137. res();
  138. };
  139. });
  140. };
  141. const toggleSpeakMessage = async () => {
  142. if (speaking) {
  143. try {
  144. speechSynthesis.cancel();
  145. if (speakingIdx !== undefined && audioParts[speakingIdx]) {
  146. audioParts[speakingIdx]!.pause();
  147. audioParts[speakingIdx]!.currentTime = 0;
  148. }
  149. } catch {}
  150. speaking = false;
  151. speakingIdx = undefined;
  152. return;
  153. }
  154. if (!(message?.content ?? '').trim().length) {
  155. toast.info($i18n.t('No content to speak'));
  156. return;
  157. }
  158. speaking = true;
  159. if ($config.audio.tts.engine !== '') {
  160. loadingSpeech = true;
  161. const messageContentParts: string[] = getMessageContentParts(
  162. message.content,
  163. $config?.audio?.tts?.split_on ?? 'punctuation'
  164. );
  165. if (!messageContentParts.length) {
  166. console.log('No content to speak');
  167. toast.info($i18n.t('No content to speak'));
  168. speaking = false;
  169. loadingSpeech = false;
  170. return;
  171. }
  172. console.debug('Prepared message content for TTS', messageContentParts);
  173. audioParts = messageContentParts.reduce(
  174. (acc, _sentence, idx) => {
  175. acc[idx] = null;
  176. return acc;
  177. },
  178. {} as typeof audioParts
  179. );
  180. let lastPlayedAudioPromise = Promise.resolve(); // Initialize a promise that resolves immediately
  181. for (const [idx, sentence] of messageContentParts.entries()) {
  182. const res = await synthesizeOpenAISpeech(
  183. localStorage.token,
  184. $settings?.audio?.tts?.defaultVoice === $config.audio.tts.voice
  185. ? ($settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice)
  186. : $config?.audio?.tts?.voice,
  187. sentence
  188. ).catch((error) => {
  189. console.error(error);
  190. toast.error(error);
  191. speaking = false;
  192. loadingSpeech = false;
  193. });
  194. if (res) {
  195. const blob = await res.blob();
  196. const blobUrl = URL.createObjectURL(blob);
  197. const audio = new Audio(blobUrl);
  198. audio.playbackRate = $settings.audio?.tts?.playbackRate ?? 1;
  199. audioParts[idx] = audio;
  200. loadingSpeech = false;
  201. lastPlayedAudioPromise = lastPlayedAudioPromise.then(() => playAudio(idx));
  202. }
  203. }
  204. } else {
  205. let voices = [];
  206. const getVoicesLoop = setInterval(() => {
  207. voices = speechSynthesis.getVoices();
  208. if (voices.length > 0) {
  209. clearInterval(getVoicesLoop);
  210. const voice =
  211. voices
  212. ?.filter(
  213. (v) => v.voiceURI === ($settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice)
  214. )
  215. ?.at(0) ?? undefined;
  216. console.log(voice);
  217. const speak = new SpeechSynthesisUtterance(message.content);
  218. speak.rate = $settings.audio?.tts?.playbackRate ?? 1;
  219. console.log(speak);
  220. speak.onend = () => {
  221. speaking = false;
  222. if ($settings.conversationMode) {
  223. document.getElementById('voice-input-button')?.click();
  224. }
  225. };
  226. if (voice) {
  227. speak.voice = voice;
  228. }
  229. speechSynthesis.speak(speak);
  230. }
  231. }, 100);
  232. }
  233. };
  234. const editMessageHandler = async () => {
  235. edit = true;
  236. editedContent = message.content;
  237. await tick();
  238. editTextAreaElement.style.height = '';
  239. editTextAreaElement.style.height = `${editTextAreaElement.scrollHeight}px`;
  240. };
  241. const editMessageConfirmHandler = async () => {
  242. editMessage(message.id, editedContent ? editedContent : '', false);
  243. edit = false;
  244. editedContent = '';
  245. await tick();
  246. };
  247. const saveAsCopyHandler = async () => {
  248. editMessage(message.id, editedContent ? editedContent : '');
  249. edit = false;
  250. editedContent = '';
  251. await tick();
  252. };
  253. const cancelEditMessage = async () => {
  254. edit = false;
  255. editedContent = '';
  256. await tick();
  257. };
  258. const generateImage = async (message: MessageType) => {
  259. generatingImage = true;
  260. const res = await imageGenerations(localStorage.token, message.content).catch((error) => {
  261. toast.error(error);
  262. });
  263. console.log(res);
  264. if (res) {
  265. const files = res.map((image) => ({
  266. type: 'image',
  267. url: `${image.url}`
  268. }));
  269. dispatch('save', { ...message, files: files });
  270. }
  271. generatingImage = false;
  272. };
  273. const feedbackHandler = async (
  274. rating: number | null = null,
  275. annotation: object | null = null
  276. ) => {
  277. console.log('Feedback', rating, annotation);
  278. const updatedMessage = {
  279. ...message,
  280. annotation: {
  281. ...(message?.annotation ?? {}),
  282. ...(rating !== null ? { rating: rating } : {}),
  283. ...(annotation ? annotation : {})
  284. }
  285. };
  286. const chat = await getChatById(localStorage.token, chatId).catch((error) => {
  287. toast.error(error);
  288. });
  289. if (!chat) {
  290. return;
  291. }
  292. let feedbackItem = {
  293. type: 'rating',
  294. data: {
  295. ...(updatedMessage?.annotation ? updatedMessage.annotation : {}),
  296. model_id: message?.selectedModelId ?? message.model,
  297. ...(history.messages[message.parentId].childrenIds.length > 1
  298. ? {
  299. sibling_model_ids: history.messages[message.parentId].childrenIds
  300. .filter((id) => id !== message.id)
  301. .map((id) => history.messages[id]?.selectedModelId ?? history.messages[id].model)
  302. }
  303. : {})
  304. },
  305. meta: {
  306. arena: message ? message.arena : false,
  307. message_id: message.id,
  308. chat_id: chatId
  309. },
  310. snapshot: {
  311. chat: chat
  312. }
  313. };
  314. let feedback = null;
  315. if (message?.feedbackId) {
  316. feedback = await updateFeedbackById(
  317. localStorage.token,
  318. message.feedbackId,
  319. feedbackItem
  320. ).catch((error) => {
  321. toast.error(error);
  322. });
  323. } else {
  324. feedback = await createNewFeedback(localStorage.token, feedbackItem).catch((error) => {
  325. toast.error(error);
  326. });
  327. if (feedback) {
  328. updatedMessage.feedbackId = feedback.id;
  329. }
  330. }
  331. console.log(updatedMessage);
  332. dispatch('save', updatedMessage);
  333. await tick();
  334. if (!annotation) {
  335. showRateComment = true;
  336. }
  337. };
  338. $: if (!edit) {
  339. (async () => {
  340. await tick();
  341. })();
  342. }
  343. onMount(async () => {
  344. console.log('ResponseMessage mounted');
  345. await tick();
  346. });
  347. </script>
  348. {#key message.id}
  349. <div
  350. class=" flex w-full message-{message.id}"
  351. id="message-{message.id}"
  352. dir={$settings.chatDirection}
  353. >
  354. <ProfileImage
  355. src={model?.info?.meta?.profile_image_url ??
  356. ($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
  357. />
  358. <div class="flex-auto w-0 pl-1">
  359. <Name>
  360. {model?.name ?? message.model}
  361. {#if message.timestamp}
  362. <span
  363. class=" self-center invisible group-hover:visible text-gray-400 text-xs font-medium uppercase ml-0.5 -mt-0.5"
  364. >
  365. {dayjs(message.timestamp * 1000).format($i18n.t('h:mm a'))}
  366. </span>
  367. {/if}
  368. </Name>
  369. <div>
  370. {#if message?.files && message.files?.filter((f) => f.type === 'image').length > 0}
  371. <div class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap">
  372. {#each message.files as file}
  373. <div>
  374. {#if file.type === 'image'}
  375. <Image src={file.url} alt={message.content} />
  376. {/if}
  377. </div>
  378. {/each}
  379. </div>
  380. {/if}
  381. <div class="chat-{message.role} w-full min-w-full markdown-prose">
  382. <div>
  383. {#if (message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]).length > 0}
  384. {@const status = (
  385. message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]
  386. ).at(-1)}
  387. <div class="status-description flex items-center gap-2 pt-0.5 pb-1">
  388. {#if status?.done === false}
  389. <div class="">
  390. <Spinner className="size-4" />
  391. </div>
  392. {/if}
  393. {#if status?.action === 'web_search' && status?.urls}
  394. <WebSearchResults {status}>
  395. <div class="flex flex-col justify-center -space-y-0.5">
  396. <div
  397. class="{status?.done === false
  398. ? 'shimmer'
  399. : ''} text-base line-clamp-1 text-wrap"
  400. >
  401. {status?.description}
  402. </div>
  403. </div>
  404. </WebSearchResults>
  405. {:else}
  406. <div class="flex flex-col justify-center -space-y-0.5">
  407. <div
  408. class="{status?.done === false
  409. ? 'shimmer'
  410. : ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
  411. >
  412. {status?.description}
  413. </div>
  414. </div>
  415. {/if}
  416. </div>
  417. {/if}
  418. {#if edit === true}
  419. <div class="w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-5 py-3 my-2">
  420. <textarea
  421. id="message-edit-{message.id}"
  422. bind:this={editTextAreaElement}
  423. class=" bg-transparent outline-none w-full resize-none"
  424. bind:value={editedContent}
  425. on:input={(e) => {
  426. e.target.style.height = '';
  427. e.target.style.height = `${e.target.scrollHeight}px`;
  428. }}
  429. on:keydown={(e) => {
  430. if (e.key === 'Escape') {
  431. document.getElementById('close-edit-message-button')?.click();
  432. }
  433. const isCmdOrCtrlPressed = e.metaKey || e.ctrlKey;
  434. const isEnterPressed = e.key === 'Enter';
  435. if (isCmdOrCtrlPressed && isEnterPressed) {
  436. document.getElementById('confirm-edit-message-button')?.click();
  437. }
  438. }}
  439. />
  440. <div class=" mt-2 mb-1 flex justify-between text-sm font-medium">
  441. <div>
  442. <button
  443. id="save-new-message-button"
  444. class=" px-4 py-2 bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 border dark:border-gray-700 text-gray-700 dark:text-gray-200 transition rounded-3xl"
  445. on:click={() => {
  446. saveAsCopyHandler();
  447. }}
  448. >
  449. {$i18n.t('Save As Copy')}
  450. </button>
  451. </div>
  452. <div class="flex space-x-1.5">
  453. <button
  454. id="close-edit-message-button"
  455. class="px-4 py-2 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl"
  456. on:click={() => {
  457. cancelEditMessage();
  458. }}
  459. >
  460. {$i18n.t('Cancel')}
  461. </button>
  462. <button
  463. id="confirm-edit-message-button"
  464. class=" px-4 py-2 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl"
  465. on:click={() => {
  466. editMessageConfirmHandler();
  467. }}
  468. >
  469. {$i18n.t('Save')}
  470. </button>
  471. </div>
  472. </div>
  473. </div>
  474. {:else}
  475. <div class="w-full flex flex-col relative" id="response-content-container">
  476. {#if message.content === '' && !message.error}
  477. <Skeleton />
  478. {:else if message.content && message.error !== true}
  479. <!-- always show message contents even if there's an error -->
  480. <!-- unless message.error === true which is legacy error handling, where the error message is stored in message.content -->
  481. <ContentRenderer
  482. id={message.id}
  483. content={message.content}
  484. floatingButtons={message?.done}
  485. save={!readOnly}
  486. {model}
  487. on:update={(e) => {
  488. const { raw, oldContent, newContent } = e.detail;
  489. history.messages[message.id].content = history.messages[
  490. message.id
  491. ].content.replace(raw, raw.replace(oldContent, newContent));
  492. dispatch('update');
  493. }}
  494. on:select={(e) => {
  495. const { type, content } = e.detail;
  496. if (type === 'explain') {
  497. dispatch('submit', {
  498. parentId: message.id,
  499. prompt: `Explain this section to me in more detail\n\n\`\`\`\n${content}\n\`\`\``
  500. });
  501. } else if (type === 'ask') {
  502. const input = e.detail?.input ?? '';
  503. dispatch('submit', {
  504. parentId: message.id,
  505. prompt: `\`\`\`\n${content}\n\`\`\`\n${input}`
  506. });
  507. }
  508. }}
  509. />
  510. {/if}
  511. {#if message.error}
  512. <Error content={message?.error?.content ?? message.content} />
  513. {/if}
  514. {#if message.citations}
  515. <Citations citations={message.citations} />
  516. {/if}
  517. {#if message.code_executions}
  518. <CodeExecutions codeExecutions={message.code_executions} />
  519. {/if}
  520. </div>
  521. {/if}
  522. </div>
  523. </div>
  524. {#if !edit}
  525. {#if message.done || siblings.length > 1}
  526. <div
  527. class=" flex justify-start overflow-x-auto buttons text-gray-600 dark:text-gray-500 mt-0.5"
  528. >
  529. {#if siblings.length > 1}
  530. <div class="flex self-center min-w-fit" dir="ltr">
  531. <button
  532. class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
  533. on:click={() => {
  534. showPreviousMessage(message);
  535. }}
  536. >
  537. <svg
  538. xmlns="http://www.w3.org/2000/svg"
  539. fill="none"
  540. viewBox="0 0 24 24"
  541. stroke="currentColor"
  542. stroke-width="2.5"
  543. class="size-3.5"
  544. >
  545. <path
  546. stroke-linecap="round"
  547. stroke-linejoin="round"
  548. d="M15.75 19.5 8.25 12l7.5-7.5"
  549. />
  550. </svg>
  551. </button>
  552. <div
  553. class="text-sm tracking-widest font-semibold self-center dark:text-gray-100 min-w-fit"
  554. >
  555. {siblings.indexOf(message.id) + 1}/{siblings.length}
  556. </div>
  557. <button
  558. class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
  559. on:click={() => {
  560. showNextMessage(message);
  561. }}
  562. >
  563. <svg
  564. xmlns="http://www.w3.org/2000/svg"
  565. fill="none"
  566. viewBox="0 0 24 24"
  567. stroke="currentColor"
  568. stroke-width="2.5"
  569. class="size-3.5"
  570. >
  571. <path
  572. stroke-linecap="round"
  573. stroke-linejoin="round"
  574. d="m8.25 4.5 7.5 7.5-7.5 7.5"
  575. />
  576. </svg>
  577. </button>
  578. </div>
  579. {/if}
  580. {#if message.done}
  581. {#if !readOnly}
  582. {#if $user.role === 'user' ? ($config?.permissions?.chat?.editing ?? true) : true}
  583. <Tooltip content={$i18n.t('Edit')} placement="bottom">
  584. <button
  585. class="{isLastMessage
  586. ? 'visible'
  587. : 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
  588. on:click={() => {
  589. editMessageHandler();
  590. }}
  591. >
  592. <svg
  593. xmlns="http://www.w3.org/2000/svg"
  594. fill="none"
  595. viewBox="0 0 24 24"
  596. stroke-width="2.3"
  597. stroke="currentColor"
  598. class="w-4 h-4"
  599. >
  600. <path
  601. stroke-linecap="round"
  602. stroke-linejoin="round"
  603. d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
  604. />
  605. </svg>
  606. </button>
  607. </Tooltip>
  608. {/if}
  609. {/if}
  610. <Tooltip content={$i18n.t('Copy')} placement="bottom">
  611. <button
  612. class="{isLastMessage
  613. ? 'visible'
  614. : 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition copy-response-button"
  615. on:click={() => {
  616. copyToClipboard(message.content);
  617. }}
  618. >
  619. <svg
  620. xmlns="http://www.w3.org/2000/svg"
  621. fill="none"
  622. viewBox="0 0 24 24"
  623. stroke-width="2.3"
  624. stroke="currentColor"
  625. class="w-4 h-4"
  626. >
  627. <path
  628. stroke-linecap="round"
  629. stroke-linejoin="round"
  630. d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
  631. />
  632. </svg>
  633. </button>
  634. </Tooltip>
  635. <Tooltip content={$i18n.t('Read Aloud')} placement="bottom">
  636. <button
  637. id="speak-button-{message.id}"
  638. class="{isLastMessage
  639. ? 'visible'
  640. : 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
  641. on:click={() => {
  642. if (!loadingSpeech) {
  643. toggleSpeakMessage();
  644. }
  645. }}
  646. >
  647. {#if loadingSpeech}
  648. <svg
  649. class=" w-4 h-4"
  650. fill="currentColor"
  651. viewBox="0 0 24 24"
  652. xmlns="http://www.w3.org/2000/svg"
  653. >
  654. <style>
  655. .spinner_S1WN {
  656. animation: spinner_MGfb 0.8s linear infinite;
  657. animation-delay: -0.8s;
  658. }
  659. .spinner_Km9P {
  660. animation-delay: -0.65s;
  661. }
  662. .spinner_JApP {
  663. animation-delay: -0.5s;
  664. }
  665. @keyframes spinner_MGfb {
  666. 93.75%,
  667. 100% {
  668. opacity: 0.2;
  669. }
  670. }
  671. </style>
  672. <circle class="spinner_S1WN" cx="4" cy="12" r="3" />
  673. <circle class="spinner_S1WN spinner_Km9P" cx="12" cy="12" r="3" />
  674. <circle class="spinner_S1WN spinner_JApP" cx="20" cy="12" r="3" />
  675. </svg>
  676. {:else if speaking}
  677. <svg
  678. xmlns="http://www.w3.org/2000/svg"
  679. fill="none"
  680. viewBox="0 0 24 24"
  681. stroke-width="2.3"
  682. stroke="currentColor"
  683. class="w-4 h-4"
  684. >
  685. <path
  686. stroke-linecap="round"
  687. stroke-linejoin="round"
  688. d="M17.25 9.75 19.5 12m0 0 2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6 4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"
  689. />
  690. </svg>
  691. {:else}
  692. <svg
  693. xmlns="http://www.w3.org/2000/svg"
  694. fill="none"
  695. viewBox="0 0 24 24"
  696. stroke-width="2.3"
  697. stroke="currentColor"
  698. class="w-4 h-4"
  699. >
  700. <path
  701. stroke-linecap="round"
  702. stroke-linejoin="round"
  703. d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"
  704. />
  705. </svg>
  706. {/if}
  707. </button>
  708. </Tooltip>
  709. {#if $config?.features.enable_image_generation && !readOnly}
  710. <Tooltip content={$i18n.t('Generate Image')} placement="bottom">
  711. <button
  712. class="{isLastMessage
  713. ? 'visible'
  714. : 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
  715. on:click={() => {
  716. if (!generatingImage) {
  717. generateImage(message);
  718. }
  719. }}
  720. >
  721. {#if generatingImage}
  722. <svg
  723. class=" w-4 h-4"
  724. fill="currentColor"
  725. viewBox="0 0 24 24"
  726. xmlns="http://www.w3.org/2000/svg"
  727. >
  728. <style>
  729. .spinner_S1WN {
  730. animation: spinner_MGfb 0.8s linear infinite;
  731. animation-delay: -0.8s;
  732. }
  733. .spinner_Km9P {
  734. animation-delay: -0.65s;
  735. }
  736. .spinner_JApP {
  737. animation-delay: -0.5s;
  738. }
  739. @keyframes spinner_MGfb {
  740. 93.75%,
  741. 100% {
  742. opacity: 0.2;
  743. }
  744. }
  745. </style>
  746. <circle class="spinner_S1WN" cx="4" cy="12" r="3" />
  747. <circle class="spinner_S1WN spinner_Km9P" cx="12" cy="12" r="3" />
  748. <circle class="spinner_S1WN spinner_JApP" cx="20" cy="12" r="3" />
  749. </svg>
  750. {:else}
  751. <svg
  752. xmlns="http://www.w3.org/2000/svg"
  753. fill="none"
  754. viewBox="0 0 24 24"
  755. stroke-width="2.3"
  756. stroke="currentColor"
  757. class="w-4 h-4"
  758. >
  759. <path
  760. stroke-linecap="round"
  761. stroke-linejoin="round"
  762. d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
  763. />
  764. </svg>
  765. {/if}
  766. </button>
  767. </Tooltip>
  768. {/if}
  769. {#if message.info}
  770. <Tooltip
  771. content={message.info.openai
  772. ? message.info.usage
  773. ? `<pre>${sanitizeResponseContent(
  774. JSON.stringify(message.info.usage, null, 2)
  775. .replace(/"([^(")"]+)":/g, '$1:')
  776. .slice(1, -1)
  777. .split('\n')
  778. .map((line) => line.slice(2))
  779. .map((line) => (line.endsWith(',') ? line.slice(0, -1) : line))
  780. .join('\n')
  781. )}</pre>`
  782. : `prompt_tokens: ${message.info.prompt_tokens ?? 'N/A'}<br/>
  783. completion_tokens: ${message.info.completion_tokens ?? 'N/A'}<br/>
  784. total_tokens: ${message.info.total_tokens ?? 'N/A'}`
  785. : `response_token/s: ${
  786. `${
  787. Math.round(
  788. ((message.info.eval_count ?? 0) /
  789. ((message.info.eval_duration ?? 0) / 1000000000)) *
  790. 100
  791. ) / 100
  792. } tokens` ?? 'N/A'
  793. }<br/>
  794. prompt_token/s: ${
  795. Math.round(
  796. ((message.info.prompt_eval_count ?? 0) /
  797. ((message.info.prompt_eval_duration ?? 0) / 1000000000)) *
  798. 100
  799. ) / 100 ?? 'N/A'
  800. } tokens<br/>
  801. total_duration: ${
  802. Math.round(((message.info.total_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A'
  803. }ms<br/>
  804. load_duration: ${
  805. Math.round(((message.info.load_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A'
  806. }ms<br/>
  807. prompt_eval_count: ${message.info.prompt_eval_count ?? 'N/A'}<br/>
  808. prompt_eval_duration: ${
  809. Math.round(((message.info.prompt_eval_duration ?? 0) / 1000000) * 100) / 100 ??
  810. 'N/A'
  811. }ms<br/>
  812. eval_count: ${message.info.eval_count ?? 'N/A'}<br/>
  813. eval_duration: ${
  814. Math.round(((message.info.eval_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A'
  815. }ms<br/>
  816. approximate_total: ${approximateToHumanReadable(message.info.total_duration ?? 0)}`}
  817. placement="top"
  818. >
  819. <Tooltip content={$i18n.t('Generation Info')} placement="bottom">
  820. <button
  821. class=" {isLastMessage
  822. ? 'visible'
  823. : 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition whitespace-pre-wrap"
  824. on:click={() => {
  825. console.log(message);
  826. }}
  827. id="info-{message.id}"
  828. >
  829. <svg
  830. xmlns="http://www.w3.org/2000/svg"
  831. fill="none"
  832. viewBox="0 0 24 24"
  833. stroke-width="2.3"
  834. stroke="currentColor"
  835. class="w-4 h-4"
  836. >
  837. <path
  838. stroke-linecap="round"
  839. stroke-linejoin="round"
  840. d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
  841. />
  842. </svg>
  843. </button>
  844. </Tooltip>
  845. </Tooltip>
  846. {/if}
  847. {#if !readOnly}
  848. {#if $config?.features.enable_message_rating ?? true}
  849. <Tooltip content={$i18n.t('Good Response')} placement="bottom">
  850. <button
  851. class="{isLastMessage
  852. ? 'visible'
  853. : 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg {(
  854. message?.annotation?.rating ?? ''
  855. ).toString() === '1'
  856. ? 'bg-gray-100 dark:bg-gray-800'
  857. : ''} dark:hover:text-white hover:text-black transition"
  858. on:click={async () => {
  859. await feedbackHandler(1);
  860. (model?.actions ?? [])
  861. .filter((action) => action?.__webui__ ?? false)
  862. .forEach((action) => {
  863. dispatch('action', {
  864. id: action.id,
  865. event: {
  866. id: 'good-response',
  867. data: {
  868. messageId: message.id
  869. }
  870. }
  871. });
  872. });
  873. window.setTimeout(() => {
  874. document
  875. .getElementById(`message-feedback-${message.id}`)
  876. ?.scrollIntoView();
  877. }, 0);
  878. }}
  879. >
  880. <svg
  881. stroke="currentColor"
  882. fill="none"
  883. stroke-width="2.3"
  884. viewBox="0 0 24 24"
  885. stroke-linecap="round"
  886. stroke-linejoin="round"
  887. class="w-4 h-4"
  888. xmlns="http://www.w3.org/2000/svg"
  889. >
  890. <path
  891. d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"
  892. />
  893. </svg>
  894. </button>
  895. </Tooltip>
  896. <Tooltip content={$i18n.t('Bad Response')} placement="bottom">
  897. <button
  898. class="{isLastMessage
  899. ? 'visible'
  900. : 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg {(
  901. message?.annotation?.rating ?? ''
  902. ).toString() === '-1'
  903. ? 'bg-gray-100 dark:bg-gray-800'
  904. : ''} dark:hover:text-white hover:text-black transition"
  905. on:click={async () => {
  906. await feedbackHandler(-1);
  907. (model?.actions ?? [])
  908. .filter((action) => action?.__webui__ ?? false)
  909. .forEach((action) => {
  910. dispatch('action', {
  911. id: action.id,
  912. event: {
  913. id: 'bad-response',
  914. data: {
  915. messageId: message.id
  916. }
  917. }
  918. });
  919. });
  920. window.setTimeout(() => {
  921. document
  922. .getElementById(`message-feedback-${message.id}`)
  923. ?.scrollIntoView();
  924. }, 0);
  925. }}
  926. >
  927. <svg
  928. stroke="currentColor"
  929. fill="none"
  930. stroke-width="2.3"
  931. viewBox="0 0 24 24"
  932. stroke-linecap="round"
  933. stroke-linejoin="round"
  934. class="w-4 h-4"
  935. xmlns="http://www.w3.org/2000/svg"
  936. >
  937. <path
  938. d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"
  939. />
  940. </svg>
  941. </button>
  942. </Tooltip>
  943. {/if}
  944. {#if isLastMessage}
  945. <Tooltip content={$i18n.t('Continue Response')} placement="bottom">
  946. <button
  947. type="button"
  948. id="continue-response-button"
  949. class="{isLastMessage
  950. ? 'visible'
  951. : 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition regenerate-response-button"
  952. on:click={() => {
  953. continueResponse();
  954. (model?.actions ?? [])
  955. .filter((action) => action?.__webui__ ?? false)
  956. .forEach((action) => {
  957. dispatch('action', {
  958. id: action.id,
  959. event: {
  960. id: 'continue-response',
  961. data: {
  962. messageId: message.id
  963. }
  964. }
  965. });
  966. });
  967. }}
  968. >
  969. <svg
  970. xmlns="http://www.w3.org/2000/svg"
  971. fill="none"
  972. viewBox="0 0 24 24"
  973. stroke-width="2.3"
  974. stroke="currentColor"
  975. class="w-4 h-4"
  976. >
  977. <path
  978. stroke-linecap="round"
  979. stroke-linejoin="round"
  980. d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
  981. />
  982. <path
  983. stroke-linecap="round"
  984. stroke-linejoin="round"
  985. d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z"
  986. />
  987. </svg>
  988. </button>
  989. </Tooltip>
  990. <Tooltip content={$i18n.t('Regenerate')} placement="bottom">
  991. <button
  992. type="button"
  993. class="{isLastMessage
  994. ? 'visible'
  995. : 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition regenerate-response-button"
  996. on:click={() => {
  997. showRateComment = false;
  998. regenerateResponse(message);
  999. (model?.actions ?? [])
  1000. .filter((action) => action?.__webui__ ?? false)
  1001. .forEach((action) => {
  1002. dispatch('action', {
  1003. id: action.id,
  1004. event: {
  1005. id: 'regenerate-response',
  1006. data: {
  1007. messageId: message.id
  1008. }
  1009. }
  1010. });
  1011. });
  1012. }}
  1013. >
  1014. <svg
  1015. xmlns="http://www.w3.org/2000/svg"
  1016. fill="none"
  1017. viewBox="0 0 24 24"
  1018. stroke-width="2.3"
  1019. stroke="currentColor"
  1020. class="w-4 h-4"
  1021. >
  1022. <path
  1023. stroke-linecap="round"
  1024. stroke-linejoin="round"
  1025. d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
  1026. />
  1027. </svg>
  1028. </button>
  1029. </Tooltip>
  1030. {#each (model?.actions ?? []).filter((action) => !(action?.__webui__ ?? false)) as action}
  1031. <Tooltip content={action.name} placement="bottom">
  1032. <button
  1033. type="button"
  1034. class="{isLastMessage
  1035. ? 'visible'
  1036. : 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition regenerate-response-button"
  1037. on:click={() => {
  1038. dispatch('action', action.id);
  1039. }}
  1040. >
  1041. {#if action.icon_url}
  1042. <img
  1043. src={action.icon_url}
  1044. class="w-4 h-4 {action.icon_url.includes('svg')
  1045. ? 'dark:invert-[80%]'
  1046. : ''}"
  1047. style="fill: currentColor;"
  1048. alt={action.name}
  1049. />
  1050. {:else}
  1051. <Sparkles strokeWidth="2.1" className="size-4" />
  1052. {/if}
  1053. </button>
  1054. </Tooltip>
  1055. {/each}
  1056. {/if}
  1057. {/if}
  1058. {/if}
  1059. </div>
  1060. {/if}
  1061. {#if message.done && showRateComment}
  1062. <RateComment
  1063. bind:message
  1064. bind:show={showRateComment}
  1065. on:save={async (e) => {
  1066. await feedbackHandler(null, {
  1067. comment: e.detail.comment,
  1068. reason: e.detail.reason
  1069. });
  1070. (model?.actions ?? [])
  1071. .filter((action) => action?.__webui__ ?? false)
  1072. .forEach((action) => {
  1073. dispatch('action', {
  1074. id: action.id,
  1075. event: {
  1076. id: 'rate-comment',
  1077. data: {
  1078. messageId: message.id,
  1079. comment: e.detail.comment,
  1080. reason: e.detail.reason
  1081. }
  1082. }
  1083. });
  1084. });
  1085. }}
  1086. />
  1087. {/if}
  1088. {/if}
  1089. </div>
  1090. </div>
  1091. </div>
  1092. {/key}
  1093. <style>
  1094. .buttons::-webkit-scrollbar {
  1095. display: none; /* for Chrome, Safari and Opera */
  1096. }
  1097. .buttons {
  1098. -ms-overflow-style: none; /* IE and Edge */
  1099. scrollbar-width: none; /* Firefox */
  1100. }
  1101. @keyframes shimmer {
  1102. 0% {
  1103. background-position: 200% 0;
  1104. }
  1105. 100% {
  1106. background-position: -200% 0;
  1107. }
  1108. }
  1109. .shimmer {
  1110. background: linear-gradient(90deg, #9a9b9e 25%, #2a2929 50%, #9a9b9e 75%);
  1111. background-size: 200% 100%;
  1112. background-clip: text;
  1113. -webkit-background-clip: text;
  1114. -webkit-text-fill-color: transparent;
  1115. animation: shimmer 4s linear infinite;
  1116. color: #818286; /* Fallback color */
  1117. }
  1118. :global(.dark) .shimmer {
  1119. background: linear-gradient(90deg, #818286 25%, #eae5e5 50%, #818286 75%);
  1120. background-size: 200% 100%;
  1121. background-clip: text;
  1122. -webkit-background-clip: text;
  1123. -webkit-text-fill-color: transparent;
  1124. animation: shimmer 4s linear infinite;
  1125. color: #a1a3a7; /* Darker fallback color for dark mode */
  1126. }
  1127. @keyframes smoothFadeIn {
  1128. 0% {
  1129. opacity: 0;
  1130. transform: translateY(-10px);
  1131. }
  1132. 100% {
  1133. opacity: 1;
  1134. transform: translateY(0);
  1135. }
  1136. }
  1137. .status-description {
  1138. animation: smoothFadeIn 0.2s forwards;
  1139. }
  1140. </style>