Sidebar.svelte 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import { v4 as uuidv4 } from 'uuid';
  4. import { goto } from '$app/navigation';
  5. import {
  6. user,
  7. chats,
  8. settings,
  9. showSettings,
  10. chatId,
  11. tags,
  12. folders as _folders,
  13. showSidebar,
  14. showSearch,
  15. mobile,
  16. showArchivedChats,
  17. pinnedChats,
  18. scrollPaginationEnabled,
  19. currentChatPage,
  20. temporaryChatEnabled,
  21. channels,
  22. socket,
  23. config,
  24. isApp,
  25. models,
  26. selectedFolder,
  27. WEBUI_NAME
  28. } from '$lib/stores';
  29. import { onMount, getContext, tick, onDestroy } from 'svelte';
  30. const i18n = getContext('i18n');
  31. import {
  32. getChatList,
  33. getAllTags,
  34. getPinnedChatList,
  35. toggleChatPinnedStatusById,
  36. getChatById,
  37. updateChatFolderIdById,
  38. importChat
  39. } from '$lib/apis/chats';
  40. import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders';
  41. import { WEBUI_BASE_URL } from '$lib/constants';
  42. import ArchivedChatsModal from './ArchivedChatsModal.svelte';
  43. import UserMenu from './Sidebar/UserMenu.svelte';
  44. import ChatItem from './Sidebar/ChatItem.svelte';
  45. import Spinner from '../common/Spinner.svelte';
  46. import Loader from '../common/Loader.svelte';
  47. import Folder from '../common/Folder.svelte';
  48. import Tooltip from '../common/Tooltip.svelte';
  49. import Folders from './Sidebar/Folders.svelte';
  50. import { getChannels, createNewChannel } from '$lib/apis/channels';
  51. import ChannelModal from './Sidebar/ChannelModal.svelte';
  52. import ChannelItem from './Sidebar/ChannelItem.svelte';
  53. import PencilSquare from '../icons/PencilSquare.svelte';
  54. import Search from '../icons/Search.svelte';
  55. import SearchModal from './SearchModal.svelte';
  56. import FolderModal from './Sidebar/Folders/FolderModal.svelte';
  57. import Sidebar from '../icons/Sidebar.svelte';
  58. import PinnedModelList from './Sidebar/PinnedModelList.svelte';
  59. import Note from '../icons/Note.svelte';
  60. import { slide } from 'svelte/transition';
  61. const BREAKPOINT = 768;
  62. let scrollTop = 0;
  63. let navElement;
  64. let shiftKey = false;
  65. let selectedChatId = null;
  66. let showPinnedChat = true;
  67. let showCreateChannel = false;
  68. // Pagination variables
  69. let chatListLoading = false;
  70. let allChatsLoaded = false;
  71. let showCreateFolderModal = false;
  72. let folders = {};
  73. let folderRegistry = {};
  74. let newFolderId = null;
  75. const initFolders = async () => {
  76. const folderList = await getFolders(localStorage.token).catch((error) => {
  77. toast.error(`${error}`);
  78. return [];
  79. });
  80. _folders.set(folderList);
  81. folders = {};
  82. // First pass: Initialize all folder entries
  83. for (const folder of folderList) {
  84. // Ensure folder is added to folders with its data
  85. folders[folder.id] = { ...(folders[folder.id] || {}), ...folder };
  86. if (newFolderId && folder.id === newFolderId) {
  87. folders[folder.id].new = true;
  88. newFolderId = null;
  89. }
  90. }
  91. // Second pass: Tie child folders to their parents
  92. for (const folder of folderList) {
  93. if (folder.parent_id) {
  94. // Ensure the parent folder is initialized if it doesn't exist
  95. if (!folders[folder.parent_id]) {
  96. folders[folder.parent_id] = {}; // Create a placeholder if not already present
  97. }
  98. // Initialize childrenIds array if it doesn't exist and add the current folder id
  99. folders[folder.parent_id].childrenIds = folders[folder.parent_id].childrenIds
  100. ? [...folders[folder.parent_id].childrenIds, folder.id]
  101. : [folder.id];
  102. // Sort the children by updated_at field
  103. folders[folder.parent_id].childrenIds.sort((a, b) => {
  104. return folders[b].updated_at - folders[a].updated_at;
  105. });
  106. }
  107. }
  108. };
  109. const createFolder = async ({ name, data }) => {
  110. if (name === '') {
  111. toast.error($i18n.t('Folder name cannot be empty.'));
  112. return;
  113. }
  114. const rootFolders = Object.values(folders).filter((folder) => folder.parent_id === null);
  115. if (rootFolders.find((folder) => folder.name.toLowerCase() === name.toLowerCase())) {
  116. // If a folder with the same name already exists, append a number to the name
  117. let i = 1;
  118. while (
  119. rootFolders.find((folder) => folder.name.toLowerCase() === `${name} ${i}`.toLowerCase())
  120. ) {
  121. i++;
  122. }
  123. name = `${name} ${i}`;
  124. }
  125. // Add a dummy folder to the list to show the user that the folder is being created
  126. const tempId = uuidv4();
  127. folders = {
  128. ...folders,
  129. tempId: {
  130. id: tempId,
  131. name: name,
  132. created_at: Date.now(),
  133. updated_at: Date.now()
  134. }
  135. };
  136. const res = await createNewFolder(localStorage.token, {
  137. name,
  138. data
  139. }).catch((error) => {
  140. toast.error(`${error}`);
  141. return null;
  142. });
  143. if (res) {
  144. // newFolderId = res.id;
  145. await initFolders();
  146. }
  147. };
  148. const initChannels = async () => {
  149. await channels.set(await getChannels(localStorage.token));
  150. };
  151. const initChatList = async () => {
  152. // Reset pagination variables
  153. tags.set(await getAllTags(localStorage.token));
  154. pinnedChats.set(await getPinnedChatList(localStorage.token));
  155. initFolders();
  156. currentChatPage.set(1);
  157. allChatsLoaded = false;
  158. await chats.set(await getChatList(localStorage.token, $currentChatPage));
  159. // Enable pagination
  160. scrollPaginationEnabled.set(true);
  161. };
  162. const loadMoreChats = async () => {
  163. chatListLoading = true;
  164. currentChatPage.set($currentChatPage + 1);
  165. let newChatList = [];
  166. newChatList = await getChatList(localStorage.token, $currentChatPage);
  167. // once the bottom of the list has been reached (no results) there is no need to continue querying
  168. allChatsLoaded = newChatList.length === 0;
  169. await chats.set([...($chats ? $chats : []), ...newChatList]);
  170. chatListLoading = false;
  171. };
  172. const importChatHandler = async (items, pinned = false, folderId = null) => {
  173. console.log('importChatHandler', items, pinned, folderId);
  174. for (const item of items) {
  175. console.log(item);
  176. if (item.chat) {
  177. await importChat(
  178. localStorage.token,
  179. item.chat,
  180. item?.meta ?? {},
  181. pinned,
  182. folderId,
  183. item?.created_at ?? null,
  184. item?.updated_at ?? null
  185. );
  186. }
  187. }
  188. initChatList();
  189. };
  190. const inputFilesHandler = async (files) => {
  191. console.log(files);
  192. for (const file of files) {
  193. const reader = new FileReader();
  194. reader.onload = async (e) => {
  195. const content = e.target.result;
  196. try {
  197. const chatItems = JSON.parse(content);
  198. importChatHandler(chatItems);
  199. } catch {
  200. toast.error($i18n.t(`Invalid file format.`));
  201. }
  202. };
  203. reader.readAsText(file);
  204. }
  205. };
  206. const tagEventHandler = async (type, tagName, chatId) => {
  207. console.log(type, tagName, chatId);
  208. if (type === 'delete') {
  209. initChatList();
  210. } else if (type === 'add') {
  211. initChatList();
  212. }
  213. };
  214. let draggedOver = false;
  215. const onDragOver = (e) => {
  216. e.preventDefault();
  217. // Check if a file is being draggedOver.
  218. if (e.dataTransfer?.types?.includes('Files')) {
  219. draggedOver = true;
  220. } else {
  221. draggedOver = false;
  222. }
  223. };
  224. const onDragLeave = () => {
  225. draggedOver = false;
  226. };
  227. const onDrop = async (e) => {
  228. e.preventDefault();
  229. console.log(e); // Log the drop event
  230. // Perform file drop check and handle it accordingly
  231. if (e.dataTransfer?.files) {
  232. const inputFiles = Array.from(e.dataTransfer?.files);
  233. if (inputFiles && inputFiles.length > 0) {
  234. console.log(inputFiles); // Log the dropped files
  235. inputFilesHandler(inputFiles); // Handle the dropped files
  236. }
  237. }
  238. draggedOver = false; // Reset draggedOver status after drop
  239. };
  240. let touchstart;
  241. let touchend;
  242. function checkDirection() {
  243. const screenWidth = window.innerWidth;
  244. const swipeDistance = Math.abs(touchend.screenX - touchstart.screenX);
  245. if (touchstart.clientX < 40 && swipeDistance >= screenWidth / 8) {
  246. if (touchend.screenX < touchstart.screenX) {
  247. showSidebar.set(false);
  248. }
  249. if (touchend.screenX > touchstart.screenX) {
  250. showSidebar.set(true);
  251. }
  252. }
  253. }
  254. const onTouchStart = (e) => {
  255. touchstart = e.changedTouches[0];
  256. console.log(touchstart.clientX);
  257. };
  258. const onTouchEnd = (e) => {
  259. touchend = e.changedTouches[0];
  260. checkDirection();
  261. };
  262. const onKeyDown = (e) => {
  263. if (e.key === 'Shift') {
  264. shiftKey = true;
  265. }
  266. };
  267. const onKeyUp = (e) => {
  268. if (e.key === 'Shift') {
  269. shiftKey = false;
  270. }
  271. };
  272. const onFocus = () => {};
  273. const onBlur = () => {
  274. shiftKey = false;
  275. selectedChatId = null;
  276. };
  277. onMount(async () => {
  278. showPinnedChat = localStorage?.showPinnedChat ? localStorage.showPinnedChat === 'true' : true;
  279. mobile.subscribe((value) => {
  280. if ($showSidebar && value) {
  281. showSidebar.set(false);
  282. }
  283. if ($showSidebar && !value) {
  284. const navElement = document.getElementsByTagName('nav')[0];
  285. if (navElement) {
  286. navElement.style['-webkit-app-region'] = 'drag';
  287. }
  288. }
  289. if (!$showSidebar && !value) {
  290. showSidebar.set(true);
  291. }
  292. });
  293. showSidebar.set(!$mobile ? localStorage.sidebar === 'true' : false);
  294. showSidebar.subscribe(async (value) => {
  295. localStorage.sidebar = value;
  296. // nav element is not available on the first render
  297. const navElement = document.getElementsByTagName('nav')[0];
  298. if (navElement) {
  299. if ($mobile) {
  300. if (!value) {
  301. navElement.style['-webkit-app-region'] = 'drag';
  302. } else {
  303. navElement.style['-webkit-app-region'] = 'no-drag';
  304. }
  305. } else {
  306. navElement.style['-webkit-app-region'] = 'drag';
  307. }
  308. }
  309. if (!value) {
  310. await initChannels();
  311. await initChatList();
  312. }
  313. });
  314. chats.subscribe((value) => {
  315. initFolders();
  316. });
  317. await initChannels();
  318. await initChatList();
  319. window.addEventListener('keydown', onKeyDown);
  320. window.addEventListener('keyup', onKeyUp);
  321. window.addEventListener('touchstart', onTouchStart);
  322. window.addEventListener('touchend', onTouchEnd);
  323. window.addEventListener('focus', onFocus);
  324. window.addEventListener('blur', onBlur);
  325. const dropZone = document.getElementById('sidebar');
  326. dropZone?.addEventListener('dragover', onDragOver);
  327. dropZone?.addEventListener('drop', onDrop);
  328. dropZone?.addEventListener('dragleave', onDragLeave);
  329. });
  330. onDestroy(() => {
  331. window.removeEventListener('keydown', onKeyDown);
  332. window.removeEventListener('keyup', onKeyUp);
  333. window.removeEventListener('touchstart', onTouchStart);
  334. window.removeEventListener('touchend', onTouchEnd);
  335. window.removeEventListener('focus', onFocus);
  336. window.removeEventListener('blur', onBlur);
  337. const dropZone = document.getElementById('sidebar');
  338. dropZone?.removeEventListener('dragover', onDragOver);
  339. dropZone?.removeEventListener('drop', onDrop);
  340. dropZone?.removeEventListener('dragleave', onDragLeave);
  341. });
  342. const newChatHandler = async () => {
  343. selectedChatId = null;
  344. selectedFolder.set(null);
  345. if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) {
  346. await temporaryChatEnabled.set(true);
  347. } else {
  348. await temporaryChatEnabled.set(false);
  349. }
  350. setTimeout(() => {
  351. if ($mobile) {
  352. showSidebar.set(false);
  353. }
  354. }, 0);
  355. };
  356. const itemClickHandler = async () => {
  357. selectedChatId = null;
  358. chatId.set('');
  359. if ($mobile) {
  360. showSidebar.set(false);
  361. }
  362. await tick();
  363. };
  364. const isWindows = /Windows/i.test(navigator.userAgent);
  365. </script>
  366. <ArchivedChatsModal
  367. bind:show={$showArchivedChats}
  368. onUpdate={async () => {
  369. await initChatList();
  370. }}
  371. />
  372. <ChannelModal
  373. bind:show={showCreateChannel}
  374. onSubmit={async ({ name, access_control }) => {
  375. const res = await createNewChannel(localStorage.token, {
  376. name: name,
  377. access_control: access_control
  378. }).catch((error) => {
  379. toast.error(`${error}`);
  380. return null;
  381. });
  382. if (res) {
  383. $socket.emit('join-channels', { auth: { token: $user?.token } });
  384. await initChannels();
  385. showCreateChannel = false;
  386. }
  387. }}
  388. />
  389. <FolderModal
  390. bind:show={showCreateFolderModal}
  391. onSubmit={async (folder) => {
  392. await createFolder(folder);
  393. showCreateFolderModal = false;
  394. }}
  395. />
  396. <!-- svelte-ignore a11y-no-static-element-interactions -->
  397. {#if $showSidebar}
  398. <div
  399. class=" {$isApp
  400. ? ' ml-[4.5rem] md:ml-0'
  401. : ''} 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"
  402. on:mousedown={() => {
  403. showSidebar.set(!$showSidebar);
  404. }}
  405. />
  406. {/if}
  407. <SearchModal
  408. bind:show={$showSearch}
  409. onClose={() => {
  410. if ($mobile) {
  411. showSidebar.set(false);
  412. }
  413. }}
  414. />
  415. <button
  416. id="sidebar-new-chat-button"
  417. class="hidden"
  418. on:click={() => {
  419. goto('/');
  420. newChatHandler();
  421. }}
  422. />
  423. {#if !$mobile && !$showSidebar}
  424. <div
  425. class=" py-2 px-1.5 flex flex-col justify-between text-black dark:text-white hover:bg-gray-50/50 dark:hover:bg-gray-950/50 h-full border-e border-gray-50 dark:border-gray-850 z-10 transition-all"
  426. id="sidebar"
  427. >
  428. <button
  429. class="flex flex-col flex-1 {isWindows ? 'cursor-pointer' : 'cursor-[e-resize]'}"
  430. on:click={async () => {
  431. showSidebar.set(!$showSidebar);
  432. }}
  433. >
  434. <div class="pb-1.5">
  435. <Tooltip
  436. content={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
  437. placement="right"
  438. >
  439. <button
  440. class="flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group {isWindows
  441. ? 'cursor-pointer'
  442. : 'cursor-[e-resize]'}"
  443. aria-label={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
  444. >
  445. <div class=" self-center flex items-center justify-center size-9">
  446. <img
  447. crossorigin="anonymous"
  448. src="{WEBUI_BASE_URL}/static/favicon.png"
  449. class="sidebar-new-chat-icon size-6 rounded-full group-hover:hidden"
  450. alt=""
  451. />
  452. <Sidebar className="size-5 hidden group-hover:flex" />
  453. </div>
  454. </button>
  455. </Tooltip>
  456. </div>
  457. <div>
  458. <div class="">
  459. <Tooltip content={$i18n.t('New Chat')} placement="right">
  460. <a
  461. class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
  462. href="/"
  463. draggable="false"
  464. on:click={async (e) => {
  465. e.stopImmediatePropagation();
  466. e.preventDefault();
  467. goto('/');
  468. newChatHandler();
  469. }}
  470. aria-label={$i18n.t('New Chat')}
  471. >
  472. <div class=" self-center flex items-center justify-center size-9">
  473. <PencilSquare className="size-4.5" />
  474. </div>
  475. </a>
  476. </Tooltip>
  477. </div>
  478. <div class="">
  479. <Tooltip content={$i18n.t('Search')} placement="right">
  480. <button
  481. class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
  482. on:click={(e) => {
  483. e.stopImmediatePropagation();
  484. e.preventDefault();
  485. showSearch.set(true);
  486. }}
  487. draggable="false"
  488. aria-label={$i18n.t('Search')}
  489. >
  490. <div class=" self-center flex items-center justify-center size-9">
  491. <Search className="size-4.5" />
  492. </div>
  493. </button>
  494. </Tooltip>
  495. </div>
  496. {#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
  497. <div class="">
  498. <Tooltip content={$i18n.t('Notes')} placement="right">
  499. <a
  500. class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
  501. href="/notes"
  502. on:click={async (e) => {
  503. e.stopImmediatePropagation();
  504. e.preventDefault();
  505. goto('/notes');
  506. itemClickHandler();
  507. }}
  508. draggable="false"
  509. aria-label={$i18n.t('Notes')}
  510. >
  511. <div class=" self-center flex items-center justify-center size-9">
  512. <Note className="size-4.5" />
  513. </div>
  514. </a>
  515. </Tooltip>
  516. </div>
  517. {/if}
  518. {#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools}
  519. <div class="">
  520. <Tooltip content={$i18n.t('Workspace')} placement="right">
  521. <a
  522. class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
  523. href="/workspace"
  524. on:click={async (e) => {
  525. e.stopImmediatePropagation();
  526. e.preventDefault();
  527. goto('/workspace');
  528. itemClickHandler();
  529. }}
  530. aria-label={$i18n.t('Workspace')}
  531. draggable="false"
  532. >
  533. <div class=" self-center flex items-center justify-center size-9">
  534. <svg
  535. xmlns="http://www.w3.org/2000/svg"
  536. fill="none"
  537. viewBox="0 0 24 24"
  538. stroke-width="1.5"
  539. stroke="currentColor"
  540. class="size-4.5"
  541. >
  542. <path
  543. stroke-linecap="round"
  544. stroke-linejoin="round"
  545. 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"
  546. />
  547. </svg>
  548. </div>
  549. </a>
  550. </Tooltip>
  551. </div>
  552. {/if}
  553. </div>
  554. </button>
  555. <div>
  556. <div>
  557. <div class=" py-0.5">
  558. {#if $user !== undefined && $user !== null}
  559. <UserMenu
  560. role={$user?.role}
  561. on:show={(e) => {
  562. if (e.detail === 'archived-chat') {
  563. showArchivedChats.set(true);
  564. }
  565. }}
  566. >
  567. <div
  568. class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
  569. >
  570. <div class=" self-center flex items-center justify-center size-9">
  571. <img
  572. src={$user?.profile_image_url}
  573. class=" size-6 object-cover rounded-full"
  574. alt={$i18n.t('Open User Profile Menu')}
  575. aria-label={$i18n.t('Open User Profile Menu')}
  576. />
  577. </div>
  578. </div>
  579. </UserMenu>
  580. {/if}
  581. </div>
  582. </div>
  583. </div>
  584. </div>
  585. {/if}
  586. {#if $showSidebar}
  587. <div
  588. bind:this={navElement}
  589. id="sidebar"
  590. class="h-screen max-h-[100dvh] min-h-screen select-none {$showSidebar
  591. ? 'bg-gray-50 dark:bg-gray-950 z-50'
  592. : ' bg-transparent z-0 '} {$isApp
  593. ? `ml-[4.5rem] md:ml-0 `
  594. : ' transition-all duration-300 '} shrink-0 text-gray-900 dark:text-gray-200 text-sm fixed top-0 left-0 overflow-x-hidden
  595. "
  596. transition:slide={{ duration: 250, axis: 'x' }}
  597. data-state={$showSidebar}
  598. >
  599. <div
  600. class=" my-auto flex flex-col justify-between h-screen max-h-[100dvh] w-[260px] overflow-x-hidden scrollbar-hidden z-50 {$showSidebar
  601. ? ''
  602. : 'invisible'}"
  603. >
  604. <div
  605. class="sidebar px-2 pt-2 pb-1.5 flex justify-between space-x-1 text-gray-600 dark:text-gray-400 sticky top-0 z-10 -mb-3"
  606. >
  607. <a
  608. class="flex items-center rounded-xl size-8.5 h-full justify-center hover:bg-gray-100/50 dark:hover:bg-gray-850/50 transition no-drag-region"
  609. href="/"
  610. draggable="false"
  611. on:click={newChatHandler}
  612. >
  613. <img
  614. crossorigin="anonymous"
  615. src="{WEBUI_BASE_URL}/static/favicon.png"
  616. class="sidebar-new-chat-icon size-6 rounded-full"
  617. alt=""
  618. />
  619. </a>
  620. <a href="/" class="flex flex-1 px-1.5" on:click={newChatHandler}>
  621. <div class=" self-center font-medium text-gray-850 dark:text-white font-primary">
  622. {$WEBUI_NAME}
  623. </div>
  624. </a>
  625. <Tooltip
  626. content={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
  627. placement="bottom"
  628. >
  629. <button
  630. class="flex rounded-xl size-8.5 justify-center items-center hover:bg-gray-100/50 dark:hover:bg-gray-850/50 transition {isWindows
  631. ? 'cursor-pointer'
  632. : 'cursor-[w-resize]'}"
  633. on:click={() => {
  634. showSidebar.set(!$showSidebar);
  635. }}
  636. aria-label={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
  637. >
  638. <div class=" self-center p-1.5">
  639. <Sidebar />
  640. </div>
  641. </button>
  642. </Tooltip>
  643. <div
  644. class="{scrollTop > 0
  645. ? 'visible'
  646. : 'invisible'} sidebar-bg-gradient-to-b bg-linear-to-b from-gray-50 dark:from-gray-950 to-transparent from-50% pointer-events-none absolute inset-0 -z-10 -mb-6"
  647. ></div>
  648. </div>
  649. <div
  650. class="relative flex flex-col flex-1 overflow-y-auto scrollbar-hidden pt-3 pb-3"
  651. on:scroll={(e) => {
  652. if (e.target.scrollTop === 0) {
  653. scrollTop = 0;
  654. } else {
  655. scrollTop = e.target.scrollTop;
  656. }
  657. }}
  658. >
  659. <div class="pb-1.5">
  660. <div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
  661. <a
  662. id="sidebar-new-chat-button"
  663. class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
  664. href="/"
  665. draggable="false"
  666. on:click={newChatHandler}
  667. aria-label={$i18n.t('New Chat')}
  668. >
  669. <div class="self-center">
  670. <PencilSquare className=" size-4.5" strokeWidth="2" />
  671. </div>
  672. <div class="flex self-center translate-y-[0.5px]">
  673. <div class=" self-center text-sm font-primary">{$i18n.t('New Chat')}</div>
  674. </div>
  675. </a>
  676. </div>
  677. <div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
  678. <button
  679. id="sidebar-search-button"
  680. class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
  681. on:click={() => {
  682. showSearch.set(true);
  683. }}
  684. draggable="false"
  685. aria-label={$i18n.t('Search')}
  686. >
  687. <div class="self-center">
  688. <Search strokeWidth="2" className="size-4.5" />
  689. </div>
  690. <div class="flex self-center translate-y-[0.5px]">
  691. <div class=" self-center text-sm font-primary">{$i18n.t('Search')}</div>
  692. </div>
  693. </button>
  694. </div>
  695. {#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
  696. <div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
  697. <a
  698. id="sidebar-notes-button"
  699. class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
  700. href="/notes"
  701. on:click={itemClickHandler}
  702. draggable="false"
  703. aria-label={$i18n.t('Notes')}
  704. >
  705. <div class="self-center">
  706. <Note className="size-4.5" strokeWidth="2" />
  707. </div>
  708. <div class="flex self-center translate-y-[0.5px]">
  709. <div class=" self-center text-sm font-primary">{$i18n.t('Notes')}</div>
  710. </div>
  711. </a>
  712. </div>
  713. {/if}
  714. {#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools}
  715. <div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
  716. <a
  717. id="sidebar-workspace-button"
  718. class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
  719. href="/workspace"
  720. on:click={itemClickHandler}
  721. draggable="false"
  722. aria-label={$i18n.t('Workspace')}
  723. >
  724. <div class="self-center">
  725. <svg
  726. xmlns="http://www.w3.org/2000/svg"
  727. fill="none"
  728. viewBox="0 0 24 24"
  729. stroke-width="2"
  730. stroke="currentColor"
  731. class="size-4.5"
  732. >
  733. <path
  734. stroke-linecap="round"
  735. stroke-linejoin="round"
  736. 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"
  737. />
  738. </svg>
  739. </div>
  740. <div class="flex self-center translate-y-[0.5px]">
  741. <div class=" self-center text-sm font-primary">{$i18n.t('Workspace')}</div>
  742. </div>
  743. </a>
  744. </div>
  745. {/if}
  746. </div>
  747. {#if ($models ?? []).length > 0 && ($settings?.pinnedModels ?? []).length > 0}
  748. <PinnedModelList bind:selectedChatId {shiftKey} />
  749. {/if}
  750. {#if $config?.features?.enable_channels && ($user?.role === 'admin' || $channels.length > 0)}
  751. <Folder
  752. className="px-2 mt-0.5"
  753. name={$i18n.t('Channels')}
  754. chevron={false}
  755. dragAndDrop={false}
  756. onAdd={async () => {
  757. if ($user?.role === 'admin') {
  758. await tick();
  759. setTimeout(() => {
  760. showCreateChannel = true;
  761. }, 0);
  762. }
  763. }}
  764. onAddLabel={$i18n.t('Create Channel')}
  765. >
  766. {#each $channels as channel}
  767. <ChannelItem
  768. {channel}
  769. onUpdate={async () => {
  770. await initChannels();
  771. }}
  772. />
  773. {/each}
  774. </Folder>
  775. {/if}
  776. {#if folders}
  777. <Folder
  778. className="px-2 mt-0.5"
  779. name={$i18n.t('Folders')}
  780. chevron={false}
  781. onAdd={() => {
  782. showCreateFolderModal = true;
  783. }}
  784. onAddLabel={$i18n.t('New Folder')}
  785. on:drop={async (e) => {
  786. const { type, id, item } = e.detail;
  787. if (type === 'folder') {
  788. if (folders[id].parent_id === null) {
  789. return;
  790. }
  791. const res = await updateFolderParentIdById(localStorage.token, id, null).catch(
  792. (error) => {
  793. toast.error(`${error}`);
  794. return null;
  795. }
  796. );
  797. if (res) {
  798. await initFolders();
  799. }
  800. }
  801. }}
  802. >
  803. <Folders
  804. bind:folderRegistry
  805. {folders}
  806. {shiftKey}
  807. onDelete={(folderId) => {
  808. selectedFolder.set(null);
  809. initChatList();
  810. }}
  811. on:update={() => {
  812. initChatList();
  813. }}
  814. on:import={(e) => {
  815. const { folderId, items } = e.detail;
  816. importChatHandler(items, false, folderId);
  817. }}
  818. on:change={async () => {
  819. initChatList();
  820. }}
  821. />
  822. </Folder>
  823. {/if}
  824. <Folder
  825. className="px-2 mt-0.5"
  826. name={$i18n.t('Chats')}
  827. chevron={false}
  828. on:change={async (e) => {
  829. selectedFolder.set(null);
  830. await goto('/');
  831. }}
  832. on:import={(e) => {
  833. importChatHandler(e.detail);
  834. }}
  835. on:drop={async (e) => {
  836. const { type, id, item } = e.detail;
  837. if (type === 'chat') {
  838. let chat = await getChatById(localStorage.token, id).catch((error) => {
  839. return null;
  840. });
  841. if (!chat && item) {
  842. chat = await importChat(
  843. localStorage.token,
  844. item.chat,
  845. item?.meta ?? {},
  846. false,
  847. null,
  848. item?.created_at ?? null,
  849. item?.updated_at ?? null
  850. );
  851. }
  852. if (chat) {
  853. console.log(chat);
  854. if (chat.folder_id) {
  855. const res = await updateChatFolderIdById(localStorage.token, chat.id, null).catch(
  856. (error) => {
  857. toast.error(`${error}`);
  858. return null;
  859. }
  860. );
  861. folderRegistry[chat.folder_id]?.setFolderItems();
  862. }
  863. if (chat.pinned) {
  864. const res = await toggleChatPinnedStatusById(localStorage.token, chat.id);
  865. }
  866. initChatList();
  867. }
  868. } else if (type === 'folder') {
  869. if (folders[id].parent_id === null) {
  870. return;
  871. }
  872. const res = await updateFolderParentIdById(localStorage.token, id, null).catch(
  873. (error) => {
  874. toast.error(`${error}`);
  875. return null;
  876. }
  877. );
  878. if (res) {
  879. await initFolders();
  880. }
  881. }
  882. }}
  883. >
  884. {#if $pinnedChats.length > 0}
  885. <div class="mb-1">
  886. <div class="flex flex-col space-y-1 rounded-xl">
  887. <Folder
  888. buttonClassName=" text-gray-500"
  889. bind:open={showPinnedChat}
  890. on:change={(e) => {
  891. localStorage.setItem('showPinnedChat', e.detail);
  892. console.log(e.detail);
  893. }}
  894. on:import={(e) => {
  895. importChatHandler(e.detail, true);
  896. }}
  897. on:drop={async (e) => {
  898. const { type, id, item } = e.detail;
  899. if (type === 'chat') {
  900. let chat = await getChatById(localStorage.token, id).catch((error) => {
  901. return null;
  902. });
  903. if (!chat && item) {
  904. chat = await importChat(
  905. localStorage.token,
  906. item.chat,
  907. item?.meta ?? {},
  908. false,
  909. null,
  910. item?.created_at ?? null,
  911. item?.updated_at ?? null
  912. );
  913. }
  914. if (chat) {
  915. console.log(chat);
  916. if (chat.folder_id) {
  917. const res = await updateChatFolderIdById(
  918. localStorage.token,
  919. chat.id,
  920. null
  921. ).catch((error) => {
  922. toast.error(`${error}`);
  923. return null;
  924. });
  925. }
  926. if (!chat.pinned) {
  927. const res = await toggleChatPinnedStatusById(localStorage.token, chat.id);
  928. }
  929. initChatList();
  930. }
  931. }
  932. }}
  933. name={$i18n.t('Pinned')}
  934. >
  935. <div
  936. class="ml-3 pl-1 mt-[1px] flex flex-col overflow-y-auto scrollbar-hidden border-s border-gray-100 dark:border-gray-900 text-gray-900 dark:text-gray-200"
  937. >
  938. {#each $pinnedChats as chat, idx (`pinned-chat-${chat?.id ?? idx}`)}
  939. <ChatItem
  940. className=""
  941. id={chat.id}
  942. title={chat.title}
  943. {shiftKey}
  944. selected={selectedChatId === chat.id}
  945. on:select={() => {
  946. selectedChatId = chat.id;
  947. }}
  948. on:unselect={() => {
  949. selectedChatId = null;
  950. }}
  951. on:change={async () => {
  952. initChatList();
  953. }}
  954. on:tag={(e) => {
  955. const { type, name } = e.detail;
  956. tagEventHandler(type, name, chat.id);
  957. }}
  958. />
  959. {/each}
  960. </div>
  961. </Folder>
  962. </div>
  963. </div>
  964. {/if}
  965. <div class=" flex-1 flex flex-col overflow-y-auto scrollbar-hidden">
  966. <div class="pt-1.5">
  967. {#if $chats}
  968. {#each $chats as chat, idx (`chat-${chat?.id ?? idx}`)}
  969. {#if idx === 0 || (idx > 0 && chat.time_range !== $chats[idx - 1].time_range)}
  970. <div
  971. class="w-full pl-2.5 text-xs text-gray-500 dark:text-gray-500 font-medium {idx ===
  972. 0
  973. ? ''
  974. : 'pt-5'} pb-1.5"
  975. >
  976. {$i18n.t(chat.time_range)}
  977. <!-- localisation keys for time_range to be recognized from the i18next parser (so they don't get automatically removed):
  978. {$i18n.t('Today')}
  979. {$i18n.t('Yesterday')}
  980. {$i18n.t('Previous 7 days')}
  981. {$i18n.t('Previous 30 days')}
  982. {$i18n.t('January')}
  983. {$i18n.t('February')}
  984. {$i18n.t('March')}
  985. {$i18n.t('April')}
  986. {$i18n.t('May')}
  987. {$i18n.t('June')}
  988. {$i18n.t('July')}
  989. {$i18n.t('August')}
  990. {$i18n.t('September')}
  991. {$i18n.t('October')}
  992. {$i18n.t('November')}
  993. {$i18n.t('December')}
  994. -->
  995. </div>
  996. {/if}
  997. <ChatItem
  998. className=""
  999. id={chat.id}
  1000. title={chat.title}
  1001. {shiftKey}
  1002. selected={selectedChatId === chat.id}
  1003. on:select={() => {
  1004. selectedChatId = chat.id;
  1005. }}
  1006. on:unselect={() => {
  1007. selectedChatId = null;
  1008. }}
  1009. on:change={async () => {
  1010. initChatList();
  1011. }}
  1012. on:tag={(e) => {
  1013. const { type, name } = e.detail;
  1014. tagEventHandler(type, name, chat.id);
  1015. }}
  1016. />
  1017. {/each}
  1018. {#if $scrollPaginationEnabled && !allChatsLoaded}
  1019. <Loader
  1020. on:visible={(e) => {
  1021. if (!chatListLoading) {
  1022. loadMoreChats();
  1023. }
  1024. }}
  1025. >
  1026. <div
  1027. class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2"
  1028. >
  1029. <Spinner className=" size-4" />
  1030. <div class=" ">{$i18n.t('Loading...')}</div>
  1031. </div>
  1032. </Loader>
  1033. {/if}
  1034. {:else}
  1035. <div
  1036. class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2"
  1037. >
  1038. <Spinner className=" size-4" />
  1039. <div class=" ">{$i18n.t('Loading...')}</div>
  1040. </div>
  1041. {/if}
  1042. </div>
  1043. </div>
  1044. </Folder>
  1045. </div>
  1046. <div class="px-1.5 pt-1.5 pb-2 sticky bottom-0 z-10 -mt-3 sidebar">
  1047. <div
  1048. class=" sidebar-bg-gradient-to-t bg-linear-to-t from-gray-50 dark:from-gray-950 to-transparent from-50% pointer-events-none absolute inset-0 -z-10 -mt-6"
  1049. ></div>
  1050. <div class="flex flex-col font-primary">
  1051. {#if $user !== undefined && $user !== null}
  1052. <UserMenu
  1053. role={$user?.role}
  1054. on:show={(e) => {
  1055. if (e.detail === 'archived-chat') {
  1056. showArchivedChats.set(true);
  1057. }
  1058. }}
  1059. >
  1060. <div
  1061. class=" flex items-center rounded-2xl py-2 px-1.5 w-full hover:bg-gray-100/50 dark:hover:bg-gray-900/50 transition"
  1062. >
  1063. <div class=" self-center mr-3">
  1064. <img
  1065. src={$user?.profile_image_url}
  1066. class=" size-6 object-cover rounded-full"
  1067. alt={$i18n.t('Open User Profile Menu')}
  1068. aria-label={$i18n.t('Open User Profile Menu')}
  1069. />
  1070. </div>
  1071. <div class=" self-center font-medium">{$user?.name}</div>
  1072. </div>
  1073. </UserMenu>
  1074. {/if}
  1075. </div>
  1076. </div>
  1077. </div>
  1078. </div>
  1079. {/if}