mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-11 14:10:15 -07:00
369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
import * as React from 'react';
|
|
import { shallow } from 'zustand/shallow';
|
|
|
|
import { Box, IconButton, ListDivider, ListItem, ListItemButton, ListItemDecorator, Tooltip } from '@mui/joy';
|
|
import AddIcon from '@mui/icons-material/Add';
|
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
|
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
|
|
import FileUploadOutlinedIcon from '@mui/icons-material/FileUploadOutlined';
|
|
import FolderIcon from '@mui/icons-material/Folder';
|
|
import FolderOpenOutlinedIcon from '@mui/icons-material/FolderOpenOutlined';
|
|
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
|
|
|
|
import DebounceInput from '~/common/components/DebounceInput';
|
|
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
|
import { DFolder, useFolderStore } from '~/common/state/store-folders';
|
|
import { PageDrawerHeader } from '~/common/layout/optima/components/PageDrawerHeader';
|
|
import { PageDrawerList, PageDrawerTallItemSx } from '~/common/layout/optima/components/PageDrawerList';
|
|
import { conversationTitle, DConversationId, useChatStore } from '~/common/state/store-chats';
|
|
import { themeZIndexOverMobileDrawer } from '~/common/app.theme';
|
|
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
|
|
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
|
|
|
import { ChatDrawerItemMemo, ChatNavigationItemData, FolderChangeRequest } from './ChatDrawerItem';
|
|
import { ChatFolderList } from './folder/ChatFolderList';
|
|
import { ClearFolderText } from './folder/useFolderDropdown';
|
|
|
|
|
|
// this is here to make shallow comparisons work on the next hook
|
|
const noFolders: DFolder[] = [];
|
|
|
|
/*
|
|
* Lists folders and returns the active folder
|
|
*/
|
|
export const useFolders = (activeFolderId: string | null) => useFolderStore(({ enableFolders, folders, toggleEnableFolders }) => {
|
|
|
|
// finds the active folder if any
|
|
const activeFolder = (enableFolders && activeFolderId)
|
|
? folders.find(folder => folder.id === activeFolderId) ?? null
|
|
: null;
|
|
|
|
return {
|
|
activeFolder,
|
|
allFolders: enableFolders ? folders : noFolders,
|
|
enableFolders,
|
|
toggleEnableFolders,
|
|
};
|
|
}, shallow);
|
|
|
|
|
|
/*
|
|
* Optimization: return a reduced version of the DConversation object for 'Drawer Items' purposes,
|
|
* to avoid unnecessary re-renders on each new character typed by the assistant
|
|
*/
|
|
export const useChatNavigationItemsData = (activeFolder: DFolder | null, allFolders: DFolder[], activeConversationId: DConversationId | null): ChatNavigationItemData[] =>
|
|
useChatStore(({ conversations }) => {
|
|
|
|
const activeConversations = activeFolder
|
|
? conversations.filter(_c => activeFolder.conversationIds.includes(_c.id))
|
|
: conversations;
|
|
|
|
return activeConversations.map((_c): ChatNavigationItemData => ({
|
|
conversationId: _c.id,
|
|
isActive: _c.id === activeConversationId,
|
|
isEmpty: !_c.messages.length && !_c.userTitle,
|
|
title: conversationTitle(_c),
|
|
folder: !allFolders.length
|
|
? undefined // don't show folder select if folders are disabled
|
|
: _c.id === activeConversationId // only show the folder for active conversation(s)
|
|
? allFolders.find(folder => folder.conversationIds.includes(_c.id)) ?? null
|
|
: null,
|
|
messageCount: _c.messages.length,
|
|
assistantTyping: !!_c.abortController,
|
|
systemPurposeId: _c.systemPurposeId,
|
|
}));
|
|
|
|
}, (a, b) => {
|
|
// custom equality function to avoid unnecessary re-renders
|
|
return a.length === b.length && a.every((_a, i) => shallow(_a, b[i]));
|
|
});
|
|
|
|
|
|
export const ChatDrawerMemo = React.memo(ChatDrawer);
|
|
|
|
function ChatDrawer(props: {
|
|
activeConversationId: DConversationId | null,
|
|
activeFolderId: string | null,
|
|
disableNewButton: boolean,
|
|
onConversationActivate: (conversationId: DConversationId) => void,
|
|
onConversationDelete: (conversationId: DConversationId, bypassConfirmation: boolean) => void,
|
|
onConversationExportDialog: (conversationId: DConversationId | null, exportAll: boolean) => void,
|
|
onConversationImportDialog: () => void,
|
|
onConversationNew: () => void,
|
|
onConversationsDeleteAll: () => void,
|
|
setActiveFolderId: (folderId: string | null) => void,
|
|
}) {
|
|
|
|
const { onConversationActivate, onConversationDelete, onConversationExportDialog, onConversationNew } = props;
|
|
|
|
// local state
|
|
const [debouncedSearchQuery, setDebouncedSearchQuery] = React.useState('');
|
|
const [folderChangeRequest, setFolderChangeRequest] = React.useState<FolderChangeRequest | null>(null);
|
|
|
|
// external state
|
|
const { closeDrawer, closeDrawerOnMobile } = useOptimaDrawers();
|
|
const { activeFolder, allFolders, enableFolders, toggleEnableFolders } = useFolders(props.activeFolderId);
|
|
const chatNavItems = useChatNavigationItemsData(activeFolder, allFolders, props.activeConversationId);
|
|
const showSymbols = useUIPreferencesStore(state => state.zenMode !== 'cleaner');
|
|
|
|
// derived state
|
|
const selectConversationsCount = chatNavItems.length;
|
|
const nonEmptyChats = selectConversationsCount > 1 || (selectConversationsCount === 1 && !chatNavItems[0].isEmpty);
|
|
const singleChat = selectConversationsCount === 1;
|
|
const softMaxReached = selectConversationsCount >= 10;
|
|
|
|
|
|
const handleButtonNew = React.useCallback(() => {
|
|
onConversationNew();
|
|
closeDrawerOnMobile();
|
|
}, [closeDrawerOnMobile, onConversationNew]);
|
|
|
|
|
|
const handleConversationActivate = React.useCallback((conversationId: DConversationId, closeMenu: boolean) => {
|
|
onConversationActivate(conversationId);
|
|
if (closeMenu)
|
|
closeDrawerOnMobile();
|
|
}, [closeDrawerOnMobile, onConversationActivate]);
|
|
|
|
|
|
const handleConversationDelete = React.useCallback((conversationId: DConversationId) => {
|
|
!singleChat && conversationId && onConversationDelete(conversationId, true);
|
|
}, [onConversationDelete, singleChat]);
|
|
|
|
|
|
// Folder change request
|
|
|
|
const handleConversationFolderChange = React.useCallback((folderChangeRequest: FolderChangeRequest) => setFolderChangeRequest(folderChangeRequest), []);
|
|
|
|
const handleConversationFolderCancel = React.useCallback(() => setFolderChangeRequest(null), []);
|
|
|
|
const handleConversationFolderSet = React.useCallback((conversationId: DConversationId, nextFolderId: string | null) => {
|
|
// Remove conversation from existing folders
|
|
const { addConversationToFolder, folders, removeConversationFromFolder } = useFolderStore.getState();
|
|
folders.forEach(folder => folder.conversationIds.includes(conversationId) && removeConversationFromFolder(folder.id, conversationId));
|
|
|
|
// Add conversation to the selected folder
|
|
nextFolderId && addConversationToFolder(nextFolderId, conversationId);
|
|
|
|
// Close the menu
|
|
setFolderChangeRequest(null);
|
|
}, []);
|
|
|
|
|
|
// Filter chatNavItems based on the search query and rank them by search frequency
|
|
const filteredChatNavItems = React.useMemo(() => {
|
|
if (!debouncedSearchQuery) return chatNavItems;
|
|
return chatNavItems
|
|
.map(item => {
|
|
// Get the conversation by ID
|
|
const conversation = useChatStore.getState().conversations.find(c => c.id === item.conversationId);
|
|
// Calculate the frequency of the search term in the title and messages
|
|
const titleFrequency = (item.title.toLowerCase().match(new RegExp(debouncedSearchQuery.toLowerCase(), 'g')) || []).length;
|
|
const messageFrequency = conversation?.messages.reduce((count, message) => {
|
|
return count + (message.text.toLowerCase().match(new RegExp(debouncedSearchQuery.toLowerCase(), 'g')) || []).length;
|
|
}, 0) || 0;
|
|
// Return the item with the searchFrequency property
|
|
return {
|
|
...item,
|
|
searchFrequency: titleFrequency + messageFrequency,
|
|
};
|
|
})
|
|
// Exclude items with a searchFrequency of 0
|
|
.filter(item => item.searchFrequency > 0)
|
|
// Sort the items by searchFrequency in descending order
|
|
.sort((a, b) => b.searchFrequency! - a.searchFrequency!);
|
|
}, [chatNavItems, debouncedSearchQuery]);
|
|
|
|
|
|
// basis for the underline bar
|
|
const bottomBarBasis = filteredChatNavItems.reduce((longest, _c) => Math.max(longest, _c.searchFrequency ?? _c.messageCount), 1);
|
|
|
|
|
|
// grouping
|
|
/*let sortedIds = conversationIDs;
|
|
if (grouping === 'persona') {
|
|
const conversations = useChatStore.getState().conversations;
|
|
|
|
// group conversations by persona
|
|
const groupedConversations: { [personaId: string]: string[] } = {};
|
|
conversations.forEach(conversation => {
|
|
const persona = conversation.systemPurposeId;
|
|
if (persona) {
|
|
if (!groupedConversations[persona])
|
|
groupedConversations[persona] = [];
|
|
groupedConversations[persona].push(conversation.id);
|
|
}
|
|
});
|
|
|
|
// flatten grouped conversations
|
|
sortedIds = Object.values(groupedConversations).flat();
|
|
}*/
|
|
|
|
return <>
|
|
|
|
{/* Drawer Header */}
|
|
<PageDrawerHeader title='Chats' onClose={closeDrawer}>
|
|
<Tooltip title={enableFolders ? 'Hide Folders' : 'Use Folders'}>
|
|
<IconButton onClick={toggleEnableFolders}>
|
|
{enableFolders ? <FolderOpenOutlinedIcon /> : <FolderOutlinedIcon />}
|
|
</IconButton>
|
|
</Tooltip>
|
|
</PageDrawerHeader>
|
|
|
|
{/* Folders List */}
|
|
{/*<Box sx={{*/}
|
|
{/* display: 'grid',*/}
|
|
{/* gridTemplateRows: !enableFolders ? '0fr' : '1fr',*/}
|
|
{/* transition: 'grid-template-rows 0.42s cubic-bezier(.17,.84,.44,1)',*/}
|
|
{/* '& > div': {*/}
|
|
{/* padding: enableFolders ? 2 : 0,*/}
|
|
{/* transition: 'padding 0.42s cubic-bezier(.17,.84,.44,1)',*/}
|
|
{/* overflow: 'hidden',*/}
|
|
{/* },*/}
|
|
{/*}}>*/}
|
|
{enableFolders && (
|
|
<ChatFolderList
|
|
folders={allFolders}
|
|
activeFolderId={props.activeFolderId}
|
|
onFolderSelect={props.setActiveFolderId}
|
|
/>
|
|
)}
|
|
{/*</Box>*/}
|
|
|
|
{/* Chats List */}
|
|
<PageDrawerList variant='plain' noTopPadding noBottomPadding tallRows>
|
|
|
|
{enableFolders && <ListDivider sx={{ mb: 0 }} />}
|
|
|
|
{/* Search Input Field */}
|
|
<DebounceInput
|
|
minChars={2}
|
|
onDebounce={setDebouncedSearchQuery}
|
|
debounceTimeout={300}
|
|
placeholder='Search...'
|
|
aria-label='Search'
|
|
sx={{ m: 2 }}
|
|
/>
|
|
|
|
<ListItem sx={{ '--ListItem-minHeight': '2.75rem' }}>
|
|
<ListItemButton disabled={props.disableNewButton} onClick={handleButtonNew} sx={PageDrawerTallItemSx}>
|
|
<ListItemDecorator><AddIcon /></ListItemDecorator>
|
|
<Box sx={{
|
|
// style
|
|
fontSize: 'sm',
|
|
fontWeight: 'lg',
|
|
// content
|
|
flexGrow: 1,
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
gap: 1,
|
|
}}>
|
|
New chat
|
|
{/*<KeyStroke combo='Ctrl + Alt + N' sx={props.disableNewButton ? { opacity: 0.5 } : undefined} />*/}
|
|
</Box>
|
|
</ListItemButton>
|
|
</ListItem>
|
|
|
|
{/*<ListDivider sx={{ mt: 0 }} />*/}
|
|
|
|
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
|
{/*<ListItem sticky sx={{ justifyContent: 'space-between', boxShadow: 'sm' }}>*/}
|
|
{/* <Typography level='body-sm'>*/}
|
|
{/* Conversations*/}
|
|
{/* </Typography>*/}
|
|
{/* <ToggleButtonGroup variant='soft' size='sm' value={grouping} onChange={(_event, newValue) => newValue && setGrouping(newValue)}>*/}
|
|
{/* <IconButton value='off'>*/}
|
|
{/* <AccessTimeIcon />*/}
|
|
{/* </IconButton>*/}
|
|
{/* <IconButton value='persona'>*/}
|
|
{/* <PersonIcon />*/}
|
|
{/* </IconButton>*/}
|
|
{/* </ToggleButtonGroup>*/}
|
|
{/*</ListItem>*/}
|
|
|
|
{filteredChatNavItems.map(item =>
|
|
<ChatDrawerItemMemo
|
|
key={'nav-' + item.conversationId}
|
|
item={item}
|
|
isLonely={singleChat}
|
|
showSymbols={showSymbols}
|
|
bottomBarBasis={(softMaxReached || debouncedSearchQuery) ? bottomBarBasis : 0}
|
|
onConversationActivate={handleConversationActivate}
|
|
onConversationDelete={handleConversationDelete}
|
|
onConversationExport={onConversationExportDialog}
|
|
onConversationFolderChange={handleConversationFolderChange}
|
|
/>)}
|
|
</Box>
|
|
|
|
<ListDivider sx={{ mt: 0 }} />
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
|
<ListItemButton onClick={props.onConversationImportDialog} sx={{ flex: 1 }}>
|
|
<ListItemDecorator>
|
|
<FileUploadOutlinedIcon />
|
|
</ListItemDecorator>
|
|
Import
|
|
{/*<OpenAIIcon sx={{ ml: 'auto' }} />*/}
|
|
</ListItemButton>
|
|
|
|
<ListItemButton disabled={!nonEmptyChats} onClick={() => props.onConversationExportDialog(props.activeConversationId, true)} sx={{ flex: 1 }}>
|
|
<ListItemDecorator>
|
|
<FileDownloadOutlinedIcon />
|
|
</ListItemDecorator>
|
|
Export
|
|
</ListItemButton>
|
|
</Box>
|
|
|
|
<ListItemButton disabled={!nonEmptyChats} onClick={props.onConversationsDeleteAll}>
|
|
<ListItemDecorator>
|
|
<DeleteOutlineIcon />
|
|
</ListItemDecorator>
|
|
Delete {selectConversationsCount >= 2 ? `all ${selectConversationsCount} chats` : 'chat'}
|
|
</ListItemButton>
|
|
|
|
</PageDrawerList>
|
|
|
|
|
|
{/* [Menu] Chat Item Folder Change */}
|
|
{!!folderChangeRequest?.anchorEl && (
|
|
<CloseableMenu
|
|
dense bigIcons
|
|
open anchorEl={folderChangeRequest.anchorEl} onClose={handleConversationFolderCancel}
|
|
placement='bottom-start'
|
|
zIndex={themeZIndexOverMobileDrawer /* need to be on top of the Modal on Mobile */}
|
|
sx={{ minWidth: 200 }}
|
|
>
|
|
|
|
{/* Folder Assignment Buttons */}
|
|
{allFolders.map(folder => {
|
|
const isRequestFolder = folder === folderChangeRequest.currentFolder;
|
|
return (
|
|
<ListItem
|
|
key={folder.id}
|
|
variant={isRequestFolder ? 'soft' : 'plain'}
|
|
onClick={() => handleConversationFolderSet(folderChangeRequest.conversationId, folder.id)}
|
|
>
|
|
<ListItemButton>
|
|
<ListItemDecorator>
|
|
<FolderIcon sx={{ color: folder.color }} />
|
|
</ListItemDecorator>
|
|
{folder.title}
|
|
</ListItemButton>
|
|
</ListItem>
|
|
);
|
|
})}
|
|
|
|
{/* Remove Folder Assignment */}
|
|
{!!folderChangeRequest.currentFolder && (
|
|
<ListItem onClick={() => handleConversationFolderSet(folderChangeRequest.conversationId, null)}>
|
|
<ListItemButton>
|
|
{ClearFolderText}
|
|
</ListItemButton>
|
|
</ListItem>
|
|
)}
|
|
|
|
</CloseableMenu>
|
|
)}
|
|
|
|
</>;
|
|
} |