Sidebar.svelte 28 KB

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