123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813 |
- <script lang="ts">
- import { toast } from 'svelte-sonner';
- import { v4 as uuidv4 } from 'uuid';
- import { goto } from '$app/navigation';
- import {
- user,
- chats,
- settings,
- showSettings,
- chatId,
- tags,
- showSidebar,
- mobile,
- showArchivedChats,
- pinnedChats,
- scrollPaginationEnabled,
- currentChatPage,
- temporaryChatEnabled
- } from '$lib/stores';
- import { onMount, getContext, tick, onDestroy } from 'svelte';
- const i18n = getContext('i18n');
- import {
- deleteChatById,
- getChatList,
- getAllTags,
- getChatListBySearchText,
- createNewChat,
- getPinnedChatList,
- toggleChatPinnedStatusById,
- getChatPinnedStatusById,
- getChatById,
- updateChatFolderIdById,
- importChat
- } from '$lib/apis/chats';
- import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders';
- import { WEBUI_BASE_URL } from '$lib/constants';
- import ArchivedChatsModal from './Sidebar/ArchivedChatsModal.svelte';
- import UserMenu from './Sidebar/UserMenu.svelte';
- import ChatItem from './Sidebar/ChatItem.svelte';
- import Spinner from '../common/Spinner.svelte';
- import Loader from '../common/Loader.svelte';
- import AddFilesPlaceholder from '../AddFilesPlaceholder.svelte';
- import SearchInput from './Sidebar/SearchInput.svelte';
- import Folder from '../common/Folder.svelte';
- import Plus from '../icons/Plus.svelte';
- import Tooltip from '../common/Tooltip.svelte';
- import Folders from './Sidebar/Folders.svelte';
- const BREAKPOINT = 768;
- let navElement;
- let search = '';
- let shiftKey = false;
- let selectedChatId = null;
- let showDropdown = false;
- let showPinnedChat = true;
- // Pagination variables
- let chatListLoading = false;
- let allChatsLoaded = false;
- let folders = {};
- const initFolders = async () => {
- const folderList = await getFolders(localStorage.token).catch((error) => {
- toast.error(error);
- return [];
- });
- folders = {};
- // First pass: Initialize all folder entries
- for (const folder of folderList) {
- // Ensure folder is added to folders with its data
- folders[folder.id] = { ...(folders[folder.id] || {}), ...folder };
- }
- // Second pass: Tie child folders to their parents
- for (const folder of folderList) {
- if (folder.parent_id) {
- // Ensure the parent folder is initialized if it doesn't exist
- if (!folders[folder.parent_id]) {
- folders[folder.parent_id] = {}; // Create a placeholder if not already present
- }
- // Initialize childrenIds array if it doesn't exist and add the current folder id
- folders[folder.parent_id].childrenIds = folders[folder.parent_id].childrenIds
- ? [...folders[folder.parent_id].childrenIds, folder.id]
- : [folder.id];
- // Sort the children by updated_at field
- folders[folder.parent_id].childrenIds.sort((a, b) => {
- return folders[b].updated_at - folders[a].updated_at;
- });
- }
- }
- };
- const createFolder = async (name = 'Untitled') => {
- if (name === '') {
- toast.error($i18n.t('Folder name cannot be empty.'));
- return;
- }
- const rootFolders = Object.values(folders).filter((folder) => folder.parent_id === null);
- if (rootFolders.find((folder) => folder.name.toLowerCase() === name.toLowerCase())) {
- // If a folder with the same name already exists, append a number to the name
- let i = 1;
- while (
- rootFolders.find((folder) => folder.name.toLowerCase() === `${name} ${i}`.toLowerCase())
- ) {
- i++;
- }
- name = `${name} ${i}`;
- }
- // Add a dummy folder to the list to show the user that the folder is being created
- const tempId = uuidv4();
- folders = {
- ...folders,
- tempId: {
- id: tempId,
- name: name,
- created_at: Date.now(),
- updated_at: Date.now()
- }
- };
- const res = await createNewFolder(localStorage.token, name).catch((error) => {
- toast.error(error);
- return null;
- });
- if (res) {
- await initFolders();
- }
- };
- const initChatList = async () => {
- // Reset pagination variables
- tags.set(await getAllTags(localStorage.token));
- pinnedChats.set(await getPinnedChatList(localStorage.token));
- initFolders();
- currentChatPage.set(1);
- allChatsLoaded = false;
- if (search) {
- await chats.set(await getChatListBySearchText(localStorage.token, search, $currentChatPage));
- } else {
- await chats.set(await getChatList(localStorage.token, $currentChatPage));
- }
- // Enable pagination
- scrollPaginationEnabled.set(true);
- };
- const loadMoreChats = async () => {
- chatListLoading = true;
- currentChatPage.set($currentChatPage + 1);
- let newChatList = [];
- if (search) {
- newChatList = await getChatListBySearchText(localStorage.token, search, $currentChatPage);
- } else {
- newChatList = await getChatList(localStorage.token, $currentChatPage);
- }
- // once the bottom of the list has been reached (no results) there is no need to continue querying
- allChatsLoaded = newChatList.length === 0;
- await chats.set([...($chats ? $chats : []), ...newChatList]);
- chatListLoading = false;
- };
- let searchDebounceTimeout;
- const searchDebounceHandler = async () => {
- console.log('search', search);
- chats.set(null);
- if (searchDebounceTimeout) {
- clearTimeout(searchDebounceTimeout);
- }
- if (search === '') {
- await initChatList();
- return;
- } else {
- searchDebounceTimeout = setTimeout(async () => {
- allChatsLoaded = false;
- currentChatPage.set(1);
- await chats.set(await getChatListBySearchText(localStorage.token, search));
- if ($chats.length === 0) {
- tags.set(await getAllTags(localStorage.token));
- }
- }, 1000);
- }
- };
- const importChatHandler = async (items, pinned = false, folderId = null) => {
- console.log('importChatHandler', items, pinned, folderId);
- for (const item of items) {
- console.log(item);
- if (item.chat) {
- await importChat(localStorage.token, item.chat, item?.meta ?? {}, pinned, folderId);
- }
- }
- initChatList();
- };
- const inputFilesHandler = async (files) => {
- console.log(files);
- for (const file of files) {
- const reader = new FileReader();
- reader.onload = async (e) => {
- const content = e.target.result;
- try {
- const chatItems = JSON.parse(content);
- importChatHandler(chatItems);
- } catch {
- toast.error($i18n.t(`Invalid file format.`));
- }
- };
- reader.readAsText(file);
- }
- };
- const tagEventHandler = async (type, tagName, chatId) => {
- console.log(type, tagName, chatId);
- if (type === 'delete') {
- initChatList();
- } else if (type === 'add') {
- initChatList();
- }
- };
- let draggedOver = false;
- const onDragOver = (e) => {
- e.preventDefault();
- // Check if a file is being draggedOver.
- if (e.dataTransfer?.types?.includes('Files')) {
- draggedOver = true;
- } else {
- draggedOver = false;
- }
- };
- const onDragLeave = () => {
- draggedOver = false;
- };
- const onDrop = async (e) => {
- e.preventDefault();
- console.log(e); // Log the drop event
- // Perform file drop check and handle it accordingly
- if (e.dataTransfer?.files) {
- const inputFiles = Array.from(e.dataTransfer?.files);
- if (inputFiles && inputFiles.length > 0) {
- console.log(inputFiles); // Log the dropped files
- inputFilesHandler(inputFiles); // Handle the dropped files
- }
- }
- draggedOver = false; // Reset draggedOver status after drop
- };
- let touchstart;
- let touchend;
- function checkDirection() {
- const screenWidth = window.innerWidth;
- const swipeDistance = Math.abs(touchend.screenX - touchstart.screenX);
- if (touchstart.clientX < 40 && swipeDistance >= screenWidth / 8) {
- if (touchend.screenX < touchstart.screenX) {
- showSidebar.set(false);
- }
- if (touchend.screenX > touchstart.screenX) {
- showSidebar.set(true);
- }
- }
- }
- const onTouchStart = (e) => {
- touchstart = e.changedTouches[0];
- console.log(touchstart.clientX);
- };
- const onTouchEnd = (e) => {
- touchend = e.changedTouches[0];
- checkDirection();
- };
- const onKeyDown = (e) => {
- if (e.key === 'Shift') {
- shiftKey = true;
- }
- };
- const onKeyUp = (e) => {
- if (e.key === 'Shift') {
- shiftKey = false;
- }
- };
- const onFocus = () => {};
- const onBlur = () => {
- shiftKey = false;
- selectedChatId = null;
- };
- onMount(async () => {
- showPinnedChat = localStorage?.showPinnedChat ? localStorage.showPinnedChat === 'true' : true;
- mobile.subscribe((e) => {
- if ($showSidebar && e) {
- showSidebar.set(false);
- }
- if (!$showSidebar && !e) {
- showSidebar.set(true);
- }
- });
- showSidebar.set(!$mobile ? localStorage.sidebar === 'true' : false);
- showSidebar.subscribe((value) => {
- localStorage.sidebar = value;
- });
- await initChatList();
- window.addEventListener('keydown', onKeyDown);
- window.addEventListener('keyup', onKeyUp);
- window.addEventListener('touchstart', onTouchStart);
- window.addEventListener('touchend', onTouchEnd);
- window.addEventListener('focus', onFocus);
- window.addEventListener('blur', onBlur);
- const dropZone = document.getElementById('sidebar');
- dropZone?.addEventListener('dragover', onDragOver);
- dropZone?.addEventListener('drop', onDrop);
- dropZone?.addEventListener('dragleave', onDragLeave);
- });
- onDestroy(() => {
- window.removeEventListener('keydown', onKeyDown);
- window.removeEventListener('keyup', onKeyUp);
- window.removeEventListener('touchstart', onTouchStart);
- window.removeEventListener('touchend', onTouchEnd);
- window.removeEventListener('focus', onFocus);
- window.removeEventListener('blur', onBlur);
- const dropZone = document.getElementById('sidebar');
- dropZone?.removeEventListener('dragover', onDragOver);
- dropZone?.removeEventListener('drop', onDrop);
- dropZone?.removeEventListener('dragleave', onDragLeave);
- });
- </script>
- <ArchivedChatsModal
- bind:show={$showArchivedChats}
- on:change={async () => {
- await initChatList();
- }}
- />
- <!-- svelte-ignore a11y-no-static-element-interactions -->
- {#if $showSidebar}
- <div
- class=" fixed md:hidden z-40 top-0 right-0 left-0 bottom-0 bg-black/60 w-full min-h-screen h-screen flex justify-center overflow-hidden overscroll-contain"
- on:mousedown={() => {
- showSidebar.set(!$showSidebar);
- }}
- />
- {/if}
- <div
- bind:this={navElement}
- id="sidebar"
- class="h-screen max-h-[100dvh] min-h-screen select-none {$showSidebar
- ? 'md:relative w-[260px] max-w-[260px]'
- : '-translate-x-[260px] w-[0px]'} bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-200 text-sm transition fixed z-50 top-0 left-0 overflow-x-hidden
- "
- data-state={$showSidebar}
- >
- <div
- class="py-2 my-auto flex flex-col justify-between h-screen max-h-[100dvh] w-[260px] overflow-x-hidden z-50 {$showSidebar
- ? ''
- : 'invisible'}"
- >
- <div class="px-1.5 flex justify-between space-x-1 text-gray-600 dark:text-gray-400">
- <a
- id="sidebar-new-chat-button"
- class="flex flex-1 rounded-lg px-2 py-1 h-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
- href="/"
- draggable="false"
- on:click={async () => {
- selectedChatId = null;
- await goto('/');
- const newChatButton = document.getElementById('new-chat-button');
- setTimeout(() => {
- newChatButton?.click();
- if ($mobile) {
- showSidebar.set(false);
- }
- }, 0);
- }}
- >
- <div class="self-center mx-1.5">
- <img
- crossorigin="anonymous"
- src="{WEBUI_BASE_URL}/static/favicon.png"
- class=" size-5 -translate-x-1.5 rounded-full"
- alt="logo"
- />
- </div>
- <div class=" self-center font-medium text-sm text-gray-850 dark:text-white font-primary">
- {$i18n.t('New Chat')}
- </div>
- </a>
- <button
- class=" cursor-pointer p-[7px] flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-900 transition"
- on:click={() => {
- showSidebar.set(!$showSidebar);
- }}
- >
- <div class=" m-auto self-center">
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
- class="size-5"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12"
- />
- </svg>
- </div>
- </button>
- </div>
- <div class="px-1.5 flex justify-center text-gray-800 dark:text-gray-200">
- <a
- class="flex-grow flex space-x-3 rounded-lg px-2 py-[7px] hover:bg-gray-100 dark:hover:bg-gray-900 transition"
- href="/workspace"
- on:click={() => {
- selectedChatId = null;
- chatId.set('');
- if ($mobile) {
- showSidebar.set(false);
- }
- }}
- draggable="false"
- >
- <div class="self-center">
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
- class="size-[1.1rem]"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- 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"
- />
- </svg>
- </div>
- <div class="flex self-center">
- <div class=" self-center font-medium text-sm font-primary">{$i18n.t('Workspace')}</div>
- </div>
- </a>
- </div>
- <div class="relative {$temporaryChatEnabled ? 'opacity-20' : ''}">
- {#if $temporaryChatEnabled}
- <div class="absolute z-40 w-full h-full flex justify-center"></div>
- {/if}
- <SearchInput
- bind:value={search}
- on:input={searchDebounceHandler}
- placeholder={$i18n.t('Search')}
- />
- <div class="absolute z-40 right-3.5 top-1">
- <Tooltip content={$i18n.t('New folder')}>
- <button
- class="p-1 rounded-lg bg-gray-50 hover:bg-gray-100 dark:bg-gray-950 dark:hover:bg-gray-900 transition"
- on:click={() => {
- createFolder();
- }}
- >
- <Plus />
- </button>
- </Tooltip>
- </div>
- </div>
- <div
- class="relative flex flex-col flex-1 overflow-y-auto {$temporaryChatEnabled
- ? 'opacity-20'
- : ''}"
- >
- {#if $temporaryChatEnabled}
- <div class="absolute z-40 w-full h-full flex justify-center"></div>
- {/if}
- {#if !search && $pinnedChats.length > 0}
- <div class="flex flex-col space-y-1 rounded-xl">
- <Folder
- className="px-2"
- bind:open={showPinnedChat}
- on:change={(e) => {
- localStorage.setItem('showPinnedChat', e.detail);
- console.log(e.detail);
- }}
- on:import={(e) => {
- importChatHandler(e.detail, true);
- }}
- on:drop={async (e) => {
- const { type, id, item } = e.detail;
- if (type === 'chat') {
- let chat = await getChatById(localStorage.token, id).catch((error) => {
- return null;
- });
- if (!chat && item) {
- chat = await importChat(localStorage.token, item.chat, item?.meta ?? {});
- }
- if (chat) {
- console.log(chat);
- if (chat.folder_id) {
- const res = await updateChatFolderIdById(
- localStorage.token,
- chat.id,
- null
- ).catch((error) => {
- toast.error(error);
- return null;
- });
- }
- if (!chat.pinned) {
- const res = await toggleChatPinnedStatusById(localStorage.token, chat.id);
- }
- initChatList();
- }
- }
- }}
- name={$i18n.t('Pinned')}
- >
- <div
- class="ml-3 pl-1 mt-[1px] flex flex-col overflow-y-auto scrollbar-hidden border-s border-gray-100 dark:border-gray-900"
- >
- {#each $pinnedChats as chat, idx}
- <ChatItem
- className=""
- id={chat.id}
- title={chat.title}
- {shiftKey}
- selected={selectedChatId === chat.id}
- on:select={() => {
- selectedChatId = chat.id;
- }}
- on:unselect={() => {
- selectedChatId = null;
- }}
- on:change={async () => {
- initChatList();
- }}
- on:tag={(e) => {
- const { type, name } = e.detail;
- tagEventHandler(type, name, chat.id);
- }}
- />
- {/each}
- </div>
- </Folder>
- </div>
- {/if}
- <div class=" flex-1 flex flex-col overflow-y-auto scrollbar-hidden">
- {#if !search && folders}
- <Folders
- {folders}
- on:import={(e) => {
- const { folderId, items } = e.detail;
- importChatHandler(items, false, folderId);
- }}
- on:update={async (e) => {
- initChatList();
- }}
- on:change={async () => {
- initChatList();
- }}
- />
- {/if}
- <Folder
- collapsible={!search}
- className="px-2 mt-0.5"
- name={$i18n.t('All chats')}
- on:import={(e) => {
- importChatHandler(e.detail);
- }}
- on:drop={async (e) => {
- const { type, id, item } = e.detail;
- if (type === 'chat') {
- let chat = await getChatById(localStorage.token, id).catch((error) => {
- return null;
- });
- if (!chat && item) {
- chat = await importChat(localStorage.token, item.chat, item?.meta ?? {});
- }
- if (chat) {
- console.log(chat);
- if (chat.folder_id) {
- const res = await updateChatFolderIdById(localStorage.token, chat.id, null).catch(
- (error) => {
- toast.error(error);
- return null;
- }
- );
- }
- if (chat.pinned) {
- const res = await toggleChatPinnedStatusById(localStorage.token, chat, id);
- }
- initChatList();
- }
- } else if (type === 'folder') {
- if (folders[id].parent_id === null) {
- return;
- }
- const res = await updateFolderParentIdById(localStorage.token, id, null).catch(
- (error) => {
- toast.error(error);
- return null;
- }
- );
- if (res) {
- await initFolders();
- }
- }
- }}
- >
- <div class="pt-1.5">
- {#if $chats}
- {#each $chats as chat, idx}
- {#if idx === 0 || (idx > 0 && chat.time_range !== $chats[idx - 1].time_range)}
- <div
- class="w-full pl-2.5 text-xs text-gray-500 dark:text-gray-500 font-medium {idx ===
- 0
- ? ''
- : 'pt-5'} pb-1.5"
- >
- {$i18n.t(chat.time_range)}
- <!-- localisation keys for time_range to be recognized from the i18next parser (so they don't get automatically removed):
- {$i18n.t('Today')}
- {$i18n.t('Yesterday')}
- {$i18n.t('Previous 7 days')}
- {$i18n.t('Previous 30 days')}
- {$i18n.t('January')}
- {$i18n.t('February')}
- {$i18n.t('March')}
- {$i18n.t('April')}
- {$i18n.t('May')}
- {$i18n.t('June')}
- {$i18n.t('July')}
- {$i18n.t('August')}
- {$i18n.t('September')}
- {$i18n.t('October')}
- {$i18n.t('November')}
- {$i18n.t('December')}
- -->
- </div>
- {/if}
- <ChatItem
- className=""
- id={chat.id}
- title={chat.title}
- {shiftKey}
- selected={selectedChatId === chat.id}
- on:select={() => {
- selectedChatId = chat.id;
- }}
- on:unselect={() => {
- selectedChatId = null;
- }}
- on:change={async () => {
- initChatList();
- }}
- on:tag={(e) => {
- const { type, name } = e.detail;
- tagEventHandler(type, name, chat.id);
- }}
- />
- {/each}
- {#if $scrollPaginationEnabled && !allChatsLoaded}
- <Loader
- on:visible={(e) => {
- if (!chatListLoading) {
- loadMoreChats();
- }
- }}
- >
- <div
- class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2"
- >
- <Spinner className=" size-4" />
- <div class=" ">Loading...</div>
- </div>
- </Loader>
- {/if}
- {:else}
- <div class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2">
- <Spinner className=" size-4" />
- <div class=" ">Loading...</div>
- </div>
- {/if}
- </div>
- </Folder>
- </div>
- </div>
- <div class="px-2">
- <div class="flex flex-col font-primary">
- {#if $user !== undefined}
- <UserMenu
- role={$user.role}
- on:show={(e) => {
- if (e.detail === 'archived-chat') {
- showArchivedChats.set(true);
- }
- }}
- >
- <button
- class=" flex items-center rounded-xl py-2.5 px-2.5 w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
- on:click={() => {
- showDropdown = !showDropdown;
- }}
- >
- <div class=" self-center mr-3">
- <img
- src={$user.profile_image_url}
- class=" max-w-[30px] object-cover rounded-full"
- alt="User profile"
- />
- </div>
- <div class=" self-center font-medium">{$user.name}</div>
- </button>
- </UserMenu>
- {/if}
- </div>
- </div>
- </div>
- </div>
- <style>
- .scrollbar-hidden:active::-webkit-scrollbar-thumb,
- .scrollbar-hidden:focus::-webkit-scrollbar-thumb,
- .scrollbar-hidden:hover::-webkit-scrollbar-thumb {
- visibility: visible;
- }
- .scrollbar-hidden::-webkit-scrollbar-thumb {
- visibility: hidden;
- }
- </style>
|