1
0

SearchModal.svelte 9.6 KB

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