ChatItem.svelte 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import { goto, invalidate, invalidateAll } from '$app/navigation';
  4. import { onMount, getContext, createEventDispatcher, tick, onDestroy } from 'svelte';
  5. const i18n = getContext('i18n');
  6. const dispatch = createEventDispatcher();
  7. import {
  8. archiveChatById,
  9. cloneChatById,
  10. deleteChatById,
  11. getAllTags,
  12. getChatById,
  13. getChatList,
  14. getChatListByTagName,
  15. getPinnedChatList,
  16. updateChatById,
  17. updateChatFolderIdById
  18. } from '$lib/apis/chats';
  19. import {
  20. chatId,
  21. chatTitle as _chatTitle,
  22. chats,
  23. mobile,
  24. pinnedChats,
  25. showSidebar,
  26. currentChatPage,
  27. tags,
  28. selectedFolder
  29. } from '$lib/stores';
  30. import ChatMenu from './ChatMenu.svelte';
  31. import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
  32. import ShareChatModal from '$lib/components/chat/ShareChatModal.svelte';
  33. import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
  34. import Tooltip from '$lib/components/common/Tooltip.svelte';
  35. import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
  36. import DragGhost from '$lib/components/common/DragGhost.svelte';
  37. import Check from '$lib/components/icons/Check.svelte';
  38. import XMark from '$lib/components/icons/XMark.svelte';
  39. import Document from '$lib/components/icons/Document.svelte';
  40. import Sparkles from '$lib/components/icons/Sparkles.svelte';
  41. import { generateTitle } from '$lib/apis';
  42. export let className = '';
  43. export let id;
  44. export let title;
  45. export let selected = false;
  46. export let shiftKey = false;
  47. let chat = null;
  48. let mouseOver = false;
  49. let draggable = false;
  50. $: if (mouseOver) {
  51. loadChat();
  52. }
  53. const loadChat = async () => {
  54. if (!chat) {
  55. draggable = false;
  56. chat = await getChatById(localStorage.token, id);
  57. draggable = true;
  58. }
  59. };
  60. let showShareChatModal = false;
  61. let confirmEdit = false;
  62. let chatTitle = title;
  63. const editChatTitle = async (id, title) => {
  64. if (title === '') {
  65. toast.error($i18n.t('Title cannot be an empty string.'));
  66. } else {
  67. await updateChatById(localStorage.token, id, {
  68. title: title
  69. });
  70. if (id === $chatId) {
  71. _chatTitle.set(title);
  72. }
  73. currentChatPage.set(1);
  74. await chats.set(await getChatList(localStorage.token, $currentChatPage));
  75. await pinnedChats.set(await getPinnedChatList(localStorage.token));
  76. dispatch('change');
  77. }
  78. };
  79. const cloneChatHandler = async (id) => {
  80. const res = await cloneChatById(
  81. localStorage.token,
  82. id,
  83. $i18n.t('Clone of {{TITLE}}', {
  84. TITLE: title
  85. })
  86. ).catch((error) => {
  87. toast.error(`${error}`);
  88. return null;
  89. });
  90. if (res) {
  91. goto(`/c/${res.id}`);
  92. currentChatPage.set(1);
  93. await chats.set(await getChatList(localStorage.token, $currentChatPage));
  94. await pinnedChats.set(await getPinnedChatList(localStorage.token));
  95. }
  96. };
  97. const deleteChatHandler = async (id) => {
  98. const res = await deleteChatById(localStorage.token, id).catch((error) => {
  99. toast.error(`${error}`);
  100. return null;
  101. });
  102. if (res) {
  103. tags.set(await getAllTags(localStorage.token));
  104. if ($chatId === id) {
  105. await goto('/');
  106. await chatId.set('');
  107. await tick();
  108. }
  109. dispatch('change');
  110. }
  111. };
  112. const archiveChatHandler = async (id) => {
  113. await archiveChatById(localStorage.token, id);
  114. dispatch('change');
  115. };
  116. const moveChatHandler = async (chatId, folderId) => {
  117. if (chatId && folderId) {
  118. const res = await updateChatFolderIdById(localStorage.token, chatId, folderId).catch(
  119. (error) => {
  120. toast.error(`${error}`);
  121. return null;
  122. }
  123. );
  124. if (res) {
  125. currentChatPage.set(1);
  126. await chats.set(await getChatList(localStorage.token, $currentChatPage));
  127. await pinnedChats.set(await getPinnedChatList(localStorage.token));
  128. dispatch('change');
  129. toast.success($i18n.t('Chat moved successfully'));
  130. }
  131. } else {
  132. toast.error($i18n.t('Failed to move chat'));
  133. }
  134. };
  135. let itemElement;
  136. let generating = false;
  137. let ignoreBlur = false;
  138. let doubleClicked = false;
  139. let dragged = false;
  140. let x = 0;
  141. let y = 0;
  142. const dragImage = new Image();
  143. dragImage.src =
  144. 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
  145. const onDragStart = (event) => {
  146. event.stopPropagation();
  147. event.dataTransfer.setDragImage(dragImage, 0, 0);
  148. // Set the data to be transferred
  149. event.dataTransfer.setData(
  150. 'text/plain',
  151. JSON.stringify({
  152. type: 'chat',
  153. id: id,
  154. item: chat
  155. })
  156. );
  157. dragged = true;
  158. itemElement.style.opacity = '0.5'; // Optional: Visual cue to show it's being dragged
  159. };
  160. const onDrag = (event) => {
  161. event.stopPropagation();
  162. x = event.clientX;
  163. y = event.clientY;
  164. };
  165. const onDragEnd = (event) => {
  166. event.stopPropagation();
  167. itemElement.style.opacity = '1'; // Reset visual cue after drag
  168. dragged = false;
  169. };
  170. const onClickOutside = (event) => {
  171. if (confirmEdit && !event.target.closest(`#chat-title-input-${id}`)) {
  172. confirmEdit = false;
  173. ignoreBlur = false;
  174. chatTitle = '';
  175. }
  176. };
  177. onMount(() => {
  178. if (itemElement) {
  179. document.addEventListener('click', onClickOutside, true);
  180. // Event listener for when dragging starts
  181. itemElement.addEventListener('dragstart', onDragStart);
  182. // Event listener for when dragging occurs (optional)
  183. itemElement.addEventListener('drag', onDrag);
  184. // Event listener for when dragging ends
  185. itemElement.addEventListener('dragend', onDragEnd);
  186. }
  187. });
  188. onDestroy(() => {
  189. if (itemElement) {
  190. document.removeEventListener('click', onClickOutside, true);
  191. itemElement.removeEventListener('dragstart', onDragStart);
  192. itemElement.removeEventListener('drag', onDrag);
  193. itemElement.removeEventListener('dragend', onDragEnd);
  194. }
  195. });
  196. let showDeleteConfirm = false;
  197. const chatTitleInputKeydownHandler = (e) => {
  198. if (e.key === 'Enter') {
  199. e.preventDefault();
  200. setTimeout(() => {
  201. const input = document.getElementById(`chat-title-input-${id}`);
  202. if (input) input.blur();
  203. }, 0);
  204. } else if (e.key === 'Escape') {
  205. e.preventDefault();
  206. confirmEdit = false;
  207. chatTitle = '';
  208. }
  209. };
  210. const renameHandler = async () => {
  211. chatTitle = title;
  212. confirmEdit = true;
  213. await tick();
  214. setTimeout(() => {
  215. const input = document.getElementById(`chat-title-input-${id}`);
  216. if (input) {
  217. input.focus();
  218. input.select();
  219. }
  220. }, 0);
  221. };
  222. const generateTitleHandler = async () => {
  223. generating = true;
  224. if (!chat) {
  225. chat = await getChatById(localStorage.token, id);
  226. }
  227. const messages = (chat.chat?.messages ?? []).map((message) => {
  228. return {
  229. role: message.role,
  230. content: message.content
  231. };
  232. });
  233. const model = chat.chat.models.at(0) ?? chat.models.at(0) ?? '';
  234. chatTitle = '';
  235. const generatedTitle = await generateTitle(localStorage.token, model, messages).catch(
  236. (error) => {
  237. toast.error(`${error}`);
  238. return null;
  239. }
  240. );
  241. if (generatedTitle) {
  242. if (generatedTitle !== title) {
  243. editChatTitle(id, generatedTitle);
  244. }
  245. confirmEdit = false;
  246. } else {
  247. chatTitle = title;
  248. }
  249. generating = false;
  250. };
  251. </script>
  252. <ShareChatModal bind:show={showShareChatModal} chatId={id} />
  253. <DeleteConfirmDialog
  254. bind:show={showDeleteConfirm}
  255. title={$i18n.t('Delete chat?')}
  256. on:confirm={() => {
  257. deleteChatHandler(id);
  258. }}
  259. >
  260. <div class=" text-sm text-gray-500 flex-1 line-clamp-3">
  261. {$i18n.t('This will delete')} <span class=" font-semibold">{title}</span>.
  262. </div>
  263. </DeleteConfirmDialog>
  264. {#if dragged && x && y}
  265. <DragGhost {x} {y}>
  266. <div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-fit max-w-40">
  267. <div class="flex items-center gap-1">
  268. <Document className=" size-[18px]" strokeWidth="2" />
  269. <div class=" text-xs text-white line-clamp-1">
  270. {title}
  271. </div>
  272. </div>
  273. </div>
  274. </DragGhost>
  275. {/if}
  276. <div
  277. id="sidebar-chat-group"
  278. bind:this={itemElement}
  279. class=" w-full {className} relative group"
  280. draggable={draggable && !confirmEdit}
  281. >
  282. {#if confirmEdit}
  283. <div
  284. id="sidebar-chat-item"
  285. class=" w-full flex justify-between rounded-xl px-[11px] py-[6px] {id === $chatId ||
  286. confirmEdit
  287. ? 'bg-gray-100 dark:bg-gray-900 selected'
  288. : selected
  289. ? 'bg-gray-100 dark:bg-gray-950 selected'
  290. : 'group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis relative {generating
  291. ? 'cursor-not-allowed'
  292. : ''}"
  293. >
  294. <input
  295. id="chat-title-input-{id}"
  296. bind:value={chatTitle}
  297. class=" bg-transparent w-full outline-hidden mr-10"
  298. placeholder={generating ? $i18n.t('Generating...') : ''}
  299. disabled={generating}
  300. on:keydown={chatTitleInputKeydownHandler}
  301. on:blur={async (e) => {
  302. // check if target is generate button
  303. if (ignoreBlur) {
  304. ignoreBlur = false;
  305. if (e.relatedTarget?.id === 'generate-title-button') {
  306. generateTitleHandler();
  307. }
  308. return;
  309. }
  310. if (doubleClicked) {
  311. e.preventDefault();
  312. e.stopPropagation();
  313. await tick();
  314. setTimeout(() => {
  315. const input = document.getElementById(`chat-title-input-${id}`);
  316. if (input) input.focus();
  317. }, 0);
  318. doubleClicked = false;
  319. return;
  320. }
  321. if (chatTitle !== title) {
  322. editChatTitle(id, chatTitle);
  323. }
  324. confirmEdit = false;
  325. chatTitle = '';
  326. }}
  327. />
  328. </div>
  329. {:else}
  330. <a
  331. id="sidebar-chat-item"
  332. class=" w-full flex justify-between rounded-xl px-[11px] py-[6px] {id === $chatId ||
  333. confirmEdit
  334. ? 'bg-gray-100 dark:bg-gray-900 selected'
  335. : selected
  336. ? 'bg-gray-100 dark:bg-gray-950 selected'
  337. : ' group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis"
  338. href="/c/{id}"
  339. on:click={() => {
  340. dispatch('select');
  341. if ($selectedFolder) {
  342. selectedFolder.set(null);
  343. }
  344. if ($mobile) {
  345. showSidebar.set(false);
  346. }
  347. }}
  348. on:dblclick={async (e) => {
  349. e.preventDefault();
  350. e.stopPropagation();
  351. doubleClicked = true;
  352. renameHandler();
  353. }}
  354. on:mouseenter={(e) => {
  355. mouseOver = true;
  356. }}
  357. on:mouseleave={(e) => {
  358. mouseOver = false;
  359. }}
  360. on:focus={(e) => {}}
  361. draggable="false"
  362. >
  363. <div class=" flex self-center flex-1 w-full">
  364. <div dir="auto" class="text-left self-center overflow-hidden w-full h-[20px] truncate">
  365. {title}
  366. </div>
  367. </div>
  368. </a>
  369. {/if}
  370. <!-- svelte-ignore a11y-no-static-element-interactions -->
  371. <div
  372. id="sidebar-chat-item-menu"
  373. class="
  374. {id === $chatId || confirmEdit
  375. ? 'from-gray-100 dark:from-gray-900 selected'
  376. : selected
  377. ? 'from-gray-100 dark:from-gray-950 selected'
  378. : 'invisible group-hover:visible from-gray-100 dark:from-gray-950'}
  379. absolute {className === 'pr-2'
  380. ? 'right-[8px]'
  381. : 'right-1'} top-[4px] py-1 pr-0.5 mr-1.5 pl-5 bg-linear-to-l from-80%
  382. to-transparent"
  383. on:mouseenter={(e) => {
  384. mouseOver = true;
  385. }}
  386. on:mouseleave={(e) => {
  387. mouseOver = false;
  388. }}
  389. >
  390. {#if confirmEdit}
  391. <div
  392. class="flex self-center items-center space-x-1.5 z-10 translate-y-[0.5px] -translate-x-[0.5px]"
  393. >
  394. <Tooltip content={$i18n.t('Generate')}>
  395. <button
  396. class=" self-center dark:hover:text-white transition disabled:cursor-not-allowed"
  397. id="generate-title-button"
  398. disabled={generating}
  399. on:mouseenter={() => {
  400. ignoreBlur = true;
  401. }}
  402. >
  403. <Sparkles strokeWidth="2" />
  404. </button>
  405. </Tooltip>
  406. </div>
  407. {:else if shiftKey && mouseOver}
  408. <div class=" flex items-center self-center space-x-1.5">
  409. <Tooltip content={$i18n.t('Archive')} className="flex items-center">
  410. <button
  411. class=" self-center dark:hover:text-white transition"
  412. on:click={() => {
  413. archiveChatHandler(id);
  414. }}
  415. type="button"
  416. >
  417. <ArchiveBox className="size-4 translate-y-[0.5px]" strokeWidth="2" />
  418. </button>
  419. </Tooltip>
  420. <Tooltip content={$i18n.t('Delete')}>
  421. <button
  422. class=" self-center dark:hover:text-white transition"
  423. on:click={() => {
  424. deleteChatHandler(id);
  425. }}
  426. type="button"
  427. >
  428. <GarbageBin strokeWidth="2" />
  429. </button>
  430. </Tooltip>
  431. </div>
  432. {:else}
  433. <div class="flex self-center z-10 items-end">
  434. <ChatMenu
  435. chatId={id}
  436. cloneChatHandler={() => {
  437. cloneChatHandler(id);
  438. }}
  439. shareHandler={() => {
  440. showShareChatModal = true;
  441. }}
  442. {moveChatHandler}
  443. archiveChatHandler={() => {
  444. archiveChatHandler(id);
  445. }}
  446. {renameHandler}
  447. deleteHandler={() => {
  448. showDeleteConfirm = true;
  449. }}
  450. onClose={() => {
  451. dispatch('unselect');
  452. }}
  453. on:change={async () => {
  454. dispatch('change');
  455. }}
  456. on:tag={(e) => {
  457. dispatch('tag', e.detail);
  458. }}
  459. >
  460. <button
  461. aria-label="Chat Menu"
  462. class=" self-center dark:hover:text-white transition m-0"
  463. on:click={() => {
  464. dispatch('select');
  465. }}
  466. >
  467. <svg
  468. xmlns="http://www.w3.org/2000/svg"
  469. viewBox="0 0 16 16"
  470. fill="currentColor"
  471. class="w-4 h-4"
  472. >
  473. <path
  474. d="M2 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM6.5 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM12.5 6.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"
  475. />
  476. </svg>
  477. </button>
  478. </ChatMenu>
  479. {#if id === $chatId}
  480. <!-- Shortcut support using "delete-chat-button" id -->
  481. <button
  482. id="delete-chat-button"
  483. class="hidden"
  484. on:click={() => {
  485. showDeleteConfirm = true;
  486. }}
  487. >
  488. <svg
  489. xmlns="http://www.w3.org/2000/svg"
  490. viewBox="0 0 16 16"
  491. fill="currentColor"
  492. class="w-4 h-4"
  493. >
  494. <path
  495. d="M2 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM6.5 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM12.5 6.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"
  496. />
  497. </svg>
  498. </button>
  499. {/if}
  500. </div>
  501. {/if}
  502. </div>
  503. </div>