SearchModal.svelte 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import { getContext, onDestroy, onMount, tick } from 'svelte';
  4. const i18n = getContext('i18n');
  5. import Modal from '$lib/components/common/Modal.svelte';
  6. import SearchInput from './Sidebar/SearchInput.svelte';
  7. import { getChatById, getChatList, getChatListBySearchText } from '$lib/apis/chats';
  8. import Spinner from '../common/Spinner.svelte';
  9. import dayjs from '$lib/dayjs';
  10. import calendar from 'dayjs/plugin/calendar';
  11. import Loader from '../common/Loader.svelte';
  12. import { createMessagesList } from '$lib/utils';
  13. import { user } from '$lib/stores';
  14. import Messages from '../chat/Messages.svelte';
  15. import { goto } from '$app/navigation';
  16. import PencilSquare from '../icons/PencilSquare.svelte';
  17. import Note from '../icons/Note.svelte';
  18. dayjs.extend(calendar);
  19. export let show = false;
  20. export let onClose = () => {};
  21. let actions = [
  22. {
  23. label: 'Start a new conversation',
  24. onClick: async () => {
  25. await goto(`/${query ? `?q=${query}` : ''}`);
  26. show = false;
  27. onClose();
  28. },
  29. icon: PencilSquare
  30. },
  31. {
  32. label: 'Create a new note',
  33. onClick: async () => {
  34. await goto('/notes');
  35. show = false;
  36. onClose();
  37. },
  38. icon: Note
  39. }
  40. ];
  41. let query = '';
  42. let page = 1;
  43. let chatList = null;
  44. let chatListLoading = false;
  45. let allChatsLoaded = false;
  46. let searchDebounceTimeout;
  47. let selectedIdx = null;
  48. let selectedChat = null;
  49. let selectedModels = [''];
  50. let history = null;
  51. let messages = null;
  52. $: if (!chatListLoading && chatList) {
  53. loadChatPreview(selectedIdx);
  54. }
  55. const loadChatPreview = async (selectedIdx) => {
  56. if (
  57. !chatList ||
  58. chatList.length === 0 ||
  59. selectedIdx === null ||
  60. chatList[selectedIdx] === undefined
  61. ) {
  62. selectedChat = null;
  63. messages = null;
  64. history = null;
  65. selectedModels = [''];
  66. return;
  67. }
  68. const selectedChatIdx = selectedIdx - actions.length;
  69. if (selectedChatIdx < 0) {
  70. selectedChat = null;
  71. return;
  72. }
  73. const chatId = chatList[selectedChatIdx].id;
  74. const chat = await getChatById(localStorage.token, chatId).catch(async (error) => {
  75. return null;
  76. });
  77. if (chat) {
  78. if (chat?.chat?.history) {
  79. selectedModels =
  80. (chat?.chat?.models ?? undefined) !== undefined
  81. ? chat?.chat?.models
  82. : [chat?.chat?.models ?? ''];
  83. history = chat?.chat?.history;
  84. messages = createMessagesList(chat?.chat?.history, chat?.chat?.history?.currentId);
  85. // scroll to the bottom of the messages container
  86. await tick();
  87. const messagesContainerElement = document.getElementById('chat-preview');
  88. if (messagesContainerElement) {
  89. messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
  90. }
  91. } else {
  92. messages = [];
  93. }
  94. } else {
  95. toast.error($i18n.t('Failed to load chat preview'));
  96. selectedChat = null;
  97. messages = null;
  98. history = null;
  99. selectedModels = [''];
  100. return;
  101. }
  102. };
  103. const searchHandler = async () => {
  104. if (!show) {
  105. return;
  106. }
  107. if (searchDebounceTimeout) {
  108. clearTimeout(searchDebounceTimeout);
  109. }
  110. page = 1;
  111. chatList = null;
  112. if (query === '') {
  113. chatList = await getChatList(localStorage.token, page);
  114. } else {
  115. searchDebounceTimeout = setTimeout(async () => {
  116. chatList = await getChatListBySearchText(localStorage.token, query, page);
  117. if ((chatList ?? []).length === 0) {
  118. allChatsLoaded = true;
  119. } else {
  120. allChatsLoaded = false;
  121. }
  122. }, 500);
  123. }
  124. selectedChat = null;
  125. messages = null;
  126. history = null;
  127. selectedModels = [''];
  128. if ((chatList ?? []).length === 0) {
  129. allChatsLoaded = true;
  130. } else {
  131. allChatsLoaded = false;
  132. }
  133. };
  134. const loadMoreChats = async () => {
  135. chatListLoading = true;
  136. page += 1;
  137. let newChatList = [];
  138. if (query) {
  139. newChatList = await getChatListBySearchText(localStorage.token, query, page);
  140. } else {
  141. newChatList = await getChatList(localStorage.token, page);
  142. }
  143. // once the bottom of the list has been reached (no results) there is no need to continue querying
  144. allChatsLoaded = newChatList.length === 0;
  145. if (newChatList.length > 0) {
  146. chatList = [...chatList, ...newChatList];
  147. }
  148. chatListLoading = false;
  149. };
  150. $: if (show) {
  151. searchHandler();
  152. }
  153. const onKeyDown = (e) => {
  154. const searchOptions = document.getElementById('search-options-container');
  155. if (searchOptions || !show) {
  156. return;
  157. }
  158. if (e.code === 'Escape') {
  159. show = false;
  160. onClose();
  161. } else if (e.code === 'Enter') {
  162. const item = document.querySelector(`[data-arrow-selected="true"]`);
  163. if (item) {
  164. item?.click();
  165. show = false;
  166. }
  167. return;
  168. } else if (e.code === 'ArrowDown') {
  169. const searchInput = document.getElementById('search-input');
  170. if (searchInput) {
  171. // check if focused on the search input
  172. if (document.activeElement === searchInput) {
  173. searchInput.blur();
  174. selectedIdx = 0;
  175. return;
  176. }
  177. }
  178. selectedIdx = Math.min(selectedIdx + 1, (chatList ?? []).length - 1 + actions.length);
  179. } else if (e.code === 'ArrowUp') {
  180. if (selectedIdx === 0) {
  181. const searchInput = document.getElementById('search-input');
  182. if (searchInput) {
  183. // check if focused on the search input
  184. if (document.activeElement !== searchInput) {
  185. searchInput.focus();
  186. selectedIdx = 0;
  187. return;
  188. }
  189. }
  190. }
  191. selectedIdx = Math.max(selectedIdx - 1, 0);
  192. }
  193. const item = document.querySelector(`[data-arrow-selected="true"]`);
  194. item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
  195. };
  196. onMount(() => {
  197. document.addEventListener('keydown', onKeyDown);
  198. });
  199. onDestroy(() => {
  200. if (searchDebounceTimeout) {
  201. clearTimeout(searchDebounceTimeout);
  202. }
  203. document.removeEventListener('keydown', onKeyDown);
  204. });
  205. </script>
  206. <Modal size="xl" bind:show>
  207. <div class="py-3 dark:text-gray-300 text-gray-700">
  208. <div class="px-4 pb-1.5">
  209. <SearchInput
  210. bind:value={query}
  211. on:input={searchHandler}
  212. placeholder={$i18n.t('Search')}
  213. showClearButton={true}
  214. onFocus={() => {
  215. selectedIdx = null;
  216. messages = null;
  217. }}
  218. onKeydown={(e) => {
  219. console.log('e', e);
  220. if (e.code === 'Enter' && (chatList ?? []).length > 0) {
  221. const item = document.querySelector(`[data-arrow-selected="true"]`);
  222. if (item) {
  223. item?.click();
  224. }
  225. show = false;
  226. return;
  227. } else if (e.code === 'ArrowDown') {
  228. selectedIdx = Math.min(selectedIdx + 1, (chatList ?? []).length - 1 + actions.length);
  229. } else if (e.code === 'ArrowUp') {
  230. selectedIdx = Math.max(selectedIdx - 1, 0);
  231. } else {
  232. selectedIdx = 0;
  233. }
  234. const item = document.querySelector(`[data-arrow-selected="true"]`);
  235. item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
  236. }}
  237. />
  238. </div>
  239. <!-- <hr class="border-gray-50 dark:border-gray-850 my-1" /> -->
  240. <div class="flex px-4 pb-1">
  241. <div
  242. class="flex flex-col overflow-y-auto h-96 md:h-[40rem] max-h-full scrollbar-hidden w-full flex-1 pr-2"
  243. >
  244. <div class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium pb-2 px-2">
  245. {$i18n.t('Actions')}
  246. </div>
  247. {#each actions as action, idx (action.label)}
  248. <button
  249. class=" w-full flex items-center rounded-xl text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850 {selectedIdx ===
  250. idx
  251. ? 'bg-gray-50 dark:bg-gray-850'
  252. : ''}"
  253. data-arrow-selected={selectedIdx === idx ? 'true' : undefined}
  254. dragabble="false"
  255. on:mouseenter={() => {
  256. selectedIdx = idx;
  257. }}
  258. on:click={async () => {
  259. await action.onClick();
  260. }}
  261. >
  262. <div class="pr-2">
  263. <svelte:component this={action.icon} />
  264. </div>
  265. <div class=" flex-1 text-left">
  266. <div class="text-ellipsis line-clamp-1 w-full">
  267. {$i18n.t(action.label)}
  268. </div>
  269. </div>
  270. </button>
  271. {/each}
  272. {#if chatList}
  273. <hr class="border-gray-50 dark:border-gray-850 my-3" />
  274. {#if chatList.length === 0}
  275. <div class="text-xs text-gray-500 dark:text-gray-400 text-center px-5 py-4">
  276. {$i18n.t('No results found')}
  277. </div>
  278. {/if}
  279. {#each chatList as chat, idx (chat.id)}
  280. {#if idx === 0 || (idx > 0 && chat.time_range !== chatList[idx - 1].time_range)}
  281. <div
  282. class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium {idx === 0
  283. ? ''
  284. : 'pt-5'} pb-2 px-2"
  285. >
  286. {$i18n.t(chat.time_range)}
  287. <!-- localisation keys for time_range to be recognized from the i18next parser (so they don't get automatically removed):
  288. {$i18n.t('Today')}
  289. {$i18n.t('Yesterday')}
  290. {$i18n.t('Previous 7 days')}
  291. {$i18n.t('Previous 30 days')}
  292. {$i18n.t('January')}
  293. {$i18n.t('February')}
  294. {$i18n.t('March')}
  295. {$i18n.t('April')}
  296. {$i18n.t('May')}
  297. {$i18n.t('June')}
  298. {$i18n.t('July')}
  299. {$i18n.t('August')}
  300. {$i18n.t('September')}
  301. {$i18n.t('October')}
  302. {$i18n.t('November')}
  303. {$i18n.t('December')}
  304. -->
  305. </div>
  306. {/if}
  307. <a
  308. class=" w-full flex justify-between items-center rounded-xl text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850 {selectedIdx ===
  309. idx + actions.length
  310. ? 'bg-gray-50 dark:bg-gray-850'
  311. : ''}"
  312. href="/c/{chat.id}"
  313. draggable="false"
  314. data-arrow-selected={selectedIdx === idx + actions.length ? 'true' : undefined}
  315. on:mouseenter={() => {
  316. selectedIdx = idx + actions.length;
  317. }}
  318. on:click={async () => {
  319. await goto(`/c/${chat.id}`);
  320. show = false;
  321. onClose();
  322. }}
  323. >
  324. <div class=" flex-1">
  325. <div class="text-ellipsis line-clamp-1 w-full">
  326. {chat?.title}
  327. </div>
  328. </div>
  329. <div class=" pl-3 shrink-0 text-gray-500 dark:text-gray-400 text-xs">
  330. {dayjs(chat?.updated_at * 1000).calendar()}
  331. </div>
  332. </a>
  333. {/each}
  334. {#if !allChatsLoaded}
  335. <Loader
  336. on:visible={(e) => {
  337. if (!chatListLoading) {
  338. loadMoreChats();
  339. }
  340. }}
  341. >
  342. <div class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2">
  343. <Spinner className=" size-4" />
  344. <div class=" ">{$i18n.t('Loading...')}</div>
  345. </div>
  346. </Loader>
  347. {/if}
  348. {:else}
  349. <div class="w-full h-full flex justify-center items-center">
  350. <Spinner className="size-5" />
  351. </div>
  352. {/if}
  353. </div>
  354. <div
  355. id="chat-preview"
  356. class="hidden md:flex md:flex-1 w-full overflow-y-auto h-96 md:h-[40rem] scrollbar-hidden"
  357. >
  358. {#if messages === null}
  359. <div
  360. class="w-full h-full flex justify-center items-center text-gray-500 dark:text-gray-400 text-sm"
  361. >
  362. {$i18n.t('Select a conversation to preview')}
  363. </div>
  364. {:else}
  365. <div class="w-full h-full flex flex-col">
  366. <Messages
  367. className="h-full flex pt-4 pb-8 w-full"
  368. chatId={`chat-preview-${selectedChat?.id ?? ''}`}
  369. user={$user}
  370. readOnly={true}
  371. {selectedModels}
  372. bind:history
  373. bind:messages
  374. autoScroll={true}
  375. sendMessage={() => {}}
  376. continueResponse={() => {}}
  377. regenerateResponse={() => {}}
  378. />
  379. </div>
  380. {/if}
  381. </div>
  382. </div>
  383. </div>
  384. </Modal>