UserMessage.svelte 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. <script lang="ts">
  2. import dayjs from 'dayjs';
  3. import { toast } from 'svelte-sonner';
  4. import { tick, getContext, onMount } from 'svelte';
  5. import { models, settings } from '$lib/stores';
  6. import { user as _user } from '$lib/stores';
  7. import { copyToClipboard as _copyToClipboard, formatDate } from '$lib/utils';
  8. import Name from './Name.svelte';
  9. import ProfileImage from './ProfileImage.svelte';
  10. import Tooltip from '$lib/components/common/Tooltip.svelte';
  11. import FileItem from '$lib/components/common/FileItem.svelte';
  12. import Markdown from './Markdown.svelte';
  13. import Image from '$lib/components/common/Image.svelte';
  14. import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
  15. import localizedFormat from 'dayjs/plugin/localizedFormat';
  16. const i18n = getContext('i18n');
  17. dayjs.extend(localizedFormat);
  18. export let user;
  19. export let history;
  20. export let messageId;
  21. export let siblings;
  22. export let gotoMessage: Function;
  23. export let showPreviousMessage: Function;
  24. export let showNextMessage: Function;
  25. export let editMessage: Function;
  26. export let deleteMessage: Function;
  27. export let isFirstMessage: boolean;
  28. export let readOnly: boolean;
  29. let showDeleteConfirm = false;
  30. let messageIndexEdit = false;
  31. let edit = false;
  32. let editedContent = '';
  33. let messageEditTextAreaElement: HTMLTextAreaElement;
  34. let message = JSON.parse(JSON.stringify(history.messages[messageId]));
  35. $: if (history.messages) {
  36. if (JSON.stringify(message) !== JSON.stringify(history.messages[messageId])) {
  37. message = JSON.parse(JSON.stringify(history.messages[messageId]));
  38. }
  39. }
  40. const copyToClipboard = async (text) => {
  41. const res = await _copyToClipboard(text);
  42. if (res) {
  43. toast.success($i18n.t('Copying to clipboard was successful!'));
  44. }
  45. };
  46. const editMessageHandler = async () => {
  47. edit = true;
  48. editedContent = message.content;
  49. await tick();
  50. messageEditTextAreaElement.style.height = '';
  51. messageEditTextAreaElement.style.height = `${messageEditTextAreaElement.scrollHeight}px`;
  52. messageEditTextAreaElement?.focus();
  53. };
  54. const editMessageConfirmHandler = async (submit = true) => {
  55. editMessage(message.id, editedContent, submit);
  56. edit = false;
  57. editedContent = '';
  58. };
  59. const cancelEditMessage = () => {
  60. edit = false;
  61. editedContent = '';
  62. };
  63. const deleteMessageHandler = async () => {
  64. deleteMessage(message.id);
  65. };
  66. onMount(() => {
  67. // console.log('UserMessage mounted');
  68. });
  69. </script>
  70. <DeleteConfirmDialog
  71. bind:show={showDeleteConfirm}
  72. title={$i18n.t('Delete message?')}
  73. on:confirm={() => {
  74. deleteMessageHandler();
  75. }}
  76. />
  77. <div class=" flex w-full user-message" dir={$settings.chatDirection} id="message-{message.id}">
  78. {#if !($settings?.chatBubble ?? true)}
  79. <div class={`shrink-0 ltr:mr-3 rtl:ml-3`}>
  80. <ProfileImage
  81. src={message.user
  82. ? ($models.find((m) => m.id === message.user)?.info?.meta?.profile_image_url ??
  83. '/user.png')
  84. : (user?.profile_image_url ?? '/user.png')}
  85. className={'size-8'}
  86. />
  87. </div>
  88. {/if}
  89. <div class="flex-auto w-0 max-w-full pl-1">
  90. {#if !($settings?.chatBubble ?? true)}
  91. <div>
  92. <Name>
  93. {#if message.user}
  94. {$i18n.t('You')}
  95. <span class=" text-gray-500 text-sm font-medium">{message?.user ?? ''}</span>
  96. {:else if $settings.showUsername || $_user.name !== user.name}
  97. {user.name}
  98. {:else}
  99. {$i18n.t('You')}
  100. {/if}
  101. {#if message.timestamp}
  102. <div
  103. class=" self-center text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]"
  104. >
  105. <Tooltip content={dayjs(message.timestamp * 1000).format('LLLL')}>
  106. <span class="line-clamp-1">{formatDate(message.timestamp * 1000)}</span>
  107. </Tooltip>
  108. </div>
  109. {/if}
  110. </Name>
  111. </div>
  112. {/if}
  113. <div class="chat-{message.role} w-full min-w-full markdown-prose">
  114. {#if message.files}
  115. <div class="mt-2.5 mb-1 w-full flex flex-col justify-end overflow-x-auto gap-1 flex-wrap">
  116. {#each message.files as file}
  117. <div class={($settings?.chatBubble ?? true) ? 'self-end' : ''}>
  118. {#if file.type === 'image'}
  119. <Image src={file.url} imageClassName=" max-h-96 rounded-lg" />
  120. {:else}
  121. <FileItem
  122. item={file}
  123. url={file.url}
  124. name={file.name}
  125. type={file.type}
  126. size={file?.size}
  127. colorClassName="bg-white dark:bg-gray-850 "
  128. />
  129. {/if}
  130. </div>
  131. {/each}
  132. </div>
  133. {/if}
  134. {#if message.content !== ''}
  135. {#if edit === true}
  136. <div class=" w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-5 py-3 mb-2">
  137. <div class="max-h-96 overflow-auto">
  138. <textarea
  139. id="message-edit-{message.id}"
  140. bind:this={messageEditTextAreaElement}
  141. class=" bg-transparent outline-hidden w-full resize-none"
  142. bind:value={editedContent}
  143. on:input={(e) => {
  144. e.target.style.height = '';
  145. e.target.style.height = `${e.target.scrollHeight}px`;
  146. }}
  147. on:keydown={(e) => {
  148. if (e.key === 'Escape') {
  149. document.getElementById('close-edit-message-button')?.click();
  150. }
  151. const isCmdOrCtrlPressed = e.metaKey || e.ctrlKey;
  152. const isEnterPressed = e.key === 'Enter';
  153. if (isCmdOrCtrlPressed && isEnterPressed) {
  154. document.getElementById('confirm-edit-message-button')?.click();
  155. }
  156. }}
  157. />
  158. </div>
  159. <div class=" mt-2 mb-1 flex justify-between text-sm font-medium">
  160. <div>
  161. <button
  162. id="save-edit-message-button"
  163. class=" px-4 py-2 bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 border border-gray-100 dark:border-gray-700 text-gray-700 dark:text-gray-200 transition rounded-3xl"
  164. on:click={() => {
  165. editMessageConfirmHandler(false);
  166. }}
  167. >
  168. {$i18n.t('Save')}
  169. </button>
  170. </div>
  171. <div class="flex space-x-1.5">
  172. <button
  173. id="close-edit-message-button"
  174. 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"
  175. on:click={() => {
  176. cancelEditMessage();
  177. }}
  178. >
  179. {$i18n.t('Cancel')}
  180. </button>
  181. <button
  182. id="confirm-edit-message-button"
  183. 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"
  184. on:click={() => {
  185. editMessageConfirmHandler();
  186. }}
  187. >
  188. {$i18n.t('Send')}
  189. </button>
  190. </div>
  191. </div>
  192. </div>
  193. {:else}
  194. <div class="w-full">
  195. <div class="flex {($settings?.chatBubble ?? true) ? 'justify-end pb-1' : 'w-full'}">
  196. <div
  197. class="rounded-3xl {($settings?.chatBubble ?? true)
  198. ? `max-w-[90%] px-5 py-2 bg-gray-50 dark:bg-gray-850 ${
  199. message.files ? 'rounded-tr-lg' : ''
  200. }`
  201. : ' w-full'}"
  202. >
  203. {#if message.content}
  204. <Markdown id={message.id} content={message.content} />
  205. {/if}
  206. </div>
  207. </div>
  208. <div
  209. class=" flex {($settings?.chatBubble ?? true)
  210. ? 'justify-end'
  211. : ''} text-gray-600 dark:text-gray-500"
  212. >
  213. {#if !($settings?.chatBubble ?? true)}
  214. {#if siblings.length > 1}
  215. <div class="flex self-center" dir="ltr">
  216. <button
  217. class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
  218. on:click={() => {
  219. showPreviousMessage(message);
  220. }}
  221. >
  222. <svg
  223. xmlns="http://www.w3.org/2000/svg"
  224. fill="none"
  225. viewBox="0 0 24 24"
  226. stroke="currentColor"
  227. stroke-width="2.5"
  228. class="size-3.5"
  229. >
  230. <path
  231. stroke-linecap="round"
  232. stroke-linejoin="round"
  233. d="M15.75 19.5 8.25 12l7.5-7.5"
  234. />
  235. </svg>
  236. </button>
  237. {#if messageIndexEdit}
  238. <div
  239. class="text-sm flex justify-center font-semibold self-center dark:text-gray-100 min-w-fit"
  240. >
  241. <input
  242. id="message-index-input-{message.id}"
  243. type="number"
  244. value={siblings.indexOf(message.id) + 1}
  245. min="1"
  246. max={siblings.length}
  247. on:focus={(e) => {
  248. e.target.select();
  249. }}
  250. on:blur={(e) => {
  251. gotoMessage(message, e.target.value - 1);
  252. messageIndexEdit = false;
  253. }}
  254. on:keydown={(e) => {
  255. if (e.key === 'Enter') {
  256. gotoMessage(message, e.target.value - 1);
  257. messageIndexEdit = false;
  258. }
  259. }}
  260. class="bg-transparent font-semibold self-center dark:text-gray-100 min-w-fit outline-hidden"
  261. />/{siblings.length}
  262. </div>
  263. {:else}
  264. <!-- svelte-ignore a11y-no-static-element-interactions -->
  265. <div
  266. class="text-sm tracking-widest font-semibold self-center dark:text-gray-100 min-w-fit"
  267. on:dblclick={async () => {
  268. messageIndexEdit = true;
  269. await tick();
  270. const input = document.getElementById(
  271. `message-index-input-${message.id}`
  272. );
  273. if (input) {
  274. input.focus();
  275. input.select();
  276. }
  277. }}
  278. >
  279. {siblings.indexOf(message.id) + 1}/{siblings.length}
  280. </div>
  281. {/if}
  282. <button
  283. class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
  284. on:click={() => {
  285. showNextMessage(message);
  286. }}
  287. >
  288. <svg
  289. xmlns="http://www.w3.org/2000/svg"
  290. fill="none"
  291. viewBox="0 0 24 24"
  292. stroke="currentColor"
  293. stroke-width="2.5"
  294. class="size-3.5"
  295. >
  296. <path
  297. stroke-linecap="round"
  298. stroke-linejoin="round"
  299. d="m8.25 4.5 7.5 7.5-7.5 7.5"
  300. />
  301. </svg>
  302. </button>
  303. </div>
  304. {/if}
  305. {/if}
  306. {#if !readOnly}
  307. <Tooltip content={$i18n.t('Edit')} placement="bottom">
  308. <button
  309. class="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 edit-user-message-button"
  310. on:click={() => {
  311. editMessageHandler();
  312. }}
  313. >
  314. <svg
  315. xmlns="http://www.w3.org/2000/svg"
  316. fill="none"
  317. viewBox="0 0 24 24"
  318. stroke-width="2.3"
  319. stroke="currentColor"
  320. class="w-4 h-4"
  321. >
  322. <path
  323. stroke-linecap="round"
  324. stroke-linejoin="round"
  325. 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"
  326. />
  327. </svg>
  328. </button>
  329. </Tooltip>
  330. {/if}
  331. <Tooltip content={$i18n.t('Copy')} placement="bottom">
  332. <button
  333. class="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"
  334. on:click={() => {
  335. copyToClipboard(message.content);
  336. }}
  337. >
  338. <svg
  339. xmlns="http://www.w3.org/2000/svg"
  340. fill="none"
  341. viewBox="0 0 24 24"
  342. stroke-width="2.3"
  343. stroke="currentColor"
  344. class="w-4 h-4"
  345. >
  346. <path
  347. stroke-linecap="round"
  348. stroke-linejoin="round"
  349. 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"
  350. />
  351. </svg>
  352. </button>
  353. </Tooltip>
  354. {#if !readOnly && (!isFirstMessage || siblings.length > 1)}
  355. <Tooltip content={$i18n.t('Delete')} placement="bottom">
  356. <button
  357. class="invisible group-hover:visible p-1 rounded-sm dark:hover:text-white hover:text-black transition"
  358. on:click={() => {
  359. showDeleteConfirm = true;
  360. }}
  361. >
  362. <svg
  363. xmlns="http://www.w3.org/2000/svg"
  364. fill="none"
  365. viewBox="0 0 24 24"
  366. stroke-width="2"
  367. stroke="currentColor"
  368. class="w-4 h-4"
  369. >
  370. <path
  371. stroke-linecap="round"
  372. stroke-linejoin="round"
  373. d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
  374. />
  375. </svg>
  376. </button>
  377. </Tooltip>
  378. {/if}
  379. {#if $settings?.chatBubble ?? true}
  380. {#if siblings.length > 1}
  381. <div class="flex self-center" dir="ltr">
  382. <button
  383. class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
  384. on:click={() => {
  385. showPreviousMessage(message);
  386. }}
  387. >
  388. <svg
  389. xmlns="http://www.w3.org/2000/svg"
  390. fill="none"
  391. viewBox="0 0 24 24"
  392. stroke="currentColor"
  393. stroke-width="2.5"
  394. class="size-3.5"
  395. >
  396. <path
  397. stroke-linecap="round"
  398. stroke-linejoin="round"
  399. d="M15.75 19.5 8.25 12l7.5-7.5"
  400. />
  401. </svg>
  402. </button>
  403. {#if messageIndexEdit}
  404. <div
  405. class="text-sm flex justify-center font-semibold self-center dark:text-gray-100 min-w-fit"
  406. >
  407. <input
  408. id="message-index-input-{message.id}"
  409. type="number"
  410. value={siblings.indexOf(message.id) + 1}
  411. min="1"
  412. max={siblings.length}
  413. on:focus={(e) => {
  414. e.target.select();
  415. }}
  416. on:blur={(e) => {
  417. gotoMessage(message, e.target.value - 1);
  418. messageIndexEdit = false;
  419. }}
  420. on:keydown={(e) => {
  421. if (e.key === 'Enter') {
  422. gotoMessage(message, e.target.value - 1);
  423. messageIndexEdit = false;
  424. }
  425. }}
  426. class="bg-transparent font-semibold self-center dark:text-gray-100 min-w-fit outline-hidden"
  427. />/{siblings.length}
  428. </div>
  429. {:else}
  430. <!-- svelte-ignore a11y-no-static-element-interactions -->
  431. <div
  432. class="text-sm tracking-widest font-semibold self-center dark:text-gray-100 min-w-fit"
  433. on:dblclick={async () => {
  434. messageIndexEdit = true;
  435. await tick();
  436. const input = document.getElementById(
  437. `message-index-input-${message.id}`
  438. );
  439. if (input) {
  440. input.focus();
  441. input.select();
  442. }
  443. }}
  444. >
  445. {siblings.indexOf(message.id) + 1}/{siblings.length}
  446. </div>
  447. {/if}
  448. <button
  449. class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
  450. on:click={() => {
  451. showNextMessage(message);
  452. }}
  453. >
  454. <svg
  455. xmlns="http://www.w3.org/2000/svg"
  456. fill="none"
  457. viewBox="0 0 24 24"
  458. stroke="currentColor"
  459. stroke-width="2.5"
  460. class="size-3.5"
  461. >
  462. <path
  463. stroke-linecap="round"
  464. stroke-linejoin="round"
  465. d="m8.25 4.5 7.5 7.5-7.5 7.5"
  466. />
  467. </svg>
  468. </button>
  469. </div>
  470. {/if}
  471. {/if}
  472. </div>
  473. </div>
  474. {/if}
  475. {/if}
  476. </div>
  477. </div>
  478. </div>