Sidebar.svelte 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import { goto } from '$app/navigation';
  4. import {
  5. user,
  6. chats,
  7. settings,
  8. showSettings,
  9. chatId,
  10. tags,
  11. showSidebar,
  12. mobile,
  13. showArchivedChats,
  14. pinnedChats,
  15. scrollPaginationEnabled,
  16. currentChatPage,
  17. temporaryChatEnabled
  18. } from '$lib/stores';
  19. import { onMount, getContext, tick } from 'svelte';
  20. const i18n = getContext('i18n');
  21. import { updateUserSettings } from '$lib/apis/users';
  22. import {
  23. deleteChatById,
  24. getChatList,
  25. getChatById,
  26. getChatListByTagName,
  27. updateChatById,
  28. getAllChatTags,
  29. archiveChatById,
  30. cloneChatById
  31. } from '$lib/apis/chats';
  32. import { WEBUI_BASE_URL } from '$lib/constants';
  33. import ArchivedChatsModal from './Sidebar/ArchivedChatsModal.svelte';
  34. import UserMenu from './Sidebar/UserMenu.svelte';
  35. import ChatItem from './Sidebar/ChatItem.svelte';
  36. import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
  37. import Spinner from '../common/Spinner.svelte';
  38. import Loader from '../common/Loader.svelte';
  39. const BREAKPOINT = 768;
  40. let navElement;
  41. let search = '';
  42. let shiftKey = false;
  43. let selectedChatId = null;
  44. let deleteChat = null;
  45. let showDeleteConfirm = false;
  46. let showDropdown = false;
  47. let selectedTagName = null;
  48. let filteredChatList = [];
  49. // Pagination variables
  50. let chatListLoading = false;
  51. let allChatsLoaded = false;
  52. $: filteredChatList = $chats.filter((chat) => {
  53. if (search === '') {
  54. return true;
  55. } else {
  56. let title = chat.title.toLowerCase();
  57. const query = search.toLowerCase();
  58. let contentMatches = false;
  59. // Access the messages within chat.chat.messages
  60. if (chat.chat && chat.chat.messages && Array.isArray(chat.chat.messages)) {
  61. contentMatches = chat.chat.messages.some((message) => {
  62. // Check if message.content exists and includes the search query
  63. return message.content && message.content.toLowerCase().includes(query);
  64. });
  65. }
  66. return title.includes(query) || contentMatches;
  67. }
  68. });
  69. const enablePagination = async () => {
  70. // Reset pagination variables
  71. currentChatPage.set(1);
  72. allChatsLoaded = false;
  73. await chats.set(await getChatList(localStorage.token, $currentChatPage));
  74. // Enable pagination
  75. scrollPaginationEnabled.set(true);
  76. };
  77. const loadMoreChats = async () => {
  78. chatListLoading = true;
  79. currentChatPage.set($currentChatPage + 1);
  80. const newChatList = await getChatList(localStorage.token, $currentChatPage);
  81. // once the bottom of the list has been reached (no results) there is no need to continue querying
  82. allChatsLoaded = newChatList.length === 0;
  83. await chats.set([...$chats, ...newChatList]);
  84. chatListLoading = false;
  85. };
  86. onMount(async () => {
  87. mobile.subscribe((e) => {
  88. if ($showSidebar && e) {
  89. showSidebar.set(false);
  90. }
  91. if (!$showSidebar && !e) {
  92. showSidebar.set(true);
  93. }
  94. });
  95. showSidebar.set(window.innerWidth > BREAKPOINT);
  96. await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
  97. await enablePagination();
  98. let touchstart;
  99. let touchend;
  100. function checkDirection() {
  101. const screenWidth = window.innerWidth;
  102. const swipeDistance = Math.abs(touchend.screenX - touchstart.screenX);
  103. if (touchstart.clientX < 40 && swipeDistance >= screenWidth / 8) {
  104. if (touchend.screenX < touchstart.screenX) {
  105. showSidebar.set(false);
  106. }
  107. if (touchend.screenX > touchstart.screenX) {
  108. showSidebar.set(true);
  109. }
  110. }
  111. }
  112. const onTouchStart = (e) => {
  113. touchstart = e.changedTouches[0];
  114. console.log(touchstart.clientX);
  115. };
  116. const onTouchEnd = (e) => {
  117. touchend = e.changedTouches[0];
  118. checkDirection();
  119. };
  120. const onKeyDown = (e) => {
  121. if (e.key === 'Shift') {
  122. shiftKey = true;
  123. }
  124. };
  125. const onKeyUp = (e) => {
  126. if (e.key === 'Shift') {
  127. shiftKey = false;
  128. }
  129. };
  130. const onFocus = () => {};
  131. const onBlur = () => {
  132. shiftKey = false;
  133. selectedChatId = null;
  134. };
  135. window.addEventListener('keydown', onKeyDown);
  136. window.addEventListener('keyup', onKeyUp);
  137. window.addEventListener('touchstart', onTouchStart);
  138. window.addEventListener('touchend', onTouchEnd);
  139. window.addEventListener('focus', onFocus);
  140. window.addEventListener('blur', onBlur);
  141. return () => {
  142. window.removeEventListener('keydown', onKeyDown);
  143. window.removeEventListener('keyup', onKeyUp);
  144. window.removeEventListener('touchstart', onTouchStart);
  145. window.removeEventListener('touchend', onTouchEnd);
  146. window.removeEventListener('focus', onFocus);
  147. window.removeEventListener('blur', onBlur);
  148. };
  149. });
  150. // Helper function to fetch and add chat content to each chat
  151. const enrichChatsWithContent = async (chatList) => {
  152. const enrichedChats = await Promise.all(
  153. chatList.map(async (chat) => {
  154. const chatDetails = await getChatById(localStorage.token, chat.id).catch((error) => null); // Handle error or non-existent chat gracefully
  155. if (chatDetails) {
  156. chat.chat = chatDetails.chat; // Assuming chatDetails.chat contains the chat content
  157. }
  158. return chat;
  159. })
  160. );
  161. await chats.set(enrichedChats);
  162. };
  163. const saveSettings = async (updated) => {
  164. await settings.set({ ...$settings, ...updated });
  165. await updateUserSettings(localStorage.token, { ui: $settings });
  166. location.href = '/';
  167. };
  168. const deleteChatHandler = async (id) => {
  169. const res = await deleteChatById(localStorage.token, id).catch((error) => {
  170. toast.error(error);
  171. return null;
  172. });
  173. if (res) {
  174. if ($chatId === id) {
  175. await chatId.set('');
  176. await tick();
  177. goto('/');
  178. }
  179. allChatsLoaded = false;
  180. currentChatPage.set(1);
  181. await chats.set(await getChatList(localStorage.token, $currentChatPage));
  182. await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
  183. }
  184. };
  185. </script>
  186. <ArchivedChatsModal
  187. bind:show={$showArchivedChats}
  188. on:change={async () => {
  189. await chats.set(await getChatList(localStorage.token));
  190. }}
  191. />
  192. <DeleteConfirmDialog
  193. bind:show={showDeleteConfirm}
  194. title={$i18n.t('Delete chat?')}
  195. on:confirm={() => {
  196. deleteChatHandler(deleteChat.id);
  197. }}
  198. >
  199. <div class=" text-sm text-gray-500 flex-1 line-clamp-3">
  200. {$i18n.t('This will delete')} <span class=" font-semibold">{deleteChat.title}</span>.
  201. </div>
  202. </DeleteConfirmDialog>
  203. <!-- svelte-ignore a11y-no-static-element-interactions -->
  204. {#if $showSidebar}
  205. <div
  206. class=" fixed md:hidden z-40 top-0 right-0 left-0 bottom-0 bg-black/60 w-full min-h-screen h-screen flex justify-center overflow-hidden overscroll-contain"
  207. on:mousedown={() => {
  208. showSidebar.set(!$showSidebar);
  209. }}
  210. />
  211. {/if}
  212. <div
  213. bind:this={navElement}
  214. id="sidebar"
  215. class="h-screen max-h-[100dvh] min-h-screen select-none {$showSidebar
  216. ? 'md:relative w-[260px]'
  217. : '-translate-x-[260px] w-[0px]'} bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-200 text-sm transition fixed z-50 top-0 left-0
  218. "
  219. data-state={$showSidebar}
  220. >
  221. <div
  222. class="py-2.5 my-auto flex flex-col justify-between h-screen max-h-[100dvh] w-[260px] z-50 {$showSidebar
  223. ? ''
  224. : 'invisible'}"
  225. >
  226. <div class="px-2.5 flex justify-between space-x-1 text-gray-600 dark:text-gray-400">
  227. <a
  228. id="sidebar-new-chat-button"
  229. class="flex flex-1 justify-between rounded-xl px-2 h-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
  230. href="/"
  231. draggable="false"
  232. on:click={async () => {
  233. selectedChatId = null;
  234. await goto('/');
  235. const newChatButton = document.getElementById('new-chat-button');
  236. setTimeout(() => {
  237. newChatButton?.click();
  238. if ($mobile) {
  239. showSidebar.set(false);
  240. }
  241. }, 0);
  242. }}
  243. >
  244. <div class="self-center mx-1.5">
  245. <img
  246. crossorigin="anonymous"
  247. src="{WEBUI_BASE_URL}/static/favicon.png"
  248. class=" size-6 -translate-x-1.5 rounded-full"
  249. alt="logo"
  250. />
  251. </div>
  252. <div class=" self-center font-medium text-sm text-gray-850 dark:text-white font-primary">
  253. {$i18n.t('New Chat')}
  254. </div>
  255. <div class="self-center ml-auto">
  256. <svg
  257. xmlns="http://www.w3.org/2000/svg"
  258. viewBox="0 0 20 20"
  259. fill="currentColor"
  260. class="size-5"
  261. >
  262. <path
  263. d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z"
  264. />
  265. <path
  266. d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z"
  267. />
  268. </svg>
  269. </div>
  270. </a>
  271. <button
  272. class=" cursor-pointer px-2 py-2 flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-900 transition"
  273. on:click={() => {
  274. showSidebar.set(!$showSidebar);
  275. }}
  276. >
  277. <div class=" m-auto self-center">
  278. <svg
  279. xmlns="http://www.w3.org/2000/svg"
  280. fill="none"
  281. viewBox="0 0 24 24"
  282. stroke-width="2"
  283. stroke="currentColor"
  284. class="size-5"
  285. >
  286. <path
  287. stroke-linecap="round"
  288. stroke-linejoin="round"
  289. d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12"
  290. />
  291. </svg>
  292. </div>
  293. </button>
  294. </div>
  295. {#if $user?.role === 'admin'}
  296. <div class="px-2.5 flex justify-center text-gray-800 dark:text-gray-200">
  297. <a
  298. class="flex-grow flex space-x-3 rounded-xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
  299. href="/workspace"
  300. on:click={() => {
  301. selectedChatId = null;
  302. chatId.set('');
  303. if ($mobile) {
  304. showSidebar.set(false);
  305. }
  306. }}
  307. draggable="false"
  308. >
  309. <div class="self-center">
  310. <svg
  311. xmlns="http://www.w3.org/2000/svg"
  312. fill="none"
  313. viewBox="0 0 24 24"
  314. stroke-width="2"
  315. stroke="currentColor"
  316. class="size-[1.1rem]"
  317. >
  318. <path
  319. stroke-linecap="round"
  320. stroke-linejoin="round"
  321. d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z"
  322. />
  323. </svg>
  324. </div>
  325. <div class="flex self-center">
  326. <div class=" self-center font-medium text-sm font-primary">{$i18n.t('Workspace')}</div>
  327. </div>
  328. </a>
  329. </div>
  330. {/if}
  331. <div class="relative flex flex-col flex-1 overflow-y-auto">
  332. {#if $temporaryChatEnabled}
  333. <div
  334. class="absolute z-40 w-full h-full bg-gray-50/90 dark:bg-black/90 flex justify-center"
  335. ></div>
  336. {/if}
  337. <div class="px-2 mt-0.5 mb-2 flex justify-center space-x-2">
  338. <div class="flex w-full rounded-xl" id="chat-search">
  339. <div class="self-center pl-3 py-2 rounded-l-xl bg-transparent">
  340. <svg
  341. xmlns="http://www.w3.org/2000/svg"
  342. viewBox="0 0 20 20"
  343. fill="currentColor"
  344. class="w-4 h-4"
  345. >
  346. <path
  347. fill-rule="evenodd"
  348. d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
  349. clip-rule="evenodd"
  350. />
  351. </svg>
  352. </div>
  353. <input
  354. class="w-full rounded-r-xl py-1.5 pl-2.5 pr-4 text-sm bg-transparent dark:text-gray-300 outline-none"
  355. placeholder={$i18n.t('Search')}
  356. bind:value={search}
  357. on:focus={async () => {
  358. // TODO: migrate backend for more scalable search mechanism
  359. scrollPaginationEnabled.set(false);
  360. await chats.set(await getChatList(localStorage.token)); // when searching, load all chats
  361. enrichChatsWithContent($chats);
  362. }}
  363. />
  364. </div>
  365. </div>
  366. {#if $tags.filter((t) => t.name !== 'pinned').length > 0}
  367. <div class="px-3.5 mb-1 flex gap-0.5 flex-wrap">
  368. <button
  369. class="px-2.5 py-[1px] text-xs transition {selectedTagName === null
  370. ? 'bg-gray-100 dark:bg-gray-900'
  371. : ' '} rounded-md font-medium"
  372. on:click={async () => {
  373. selectedTagName = null;
  374. await enablePagination();
  375. }}
  376. >
  377. {$i18n.t('all')}
  378. </button>
  379. {#each $tags.filter((t) => t.name !== 'pinned') as tag}
  380. <button
  381. class="px-2.5 py-[1px] text-xs transition {selectedTagName === tag.name
  382. ? 'bg-gray-100 dark:bg-gray-900'
  383. : ''} rounded-md font-medium"
  384. on:click={async () => {
  385. selectedTagName = tag.name;
  386. scrollPaginationEnabled.set(false);
  387. let chatIds = await getChatListByTagName(localStorage.token, tag.name);
  388. if (chatIds.length === 0) {
  389. await tags.set(await getAllChatTags(localStorage.token));
  390. // if the tag we deleted is no longer a valid tag, return to main chat list view
  391. await enablePagination();
  392. }
  393. await chats.set(chatIds);
  394. chatListLoading = false;
  395. }}
  396. >
  397. {tag.name}
  398. </button>
  399. {/each}
  400. </div>
  401. {/if}
  402. {#if $pinnedChats.length > 0}
  403. <div class="pl-2 py-2 flex flex-col space-y-1">
  404. <div class="">
  405. <div class="w-full pl-2.5 text-xs text-gray-500 dark:text-gray-500 font-medium pb-1.5">
  406. {$i18n.t('Pinned')}
  407. </div>
  408. {#each $pinnedChats as chat, idx}
  409. <ChatItem
  410. {chat}
  411. {shiftKey}
  412. selected={selectedChatId === chat.id}
  413. on:select={() => {
  414. selectedChatId = chat.id;
  415. }}
  416. on:unselect={() => {
  417. selectedChatId = null;
  418. }}
  419. on:delete={(e) => {
  420. if ((e?.detail ?? '') === 'shift') {
  421. deleteChatHandler(chat.id);
  422. } else {
  423. deleteChat = chat;
  424. showDeleteConfirm = true;
  425. }
  426. }}
  427. />
  428. {/each}
  429. </div>
  430. </div>
  431. {/if}
  432. <div class="pl-2 my-2 flex-1 flex flex-col space-y-1 overflow-y-auto scrollbar-hidden">
  433. {#each filteredChatList as chat, idx}
  434. {#if idx === 0 || (idx > 0 && chat.time_range !== filteredChatList[idx - 1].time_range)}
  435. <div
  436. class="w-full pl-2.5 text-xs text-gray-500 dark:text-gray-500 font-medium {idx === 0
  437. ? ''
  438. : 'pt-5'} pb-0.5"
  439. >
  440. {$i18n.t(chat.time_range)}
  441. <!-- localisation keys for time_range to be recognized from the i18next parser (so they don't get automatically removed):
  442. {$i18n.t('Today')}
  443. {$i18n.t('Yesterday')}
  444. {$i18n.t('Previous 7 days')}
  445. {$i18n.t('Previous 30 days')}
  446. {$i18n.t('January')}
  447. {$i18n.t('February')}
  448. {$i18n.t('March')}
  449. {$i18n.t('April')}
  450. {$i18n.t('May')}
  451. {$i18n.t('June')}
  452. {$i18n.t('July')}
  453. {$i18n.t('August')}
  454. {$i18n.t('September')}
  455. {$i18n.t('October')}
  456. {$i18n.t('November')}
  457. {$i18n.t('December')}
  458. -->
  459. </div>
  460. {/if}
  461. <ChatItem
  462. {chat}
  463. {shiftKey}
  464. selected={selectedChatId === chat.id}
  465. on:select={() => {
  466. selectedChatId = chat.id;
  467. }}
  468. on:unselect={() => {
  469. selectedChatId = null;
  470. }}
  471. on:delete={(e) => {
  472. if ((e?.detail ?? '') === 'shift') {
  473. deleteChatHandler(chat.id);
  474. } else {
  475. deleteChat = chat;
  476. showDeleteConfirm = true;
  477. }
  478. }}
  479. />
  480. {/each}
  481. {#if $scrollPaginationEnabled && !allChatsLoaded}
  482. <Loader
  483. on:visible={(e) => {
  484. if (!chatListLoading) {
  485. loadMoreChats();
  486. }
  487. }}
  488. >
  489. <div class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2">
  490. <Spinner className=" size-4" />
  491. <div class=" ">Loading...</div>
  492. </div>
  493. </Loader>
  494. {/if}
  495. </div>
  496. </div>
  497. <div class="px-2.5 pb-safe-bottom">
  498. <!-- <hr class=" border-gray-900 mb-1 w-full" /> -->
  499. <div class="flex flex-col font-primary">
  500. {#if $user !== undefined}
  501. <UserMenu
  502. role={$user.role}
  503. on:show={(e) => {
  504. if (e.detail === 'archived-chat') {
  505. showArchivedChats.set(true);
  506. }
  507. }}
  508. >
  509. <button
  510. class=" flex rounded-xl py-3 px-3.5 w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
  511. on:click={() => {
  512. showDropdown = !showDropdown;
  513. }}
  514. >
  515. <div class=" self-center mr-3">
  516. <img
  517. src={$user.profile_image_url}
  518. class=" max-w-[30px] object-cover rounded-full"
  519. alt="User profile"
  520. />
  521. </div>
  522. <div class=" self-center font-medium">{$user.name}</div>
  523. </button>
  524. </UserMenu>
  525. {/if}
  526. </div>
  527. </div>
  528. </div>
  529. </div>
  530. <style>
  531. .scrollbar-hidden:active::-webkit-scrollbar-thumb,
  532. .scrollbar-hidden:focus::-webkit-scrollbar-thumb,
  533. .scrollbar-hidden:hover::-webkit-scrollbar-thumb {
  534. visibility: visible;
  535. }
  536. .scrollbar-hidden::-webkit-scrollbar-thumb {
  537. visibility: hidden;
  538. }
  539. </style>