Files
open-webui/src/lib/components/layout/Sidebar.svelte
T
2026-08-11 01:25:20 -06:00

1751 lines
51 KiB
Svelte

<script lang="ts">
import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid';
import Sortable from 'sortablejs';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import {
user,
chats,
settings,
chatId,
tags,
folders as _folders,
showSidebar,
showSearch,
mobile,
pinnedChats,
pinnedNotes,
temporaryChatEnabled,
channels,
socket,
config,
isApp,
models,
selectedFolder,
WEBUI_NAME,
sidebarWidth
} from '$lib/stores';
import {
loadNextChatListPage,
refreshChatList,
registerFolderRefreshHandler,
setAllChatsRead,
setChatActive,
setChatReadAt
} from '$lib/stores/chatList';
import { onMount, getContext, tick, onDestroy } from 'svelte';
const i18n = getContext('i18n');
$: canImportChats = $user?.role === 'admin' || ($user?.permissions?.chat?.import ?? true);
import {
getAllTags,
toggleChatPinnedStatusById,
getChatById,
updateChatFolderIdById,
importChats,
deleteAllChats,
getChatListBySearchText,
markChatsRead
} from '$lib/apis/chats';
import {
createNewFolder,
getFolders,
getSharedFolders,
updateFolderParentIdById
} from '$lib/apis/folders';
import { createNewNote, getPinnedNoteList, toggleNotePinnedStatusById } from '$lib/apis/notes';
import { updateUserSettings } from '$lib/apis/users';
import { createNoteHandler } from '$lib/components/notes/utils';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
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 Folder from '../common/Folder.svelte';
import SidebarSection from './Sidebar/Section.svelte';
import Tooltip from '../common/Tooltip.svelte';
import Folders from './Sidebar/Folders.svelte';
import SharedFolderItem from './Sidebar/SharedFolderItem.svelte';
import { getChannels, createNewChannel } from '$lib/apis/channels';
import ChannelModal from './Sidebar/ChannelModal.svelte';
import ChannelItem from './Sidebar/ChannelItem.svelte';
import SearchModal from './SearchModal.svelte';
import FolderModal from './Sidebar/Folders/FolderModal.svelte';
import PinnedModelList from './Sidebar/PinnedModelList.svelte';
import PinnedNoteList from './Sidebar/PinnedNoteList.svelte';
import CalendarIcon from './Sidebar/icons/Calendar.svelte';
import ClockIcon from './Sidebar/icons/Clock.svelte';
import CodeIcon from './Sidebar/icons/Code.svelte';
import EditPencilIcon from './Sidebar/icons/EditPencil.svelte';
import NotesIcon from './Sidebar/icons/Notes.svelte';
import SearchIcon from './Sidebar/icons/Search.svelte';
import Sidebar from '../icons/Sidebar.svelte';
import WorkspaceIcon from './Sidebar/icons/Workspace.svelte';
import HotkeyHint from '../common/HotkeyHint.svelte';
import Dropdown from '../common/Dropdown.svelte';
import DropdownMenu from '../common/DropdownMenu.svelte';
import CheckIcon from '../icons/Check.svelte';
import MoreHorizontalIcon from './Sidebar/icons/MoreHorizontal.svelte';
import MobileSwipePanel from '../common/MobileSwipePanel.svelte';
const BREAKPOINT = 768;
const DEFAULT_PINNED_ITEMS = ['notes', 'workspace'];
let scrollTop = 0;
let navElement;
let shiftKey = false;
let selectedChatId = null;
// Keep the optimistic sidebar highlight in sync with the active chat. Leaving the
// chat view (e.g. navigating to an admin page) clears chatId, and programmatic
// navigation such as cloning moves chatId to a different chat — in both cases the
// previously-selected item must not stay highlighted. The optimistic on-click
// highlight is preserved because a click sets selectedChatId without changing
// chatId, so this reactive only re-runs once chatId catches up to the same value.
$: selectedChatId = $chatId || null;
let showCreateChannel = false;
// Pagination variables
let chatListLoading = false;
let chatListReady = false;
let allChatsLoaded = false;
let showCreateFolderModal = false;
let pinnedModels = [];
let showPinnedModels = false;
let showPinnedNotes = false;
let showChannels = false;
let showFolders = false;
let showSharedFolders = false;
let showChatsMenu = false;
let folders = {};
let folderRegistry: Record<
string,
{
setFolderItems?: () => unknown;
upsertChat?: (chat: Record<string, unknown>) => unknown;
setChatActive?: (chatId: string, active: boolean) => boolean;
setChatReadAt?: (chatId: string, lastReadAt: number) => boolean;
setAllChatsRead?: () => unknown;
}
> = {};
let newFolderId = null;
let sharedFolders: any[] = [];
$: pinnedItems = $settings?.pinnedMenuItems ?? DEFAULT_PINNED_ITEMS;
const isMenuItemVisible = (id) => {
switch (id) {
case 'notes':
return (
($config?.features?.enable_notes ?? false) &&
($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))
);
case 'workspace':
return (
$user?.role === 'admin' ||
$user?.permissions?.workspace?.models ||
$user?.permissions?.workspace?.knowledge ||
$user?.permissions?.workspace?.prompts ||
$user?.permissions?.workspace?.tools ||
$user?.permissions?.workspace?.skills
);
case 'automations':
return (
$config?.features?.enable_automations &&
($user?.role === 'admin' || $user?.permissions?.features?.automations)
);
case 'calendar':
return (
$config?.features?.enable_calendar &&
($user?.role === 'admin' || $user?.permissions?.features?.calendar)
);
case 'playground':
return $user?.role === 'admin';
default:
return false;
}
};
const getMenuItemMeta = (id) => {
const items = {
notes: { label: 'Notes', href: '/notes', iconType: 'note' },
workspace: { label: 'Workspace', href: '/workspace', iconType: 'workspace' },
automations: { label: 'Automations', href: '/automations', iconType: 'automations' },
calendar: { label: 'Calendar', href: '/calendar', iconType: 'calendar' },
playground: { label: 'Playground', href: '/playground', iconType: 'playground' }
};
return items[id];
};
const menuItemPathPrefixes = {
notes: '/notes',
workspace: '/workspace',
calendar: '/calendar',
automations: '/automations',
playground: '/playground'
};
const getActiveMenuItemId = (pathname) => {
for (const [id, pathPrefix] of Object.entries(menuItemPathPrefixes)) {
if (pathname === pathPrefix || pathname.startsWith(`${pathPrefix}/`)) {
return id;
}
}
return null;
};
$: activeMenuItemId = getActiveMenuItemId($page.url.pathname);
const initPinnedMenuSortable = () => {
const el = document.getElementById('pinned-menu-items-list');
if (el && !$mobile) {
new Sortable(el, {
animation: 150,
onUpdate: async (event) => {
const itemId = event.item.dataset.id;
const newIndex = event.newIndex;
const current = [...pinnedItems];
const oldIndex = current.indexOf(itemId);
current.splice(oldIndex, 1);
current.splice(newIndex, 0, itemId);
settings.set({ ...$settings, pinnedMenuItems: current });
await updateUserSettings(localStorage.token, { ui: $settings });
}
});
}
};
$: if ($selectedFolder) {
initFolders();
}
const initFolders = async () => {
if ($config?.features?.enable_folders === false) {
return;
}
const folderList = await getFolders(localStorage.token).catch((error) => {
return [];
});
_folders.set(folderList.sort((a, b) => b.updated_at - a.updated_at));
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 };
if (newFolderId && folder.id === newFolderId) {
folders[folder.id].new = true;
newFolderId = null;
}
}
// 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;
});
}
}
// Merge shared folders into the same structure
try {
sharedFolders = await getSharedFolders(localStorage.token);
} catch (e) {
sharedFolders = [];
}
for (const sf of sharedFolders) {
if (folders[sf.id]) continue; // Already owned by user
folders[sf.id] = { ...sf, shared: true };
}
// Build parent-child relationships for shared folders
for (const sf of sharedFolders) {
if (folders[sf.id]?.shared && sf.parent_id && folders[sf.parent_id]) {
folders[sf.parent_id].childrenIds = folders[sf.parent_id].childrenIds
? [...new Set([...folders[sf.parent_id].childrenIds, sf.id])]
: [sf.id];
}
}
};
const initSharedFolders = async () => {
await initFolders();
};
const createFolder = async ({ name, data, parent_id }) => {
name = name?.trim();
if (!name) {
toast.error($i18n.t('Folder name cannot be empty.'));
return;
}
// Check for duplicate names in the same parent
const siblings = Object.values(folders).filter((folder) => folder.parent_id === parent_id);
if (siblings.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 (
siblings.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,
parent_id: parent_id,
created_at: Date.now(),
updated_at: Date.now()
}
};
const res = await createNewFolder(localStorage.token, {
name,
data,
parent_id
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
// newFolderId = res.id;
await initFolders();
showFolders = true;
}
};
const initChannels = async () => {
// default (none), group, dm type
const res = await getChannels(localStorage.token).catch((error) => {
return null;
});
if (res) {
await channels.set(
res.sort(
(a, b) =>
['', null, 'group', 'dm'].indexOf(a.type) - ['', null, 'group', 'dm'].indexOf(b.type)
)
);
}
};
const initChatList = async () => {
// Reset pagination variables
console.log('initChatList');
allChatsLoaded = false;
chatListReady = false;
initFolders();
initSharedFolders();
await Promise.all([
(async () => {
console.log('Init tags');
const _tags = await getAllTags(localStorage.token);
tags.set(_tags);
})(),
(async () => {
if (
$config?.features?.enable_notes &&
($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))
) {
console.log('Init pinned notes');
const _pinnedNotes = await getPinnedNoteList(localStorage.token).catch(() => []);
pinnedNotes.set(_pinnedNotes);
}
})(),
(async () => {
console.log('Init chat list');
await refreshChatRows();
})()
]);
};
const initSidebarData = async () => {
// Only fetch channels if the feature is enabled and user has permission
if (
$config?.features?.enable_channels &&
($user?.role === 'admin' || ($user?.permissions?.features?.channels ?? true))
) {
await initChannels();
}
await initChatList();
};
const refreshChatRows = async () => {
const result = await refreshChatList(localStorage.token, { refreshPinned: true });
if (result.accepted) {
await initFolders();
await Promise.all(Object.values(folderRegistry).map((folder) => folder?.setFolderItems?.()));
allChatsLoaded = result.allLoaded;
chatListReady = true;
}
};
const loadMoreChats = async () => {
if (chatListLoading || allChatsLoaded || !$showSidebar) {
return;
}
chatListLoading = true;
const result = await loadNextChatListPage(localStorage.token);
allChatsLoaded = result.allLoaded;
chatListLoading = false;
};
const applyFolderUnreadCounts = (folderUnreadCounts: Record<string, number>) => {
folders = Object.fromEntries(
Object.entries(folders).map(([id, folder]) => [
id,
id in folderUnreadCounts ? { ...folder, unread_count: folderUnreadCounts[id] } : folder
])
);
_folders.update((folderList) =>
folderList.map((folder) =>
folder.id in folderUnreadCounts
? { ...folder, unread_count: folderUnreadCounts[folder.id] }
: folder
)
);
};
const applyChatReadState = (data) => {
if (data?.folder_unread_counts) {
applyFolderUnreadCounts(data.folder_unread_counts);
}
if (data?.chat_id && typeof data?.last_read_at === 'number') {
setChatReadAt(data.chat_id, data.last_read_at);
for (const folder of Object.values(folderRegistry)) {
folder?.setChatReadAt?.(data.chat_id, data.last_read_at);
}
}
};
const markAllChatsReadHandler = async () => {
const res = await markChatsRead(localStorage.token).catch((error) => {
toast.error(`${error}`);
return null;
});
if (!res) return;
showChatsMenu = false;
if (res.folder_unread_counts) {
applyFolderUnreadCounts(res.folder_unread_counts);
}
setAllChatsRead();
for (const folder of Object.values(folderRegistry)) {
folder?.setAllChatsRead?.();
}
};
const importChatHandler = async (items, pinned = false, folderId = null) => {
if (!canImportChats) {
toast.error($i18n.t('Access prohibited'));
return;
}
console.log('importChatHandler', items, pinned, folderId);
for (const item of items) {
console.log(item);
if (item.chat) {
await importChats(localStorage.token, [
{
chat: item.chat,
meta: item?.meta ?? {},
pinned: pinned,
folder_id: folderId,
created_at: item?.created_at ?? null,
updated_at: item?.updated_at ?? null
}
]);
}
}
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
};
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;
};
const closeMobileSidebar = () => {
if ($mobile) {
showSidebar.set(false);
}
};
const MIN_WIDTH = 220;
const MAX_WIDTH = 480;
let isResizing = false;
let startWidth = 0;
let startClientX = 0;
const resizeStartHandler = (e: MouseEvent) => {
if ($mobile) return;
isResizing = true;
startClientX = e.clientX;
startWidth = $sidebarWidth ?? 245;
document.body.style.userSelect = 'none';
};
const resizeEndHandler = () => {
if (!isResizing) return;
isResizing = false;
document.body.style.userSelect = '';
localStorage.setItem('sidebarWidth', String($sidebarWidth));
};
const resizeSidebarHandler = (endClientX) => {
const dx = endClientX - startClientX;
const newSidebarWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + dx));
sidebarWidth.set(newSidebarWidth);
document.documentElement.style.setProperty('--sidebar-width', `${newSidebarWidth}px`);
};
onMount(async () => {
try {
const width = Number(localStorage.getItem('sidebarWidth'));
if (!Number.isNaN(width) && width >= MIN_WIDTH && width <= MAX_WIDTH) {
sidebarWidth.set(width);
}
} catch {}
document.documentElement.style.setProperty('--sidebar-width', `${$sidebarWidth}px`);
sidebarWidth.subscribe((w) => {
document.documentElement.style.setProperty('--sidebar-width', `${w}px`);
});
showSidebar.set(!$mobile ? localStorage.sidebar === 'true' : false);
const unsubscribers = [
mobile.subscribe((value) => {
if ($showSidebar && value) {
showSidebar.set(false);
}
if ($showSidebar && !value) {
const navElement = document.getElementsByTagName('nav')[0];
if (navElement) {
navElement.style['-webkit-app-region'] = 'drag';
}
}
}),
showSidebar.subscribe(async (value) => {
localStorage.sidebar = value;
// nav element is not available on the first render
const navElement = document.getElementsByTagName('nav')[0];
if (navElement) {
if ($mobile) {
if (!value) {
navElement.style['-webkit-app-region'] = 'drag';
} else {
navElement.style['-webkit-app-region'] = 'no-drag';
}
} else {
navElement.style['-webkit-app-region'] = 'drag';
}
}
}),
settings.subscribe((value) => {
if (pinnedModels != value?.pinnedModels ?? []) {
pinnedModels = value?.pinnedModels ?? [];
showPinnedModels = pinnedModels.length > 0;
}
})
];
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
const dropZone = document.getElementById('sidebar');
if (dropZone) {
dropZone.addEventListener('dragover', onDragOver);
dropZone.addEventListener('drop', onDrop);
dropZone.addEventListener('dragleave', onDragLeave);
}
const socketInstance = $socket;
socketInstance?.on('events', chatActiveEventHandler);
socketInstance?.on('connect', refreshChatRows);
const unregisterFolderRefreshHandler = registerFolderRefreshHandler((folderId, chat) => {
if (folderId) {
if (chat) {
return folderRegistry[folderId]?.upsertChat?.(chat);
}
return folderRegistry[folderId]?.setFolderItems?.();
}
return Promise.all(Object.values(folderRegistry).map((folder) => folder?.setFolderItems?.()));
});
await tick();
await initSidebarData();
initPinnedMenuSortable();
return () => {
unsubscribers.forEach((unsubscriber) => unsubscriber());
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
if (dropZone) {
dropZone.removeEventListener('dragover', onDragOver);
dropZone.removeEventListener('drop', onDrop);
dropZone.removeEventListener('dragleave', onDragLeave);
}
socketInstance?.off('events', chatActiveEventHandler);
socketInstance?.off('connect', refreshChatRows);
unregisterFolderRefreshHandler();
};
});
// Handler for chat events (defined outside onMount for proper cleanup)
const chatActiveEventHandler = async (event: {
chat_id: string;
message_id: string;
data: {
type: string;
data: {
active?: boolean;
folder_id?: string | null;
last_read_at?: number;
folder_unread_counts?: Record<string, number>;
};
};
}) => {
if (event.data?.type === 'chat:active') {
const eventData = event.data.data ?? {};
const active = eventData.active ?? false;
const found = setChatActive(event.chat_id, active);
let foundInFolder = false;
for (const folder of Object.values(folderRegistry)) {
foundInFolder = folder?.setChatActive?.(event.chat_id, active) || foundInFolder;
}
if (!foundInFolder && active && eventData.folder_id) {
await folderRegistry[eventData.folder_id]?.setFolderItems?.();
}
if (!found && active) {
await refreshChatRows();
}
} else if (event.data?.type === 'chat:list') {
const eventData = event.data.data ?? {};
const folderUnreadCounts = eventData.folder_unread_counts;
if (folderUnreadCounts) {
applyFolderUnreadCounts(folderUnreadCounts);
}
if (typeof eventData.last_read_at === 'number') {
setChatReadAt(event.chat_id, eventData.last_read_at);
for (const folder of Object.values(folderRegistry)) {
folder?.setChatReadAt?.(event.chat_id, eventData.last_read_at);
}
return;
}
await refreshChatRows();
if (eventData.folder_id) {
await folderRegistry[eventData.folder_id]?.setFolderItems?.();
}
}
};
const newChatHandler = async () => {
selectedChatId = null;
selectedFolder.set(null);
closeMobileSidebar();
if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) {
await temporaryChatEnabled.set(true);
} else {
await temporaryChatEnabled.set(false);
}
};
const itemClickHandler = async () => {
selectedChatId = null;
chatId.set('');
closeMobileSidebar();
await tick();
};
const isWindows = /Windows/i.test(navigator.userAgent);
</script>
<ChannelModal
bind:show={showCreateChannel}
onSubmit={async (payload: any) => {
let { type, name, is_private, access_grants, group_ids, user_ids } = payload ?? {};
name = name?.trim();
if (type === 'dm') {
if (!user_ids || user_ids.length === 0) {
toast.error($i18n.t('Please select at least one user for Direct Message channel.'));
return;
}
} else {
if (!name) {
toast.error($i18n.t('Channel name cannot be empty.'));
return;
}
}
const res = await createNewChannel(localStorage.token, {
type: type,
name: name,
is_private: is_private,
access_grants: access_grants,
group_ids: group_ids,
user_ids: user_ids
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
$socket.emit('join-channels', { auth: { token: $user?.token } });
await initChannels();
showCreateChannel = false;
showChannels = true;
goto(`/channels/${res.id}`);
}
}}
/>
<FolderModal
bind:show={showCreateFolderModal}
onSubmit={async (folder) => {
await createFolder(folder);
showCreateFolderModal = false;
}}
/>
<svelte:window
on:mousemove={(e) => {
if (!isResizing) return;
resizeSidebarHandler(e.clientX);
}}
on:mouseup={() => {
resizeEndHandler();
}}
/>
<MobileSwipePanel
open={$showSidebar}
enabled={$mobile}
width={$sidebarWidth}
onOpenChange={(open) => showSidebar.set(open)}
let:visible
let:progress
let:panelStyle
let:backdropStyle
>
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if visible}
<div
class=" {$isApp
? ' ml-[4.5rem] md:ml-0'
: ''} fixed md:hidden z-40 top-0 right-0 left-0 bottom-0 bg-black w-full min-h-screen h-screen flex justify-center overflow-hidden overscroll-contain {progress >
0.02
? ''
: 'pointer-events-none'}"
style={backdropStyle}
on:mousedown={() => {
showSidebar.set(false);
}}
></div>
{/if}
<SearchModal
bind:show={$showSearch}
onClose={() => {
if ($mobile) {
showSidebar.set(false);
}
}}
/>
<button
id="sidebar-new-chat-button"
class="hidden"
on:click={() => {
goto('/');
newChatHandler();
}}
/>
{#if !$mobile && !$showSidebar}
<div
class="w-[calc(42px*var(--app-text-scale,1))] shrink-0 py-[calc(0.25rem*var(--app-text-scale,1))] px-[calc(0.25rem*var(--app-text-scale,1))] flex flex-col justify-between text-gray-700 dark:text-gray-300 hover:bg-gray-50/30 dark:hover:bg-gray-800/30 h-full z-10 transition-all border-e-[0.5px] border-gray-50 dark:border-gray-850/30"
id="sidebar"
role="navigation"
aria-label={$i18n.t('Chat history')}
>
<button
class="flex flex-col flex-1 {isWindows ? 'cursor-pointer' : 'cursor-[e-resize]'}"
on:click={async () => {
showSidebar.set(!$showSidebar);
}}
>
<div class="pb-1">
<Tooltip
content={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
placement="right"
>
<button
class="flex size-8.5 items-center justify-center transition group {isWindows
? 'cursor-pointer'
: 'cursor-[e-resize]'}"
aria-label={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
>
<div
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-50 dark:group-hover:bg-gray-900"
>
<!-- LICENSE covers this Open WebUI sidebar logo.
Do not alter, remove, obscure, or replace it except as LICENSE permits:
https://docs.openwebui.com/license. -->
<img
src="{WEBUI_BASE_URL}/static/favicon.png"
class="sidebar-new-chat-icon size-5 rounded-full group-hover:hidden"
alt=""
/>
<Sidebar className="size-4 hidden group-hover:flex" />
</div>
</button>
</Tooltip>
</div>
<div class="-gap-0.5">
<div class="">
<Tooltip content={$i18n.t('New Chat')} placement="right">
<a
class=" cursor-pointer flex size-8 items-center justify-center transition group"
href="/"
draggable="false"
on:click={async (e) => {
e.stopImmediatePropagation();
e.preventDefault();
goto('/');
newChatHandler();
}}
aria-label={$i18n.t('New Chat')}
>
<div
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-50 dark:group-hover:bg-gray-900"
>
<EditPencilIcon className="size-4" strokeWidth="1.5" />
</div>
</a>
</Tooltip>
</div>
<div>
<Tooltip content={$i18n.t('Search')} placement="right">
<button
class=" cursor-pointer flex size-8 items-center justify-center transition group"
on:click={(e) => {
e.stopImmediatePropagation();
e.preventDefault();
showSearch.set(true);
}}
draggable="false"
aria-label={$i18n.t('Search')}
>
<div
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-50 dark:group-hover:bg-gray-900"
>
<SearchIcon className="size-4" strokeWidth="1.5" />
</div>
</button>
</Tooltip>
</div>
{#each pinnedItems as itemId (itemId)}
{@const meta = getMenuItemMeta(itemId)}
{#if meta && isMenuItemVisible(itemId)}
<div class="">
<Tooltip content={$i18n.t(meta.label)} placement="right">
<a
class=" cursor-pointer flex size-8 items-center justify-center transition group"
href={meta.href}
on:click={async (e) => {
e.stopImmediatePropagation();
e.preventDefault();
goto(meta.href);
itemClickHandler();
}}
draggable="false"
aria-label={$i18n.t(meta.label)}
>
<div
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition {itemId ===
activeMenuItemId
? ($settings?.highContrastMode ?? false)
? 'bg-black/[0.035] dark:bg-white/[0.06]'
: 'bg-black/[0.035] dark:bg-white/[0.045]'
: 'group-hover:bg-gray-50 dark:group-hover:bg-gray-900'}"
>
{#if itemId === 'notes'}
<NotesIcon className="size-4" strokeWidth="1.5" />
{:else if itemId === 'workspace'}
<WorkspaceIcon className="size-4" strokeWidth="1.5" />
{:else if itemId === 'automations'}
<ClockIcon className="size-4" strokeWidth="1.5" />
{:else if itemId === 'calendar'}
<CalendarIcon className="size-4" strokeWidth="1.5" />
{:else if itemId === 'playground'}
<CodeIcon className="size-4" strokeWidth="1.5" />
{/if}
</div>
</a>
</Tooltip>
</div>
{/if}
{/each}
</div>
</button>
<div>
<div>
<div class=" flex justify-center items-center">
{#if $user !== undefined && $user !== null}
<UserMenu role={$user?.role} profile={$config?.features?.enable_user_status ?? true}>
<button
type="button"
class=" cursor-pointer flex size-8.5 items-center justify-center transition group"
aria-label={$i18n.t('User menu')}
>
<div
class="self-center relative flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-50 dark:group-hover:bg-gray-900"
>
<img
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
class="size-5.5 object-cover rounded-full"
alt={$i18n.t('Open User Profile Menu')}
aria-label={$i18n.t('Open User Profile Menu')}
/>
{#if $config?.features?.enable_user_status}
<div class="absolute -bottom-0.5 -right-0.5">
<span class="relative flex size-2.5">
<span
class="relative inline-flex size-2.5 rounded-full {true
? 'bg-green-500'
: 'bg-gray-300 dark:bg-gray-700'} border-2 border-white dark:border-gray-900"
></span>
</span>
</div>
{/if}
</div>
</button>
</UserMenu>
{/if}
</div>
</div>
</div>
</div>
{/if}
<!-- {$i18n.t('New Folder')} -->
<!-- {$i18n.t('Pinned')} -->
{#if visible || !$mobile}
<div
bind:this={navElement}
id={visible ? 'sidebar' : undefined}
role="navigation"
aria-label={$i18n.t('Chat history')}
aria-hidden={!$showSidebar}
inert={!$showSidebar}
class="h-screen max-h-[100dvh] min-h-screen select-none {$mobile
? visible
? 'bg-gray-50 dark:bg-gray-950 z-50'
: 'bg-transparent z-0 pointer-events-none'
: `bg-gray-50 dark:bg-gray-950 z-50 ${$showSidebar ? '' : 'pointer-events-none'}`} {$isApp
? `ml-[4.5rem] md:ml-0 `
: $mobile
? ''
: ''} shrink-0 text-gray-700 dark:text-gray-300 text-[0.8125rem] leading-5 fixed top-0 left-0 overflow-x-hidden
"
style={$mobile
? panelStyle
: `width: ${$showSidebar ? 'var(--sidebar-width)' : '0'}; transition: width 250ms cubic-bezier(0.22, 1, 0.36, 1);`}
data-state={$showSidebar}
>
<div
class=" my-auto flex flex-col justify-between h-screen max-h-[100dvh] w-[var(--sidebar-width)] overflow-x-hidden scrollbar-hidden z-50 border-e border-gray-50 dark:border-gray-850/30"
>
<div
class="sidebar px-1 pt-1.5 pb-1 flex justify-between space-x-1 text-gray-600 dark:text-gray-400 sticky top-0 z-10 -mb-2"
>
<a
class="flex items-center rounded-xl size-8.5 h-full justify-center hover:bg-gray-50 dark:hover:bg-gray-900 transition no-drag-region"
href="/"
draggable="false"
on:click={newChatHandler}
>
<!-- LICENSE covers this Open WebUI sidebar logo.
Do not alter, remove, obscure, or replace it except as LICENSE permits:
https://docs.openwebui.com/license. -->
<img
crossorigin="anonymous"
src="{WEBUI_BASE_URL}/static/favicon.png"
class="sidebar-new-chat-icon size-5 rounded-full"
alt=""
/>
</a>
<a href="/" class="flex flex-1 px-0.5" on:click={newChatHandler}>
<!-- LICENSE covers this Open WebUI sidebar name.
Do not alter, remove, obscure, or replace it except as LICENSE permits:
https://docs.openwebui.com/license. -->
<div
id="sidebar-webui-name"
class=" self-center font-normal text-gray-700 dark:text-gray-200"
>
{$WEBUI_NAME}
</div>
</a>
<Tooltip
content={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
placement="bottom"
>
<button
class="flex size-[1.875rem] justify-center items-center rounded-lg hover:bg-gray-50 dark:hover:bg-gray-900 transition {isWindows
? 'cursor-pointer'
: 'cursor-[w-resize]'}"
on:click={() => {
showSidebar.set(!$showSidebar);
}}
aria-label={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
>
<div class=" self-center">
<Sidebar className="size-4" />
</div>
</button>
</Tooltip>
<div
class="{scrollTop > 0
? 'visible'
: '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"
></div>
</div>
<div
class="relative flex flex-col flex-1 overflow-y-auto scrollbar-hidden pt-2.5 pb-2.5"
on:scroll={(e) => {
if (e.target.scrollTop === 0) {
scrollTop = 0;
} else {
scrollTop = e.target.scrollTop;
}
}}
>
<div class="pb-1">
<div class="px-1 flex justify-center text-gray-700 dark:text-gray-300">
<a
id="sidebar-new-chat-button"
class="group grow flex items-center space-x-2 rounded-xl px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-900 transition outline-none"
href="/"
draggable="false"
on:click={newChatHandler}
aria-label={$i18n.t('New Chat')}
>
<div class="self-center flex size-4 shrink-0 items-center justify-center">
<EditPencilIcon className=" size-4" strokeWidth="1.5" />
</div>
<div class="flex flex-1 self-center translate-y-[0.5px]">
<div class=" self-center text-[0.8125rem] leading-5">{$i18n.t('New Chat')}</div>
</div>
<HotkeyHint name="newChat" className=" hover-reveal " />
</a>
</div>
<div class="px-1 flex justify-center text-gray-700 dark:text-gray-300">
<button
id="sidebar-search-button"
class="group grow flex items-center space-x-2 rounded-xl px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-900 transition outline-none"
on:click={() => {
showSearch.set(true);
}}
draggable="false"
aria-label={$i18n.t('Search')}
>
<div class="self-center flex size-4 shrink-0 items-center justify-center">
<SearchIcon strokeWidth="1.5" className="size-4" />
</div>
<div class="flex flex-1 self-center translate-y-[0.5px]">
<div class=" self-center text-[0.8125rem] leading-5">{$i18n.t('Search')}</div>
</div>
<HotkeyHint name="search" className=" hover-reveal " />
</button>
</div>
<div id="pinned-menu-items-list">
{#each pinnedItems as itemId (itemId)}
{@const meta = getMenuItemMeta(itemId)}
{#if meta && isMenuItemVisible(itemId)}
<div
class="px-1 flex justify-center text-gray-700 dark:text-gray-300"
data-id={itemId}
>
<a
id="sidebar-{itemId}-button"
class="grow flex items-center space-x-2 rounded-xl px-2 py-1.5 transition {itemId ===
activeMenuItemId
? ($settings?.highContrastMode ?? false)
? 'bg-black/[0.035] dark:bg-white/[0.06]'
: 'bg-black/[0.035] dark:bg-white/[0.045]'
: 'hover:bg-gray-50 dark:hover:bg-gray-900'}"
href={meta.href}
on:click={itemClickHandler}
draggable="false"
aria-label={$i18n.t(meta.label)}
>
<div class="self-center flex size-4 shrink-0 items-center justify-center">
{#if itemId === 'notes'}
<NotesIcon className="size-4" strokeWidth="1.5" />
{:else if itemId === 'workspace'}
<WorkspaceIcon className="size-4" strokeWidth="1.5" />
{:else if itemId === 'automations'}
<ClockIcon className="size-4" strokeWidth="1.5" />
{:else if itemId === 'calendar'}
<CalendarIcon className="size-4" strokeWidth="1.5" />
{:else if itemId === 'playground'}
<CodeIcon className="size-4" strokeWidth="1.5" />
{/if}
</div>
<div class="flex self-center translate-y-[0.5px]">
<div class=" self-center text-[0.8125rem] leading-5">
{$i18n.t(meta.label)}
</div>
</div>
</a>
</div>
{/if}
{/each}
</div>
</div>
{#if ($models ?? []).length > 0 && (($settings?.pinnedModels ?? []).length > 0 || $config?.default_pinned_models)}
<SidebarSection
id="sidebar-models"
bind:open={showPinnedModels}
className="mt-0.5"
name={$i18n.t('Models')}
dragAndDrop={false}
>
<PinnedModelList bind:selectedChatId {shiftKey} />
</SidebarSection>
{/if}
{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true)) && $pinnedNotes.length > 0}
<SidebarSection
id="sidebar-pinned-notes"
bind:open={showPinnedNotes}
className="mt-0.5"
name={$i18n.t('Notes')}
dragAndDrop={false}
onAdd={async () => {
const note = await createNoteHandler('New Note');
if (note) {
goto(`/notes/${note.id}`);
}
}}
onAddLabel={$i18n.t('New Note')}
>
<PinnedNoteList bind:selectedChatId />
</SidebarSection>
{/if}
{#if $config?.features?.enable_channels && ($user?.role === 'admin' || ($user?.permissions?.features?.channels ?? true))}
<SidebarSection
id="sidebar-channels"
bind:open={showChannels}
className="mt-0.5"
name={$i18n.t('Channels')}
dragAndDrop={false}
onAdd={$user?.role === 'admin' || ($user?.permissions?.features?.channels ?? true)
? async () => {
await tick();
setTimeout(() => {
showCreateChannel = true;
}, 0);
}
: null}
onAddLabel={$i18n.t('Create Channel')}
>
{#each $channels as channel, channelIdx (`${channel?.id}`)}
<ChannelItem
{channel}
onUpdate={async () => {
await initChannels();
}}
/>
{#if channelIdx < $channels.length - 1 && channel.type !== $channels[channelIdx + 1]?.type}<hr
class=" border-gray-100/40 dark:border-gray-800/10 my-1.5 w-full"
/>
{/if}
{/each}
</SidebarSection>
{/if}
{#if $config?.features?.enable_folders && ($user?.role === 'admin' || ($user?.permissions?.features?.folders ?? true))}
<SidebarSection
id="sidebar-folders"
bind:open={showFolders}
className="mt-0.5"
name={$i18n.t('Folders')}
onAdd={() => {
showCreateFolderModal = true;
}}
onAddLabel={$i18n.t('New Folder')}
on:drop={async (e) => {
const { type, id, item } = e.detail;
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();
}
}
}}
>
<Folders
bind:folderRegistry
{folders}
{shiftKey}
onFolderUnreadCounts={applyFolderUnreadCounts}
onDelete={(folderId) => {
selectedFolder.set(null);
initChatList();
}}
on:update={() => {
initChatList();
}}
on:import={(e) => {
const { folderId, items } = e.detail;
importChatHandler(items, false, folderId);
}}
on:change={async () => {
initChatList();
}}
/>
</SidebarSection>
{/if}
<SidebarSection
id="sidebar-chats"
className="mt-0.5"
name={$i18n.t('Chats')}
on:change={async (e) => {
selectedFolder.set(null);
}}
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) {
if (!canImportChats) {
toast.error($i18n.t('Access prohibited'));
return;
}
chat = await importChats(localStorage.token, [
{
chat: item.chat,
meta: item?.meta ?? {},
pinned: false,
folder_id: null,
created_at: item?.created_at ?? null,
updated_at: item?.updated_at ?? null
}
]);
}
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;
});
folderRegistry[chat.folder_id]?.setFolderItems();
}
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();
}
}
}}
>
<svelte:fragment slot="action">
<Dropdown bind:show={showChatsMenu} align="end">
<Tooltip content={$i18n.t('More')}>
<button
type="button"
class="flex items-center justify-center w-7 h-7 rounded-lg text-gray-300 hover:text-gray-500 dark:text-gray-600 dark:hover:text-gray-400 transition-colors duration-100"
aria-label={$i18n.t('More')}
>
<MoreHorizontalIcon className="size-3.5" strokeWidth="2" />
</button>
</Tooltip>
<div slot="content">
<DropdownMenu className="min-w-[10.625rem]">
<button
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={markAllChatsReadHandler}
>
<CheckIcon className="size-3.5" />
<div class="flex items-center">{$i18n.t('Mark all as read')}</div>
</button>
</DropdownMenu>
</div>
</Dropdown>
</svelte:fragment>
{#if $pinnedChats.length > 0}
<div class="mb-1">
<div class="flex flex-col space-y-1 rounded-xl">
<Folder
id="sidebar-pinned-chats"
buttonClassName=" text-gray-500"
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) {
if (!canImportChats) {
toast.error($i18n.t('Access prohibited'));
return;
}
chat = await importChats(localStorage.token, [
{
chat: item.chat,
meta: item?.meta ?? {},
pinned: false,
folder_id: null,
created_at: item?.created_at ?? null,
updated_at: item?.updated_at ?? null
}
]);
}
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-[0.0625rem] flex flex-col overflow-y-auto scrollbar-hidden border-s border-gray-100 dark:border-gray-900 text-gray-700 dark:text-gray-300"
>
{#each $pinnedChats as chat, idx (`pinned-chat-${chat?.id ?? idx}`)}
<ChatItem
className=""
id={chat.id}
title={chat.title}
createdAt={chat.created_at}
updatedAt={chat.updated_at}
lastReadAt={chat.last_read_at}
active={chat.active ?? false}
{shiftKey}
selected={selectedChatId === chat.id}
on:select={() => {
selectedChatId = chat.id;
}}
on:unselect={() => {
selectedChatId = null;
}}
on:change={async () => {
initChatList();
}}
onReadStateChange={applyChatReadState}
on:tag={(e) => {
const { type, name } = e.detail;
tagEventHandler(type, name, chat.id);
}}
/>
{/each}
</div>
</Folder>
</div>
</div>
{/if}
<div class=" flex-1 flex flex-col overflow-y-auto scrollbar-hidden">
<div class="pt-1.5">
{#if $chats}
{#each $chats as chat, idx (`chat-${chat?.id ?? 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-normal {idx ===
0
? ''
: 'pt-4'} pb-1"
>
{$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}
createdAt={chat.created_at}
updatedAt={chat.updated_at}
lastReadAt={chat.last_read_at}
active={chat.active ?? false}
{shiftKey}
selected={selectedChatId === chat.id}
on:select={() => {
selectedChatId = chat.id;
}}
on:unselect={() => {
selectedChatId = null;
}}
on:change={async () => {
initChatList();
}}
onReadStateChange={applyChatReadState}
on:tag={(e) => {
const { type, name } = e.detail;
tagEventHandler(type, name, chat.id);
}}
/>
{/each}
{#if chatListReady && !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=" ">{$i18n.t('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=" ">{$i18n.t('Loading...')}</div>
</div>
{/if}
</div>
</div>
</SidebarSection>
</div>
<div class="px-1 pt-1 pb-1.5 sticky bottom-0 z-10 -mt-2 sidebar">
<div
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"
></div>
<div class="flex flex-col">
{#if $user !== undefined && $user !== null}
<UserMenu
role={$user?.role}
profile={$config?.features?.enable_user_status ?? true}
className="w-[calc(var(--sidebar-width)-1rem)]"
>
<button
type="button"
class=" flex items-center rounded-xl py-1.5 px-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-900 transition"
aria-label={$i18n.t('User menu')}
>
<div class=" self-center mr-3 relative flex-shrink-0">
<img
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
class="size-5.5 object-cover rounded-full"
alt={$i18n.t('Open User Profile Menu')}
aria-label={$i18n.t('Open User Profile Menu')}
/>
{#if $config?.features?.enable_user_status}
<div class="absolute -bottom-0.5 -right-0.5">
<span class="relative flex size-2.5">
<span
class="relative inline-flex size-2.5 rounded-full {true
? 'bg-green-500'
: 'bg-gray-300 dark:bg-gray-700'} border-2 border-white dark:border-gray-900"
></span>
</span>
</div>
{/if}
</div>
<div class=" self-center font-normal truncate">{$user?.name}</div>
</button>
</UserMenu>
{/if}
</div>
</div>
</div>
</div>
{#if !$mobile && visible}
<div
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
id="sidebar-resizer"
on:mousedown={resizeStartHandler}
role="separator"
>
<div
class=" absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
/>
</div>
{/if}
{/if}
</MobileSwipePanel>
<style>
@media (max-width: 767px) {
#sidebar {
will-change: transform;
touch-action: pan-y;
}
}
</style>