When navigating from a chat to a non-chat route (e.g. the admin panel), the previously-viewed chat stayed selected in the sidebar and deleting/archiving it wrongly redirected back to the new-chat page. Cloning a chat also left the source chat highlighted alongside the new clone, so two chats appeared selected at once. Two independent sources kept the stale selection: - The chatId store was never cleared when the Chat component unmounted, so $chatId still pointed at the last-viewed chat (this drove the delete/archive redirect). Clear chatId/chatTitle in Chat's onDestroy. - The sidebar's optimistic selectedChatId highlight, set on click, was only cleared on window blur (hence it appeared to fix itself after a tab switch) and never followed programmatic navigation. Bind it to the chatId store so it tracks the active chat for leave, delete and clone.
1583 lines
45 KiB
Svelte
1583 lines
45 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, setChatActive } 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
|
|
} 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 { slide } from 'svelte/transition';
|
|
import HotkeyHint from '../common/HotkeyHint.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 folders = {};
|
|
let folderRegistry: Record<string, { setFolderItems?: () => 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 refreshChatRows = async () => {
|
|
const result = await refreshChatList(localStorage.token, { refreshPinned: true });
|
|
if (result.accepted) {
|
|
await Promise.all(Object.values(folderRegistry).map((folder) => folder?.setFolderItems?.()));
|
|
allChatsLoaded = result.allLoaded;
|
|
chatListReady = true;
|
|
}
|
|
};
|
|
|
|
const loadMoreChats = async () => {
|
|
chatListLoading = true;
|
|
|
|
const result = await loadNextChatListPage(localStorage.token);
|
|
allChatsLoaded = result.allLoaded;
|
|
|
|
chatListLoading = false;
|
|
};
|
|
|
|
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
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
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';
|
|
}
|
|
}
|
|
|
|
if (value) {
|
|
// 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();
|
|
}
|
|
}),
|
|
settings.subscribe((value) => {
|
|
if (pinnedModels != value?.pinnedModels ?? []) {
|
|
pinnedModels = value?.pinnedModels ?? [];
|
|
showPinnedModels = pinnedModels.length > 0;
|
|
}
|
|
})
|
|
];
|
|
|
|
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');
|
|
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);
|
|
|
|
await tick();
|
|
initPinnedMenuSortable();
|
|
|
|
return () => {
|
|
unsubscribers.forEach((unsubscriber) => unsubscriber());
|
|
|
|
window.removeEventListener('keydown', onKeyDown);
|
|
window.removeEventListener('keyup', onKeyUp);
|
|
|
|
window.removeEventListener('touchstart', onTouchStart);
|
|
window.removeEventListener('touchend', onTouchEnd);
|
|
|
|
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);
|
|
};
|
|
});
|
|
|
|
// 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 } };
|
|
}) => {
|
|
if (event.data?.type === 'chat:active') {
|
|
const active = event.data.data.active ?? false;
|
|
const found = setChatActive(event.chat_id, active);
|
|
if (!found && active) {
|
|
await refreshChatRows();
|
|
}
|
|
} else if (event.data?.type === 'chat:list') {
|
|
refreshChatRows();
|
|
}
|
|
};
|
|
|
|
const newChatHandler = async () => {
|
|
selectedChatId = null;
|
|
selectedFolder.set(null);
|
|
|
|
if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) {
|
|
await temporaryChatEnabled.set(true);
|
|
} else {
|
|
await temporaryChatEnabled.set(false);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
if ($mobile) {
|
|
showSidebar.set(false);
|
|
}
|
|
}, 0);
|
|
};
|
|
|
|
const itemClickHandler = async () => {
|
|
selectedChatId = null;
|
|
chatId.set('');
|
|
|
|
if ($mobile) {
|
|
showSidebar.set(false);
|
|
}
|
|
|
|
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-ignore a11y-no-static-element-interactions -->
|
|
|
|
{#if $showSidebar}
|
|
<div
|
|
class=" {$isApp
|
|
? ' ml-[4.5rem] md:ml-0'
|
|
: ''} 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}
|
|
|
|
<SearchModal
|
|
bind:show={$showSearch}
|
|
onClose={() => {
|
|
if ($mobile) {
|
|
showSidebar.set(false);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<button
|
|
id="sidebar-new-chat-button"
|
|
class="hidden"
|
|
on:click={() => {
|
|
goto('/');
|
|
newChatHandler();
|
|
}}
|
|
/>
|
|
|
|
<svelte:window
|
|
on:mousemove={(e) => {
|
|
if (!isResizing) return;
|
|
resizeSidebarHandler(e.clientX);
|
|
}}
|
|
on:mouseup={() => {
|
|
resizeEndHandler();
|
|
}}
|
|
/>
|
|
|
|
{#if !$mobile && !$showSidebar}
|
|
<div
|
|
class=" w-[42px] shrink-0 py-1 px-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"
|
|
>
|
|
<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-[30px] items-center justify-center rounded-lg transition group-hover:bg-gray-50 dark:group-hover:bg-gray-900"
|
|
>
|
|
<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-[30px] 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-[30px] 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-[30px] 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-[30px] 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 $showSidebar}
|
|
<div
|
|
bind:this={navElement}
|
|
id="sidebar"
|
|
class="h-screen max-h-[100dvh] min-h-screen select-none {$showSidebar
|
|
? `${$mobile ? 'bg-gray-50 dark:bg-gray-950' : 'bg-gray-50/70 dark:bg-gray-950/70'} z-50`
|
|
: ' bg-transparent z-0 '} {$isApp
|
|
? `ml-[4.5rem] md:ml-0 `
|
|
: ' transition-all duration-300 '} shrink-0 text-gray-700 dark:text-gray-300 text-[13px] leading-5 fixed top-0 left-0 overflow-x-hidden
|
|
"
|
|
transition:slide={{ duration: 250, axis: 'x' }}
|
|
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 {$showSidebar
|
|
? ''
|
|
: 'invisible'}"
|
|
>
|
|
<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}
|
|
>
|
|
<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}>
|
|
<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-[30px] 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-[13px] leading-5">{$i18n.t('New Chat')}</div>
|
|
</div>
|
|
|
|
<HotkeyHint name="newChat" className=" group-hover:visible invisible" />
|
|
</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-[13px] leading-5">{$i18n.t('Search')}</div>
|
|
</div>
|
|
<HotkeyHint name="search" className=" group-hover:visible invisible" />
|
|
</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-[13px] 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}
|
|
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();
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
{#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-[1px] 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();
|
|
}}
|
|
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();
|
|
}}
|
|
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}
|
|
<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}
|