377 lines
10 KiB
TypeScript
377 lines
10 KiB
TypeScript
import React, { useRef, useEffect, useMemo, useCallback } from 'react';
|
|
import { AddIcon, ChatIcon, DeleteIcon, MoonIcon, SunIcon } from '@chakra-ui/icons';
|
|
import {
|
|
Box,
|
|
Button,
|
|
Accordion,
|
|
AccordionItem,
|
|
AccordionButton,
|
|
AccordionPanel,
|
|
AccordionIcon,
|
|
Flex,
|
|
Divider,
|
|
IconButton,
|
|
useDisclosure,
|
|
useColorMode,
|
|
useColorModeValue,
|
|
Menu,
|
|
MenuButton,
|
|
MenuList,
|
|
MenuItem
|
|
} from '@chakra-ui/react';
|
|
import { useUserStore } from '@/store/user';
|
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
import { useRouter } from 'next/router';
|
|
import { getToken } from '@/utils/user';
|
|
import MyIcon from '@/components/Icon';
|
|
import WxConcat from '@/components/WxConcat';
|
|
import { getChatHistory, delChatHistoryById } from '@/api/chat';
|
|
import { getCollectionModels } from '@/api/model';
|
|
import type { ChatSiteItemType } from '../index';
|
|
import { fileDownload } from '@/utils/file';
|
|
import { htmlTemplate } from '@/constants/common';
|
|
|
|
const SlideBar = ({
|
|
chatId,
|
|
modelId,
|
|
history,
|
|
resetChat,
|
|
onClose
|
|
}: {
|
|
chatId: string;
|
|
modelId: string;
|
|
history: ChatSiteItemType[];
|
|
resetChat: (modelId?: string, chatId?: string) => void;
|
|
onClose: () => void;
|
|
}) => {
|
|
const router = useRouter();
|
|
const { colorMode, toggleColorMode } = useColorMode();
|
|
const { myModels, getMyModels } = useUserStore();
|
|
const { isOpen: isOpenWx, onOpen: onOpenWx, onClose: onCloseWx } = useDisclosure();
|
|
const preChatId = useRef('chatId'); // 用于校验上一次chatId的情况,判断是否需要刷新历史记录
|
|
|
|
const { isSuccess } = useQuery(['getMyModels'], getMyModels, {
|
|
cacheTime: 5 * 60 * 1000
|
|
});
|
|
|
|
const { data: collectionModels = [] } = useQuery([getCollectionModels], getCollectionModels);
|
|
|
|
const models = useMemo(() => {
|
|
const myModelList = myModels.map((item) => ({
|
|
id: item._id,
|
|
name: item.name,
|
|
icon: 'model' as any
|
|
}));
|
|
const collectionList = collectionModels
|
|
.map((item) => ({
|
|
id: item._id,
|
|
name: item.name,
|
|
icon: 'collectionSolid' as any
|
|
}))
|
|
.filter((model) => !myModelList.find((item) => item.id === model.id));
|
|
|
|
return myModelList.concat(collectionList);
|
|
}, [collectionModels, myModels]);
|
|
|
|
const { data: chatHistory = [], mutate: loadChatHistory } = useMutation({
|
|
mutationFn: getChatHistory
|
|
});
|
|
|
|
// update history
|
|
useEffect(() => {
|
|
if (chatId && preChatId.current === '') {
|
|
loadChatHistory();
|
|
}
|
|
preChatId.current = chatId;
|
|
}, [chatId, loadChatHistory]);
|
|
|
|
// init history
|
|
useEffect(() => {
|
|
setTimeout(() => {
|
|
loadChatHistory();
|
|
}, 1000);
|
|
}, [loadChatHistory]);
|
|
|
|
/**
|
|
* export md
|
|
*/
|
|
const onclickExportMd = useCallback(() => {
|
|
fileDownload({
|
|
text: history.map((item) => item.value).join('\n'),
|
|
type: 'text/markdown',
|
|
filename: 'chat.md'
|
|
});
|
|
}, [history]);
|
|
|
|
const getHistoryHtml = useCallback(() => {
|
|
const historyDom = document.getElementById('history');
|
|
if (!historyDom) return;
|
|
const dom = Array.from(historyDom.children).map((child, i) => {
|
|
const avatar = `<img src="${
|
|
child.querySelector<HTMLImageElement>('.avatar')?.src
|
|
}" alt="" />`;
|
|
|
|
const chatContent = child.querySelector<HTMLDivElement>('.markdown');
|
|
|
|
if (!chatContent) {
|
|
return '';
|
|
}
|
|
|
|
const chatContentClone = chatContent.cloneNode(true) as HTMLDivElement;
|
|
|
|
const codeHeader = chatContentClone.querySelectorAll('.code-header');
|
|
codeHeader.forEach((childElement: any) => {
|
|
childElement.remove();
|
|
});
|
|
|
|
return `<div class="chat-item">
|
|
${avatar}
|
|
${chatContentClone.outerHTML}
|
|
</div>`;
|
|
});
|
|
const html = htmlTemplate.replace('{{CHAT_CONTENT}}', dom.join('\n'));
|
|
return html;
|
|
}, []);
|
|
|
|
const onclickExportHtml = useCallback(() => {
|
|
const html = getHistoryHtml();
|
|
html &&
|
|
fileDownload({
|
|
text: html,
|
|
type: 'text/html',
|
|
filename: '聊天记录.html'
|
|
});
|
|
}, [getHistoryHtml]);
|
|
|
|
const onclickExportPdf = useCallback(() => {
|
|
const html = getHistoryHtml();
|
|
|
|
html &&
|
|
// @ts-ignore
|
|
html2pdf(html, {
|
|
margin: 0,
|
|
filename: `聊天记录.pdf`
|
|
});
|
|
}, [getHistoryHtml]);
|
|
|
|
const RenderHistory = () => (
|
|
<>
|
|
{chatHistory.map((item) => (
|
|
<Flex
|
|
key={item._id}
|
|
alignItems={'center'}
|
|
p={3}
|
|
borderRadius={'md'}
|
|
mb={2}
|
|
cursor={'pointer'}
|
|
_hover={{
|
|
backgroundColor: 'rgba(255,255,255,0.1)'
|
|
}}
|
|
fontSize={'xs'}
|
|
border={'1px solid transparent'}
|
|
{...(item._id === chatId
|
|
? {
|
|
borderColor: 'rgba(255,255,255,0.5)',
|
|
backgroundColor: 'rgba(255,255,255,0.1)'
|
|
}
|
|
: {})}
|
|
onClick={() => {
|
|
if (item._id === chatId) return;
|
|
preChatId.current = 'chatId';
|
|
resetChat(item.modelId, item._id);
|
|
onClose();
|
|
}}
|
|
>
|
|
<ChatIcon mr={2} />
|
|
<Box flex={'1 0 0'} w={0} className="textEllipsis">
|
|
{item.title}
|
|
</Box>
|
|
<Box>
|
|
<IconButton
|
|
icon={<DeleteIcon />}
|
|
variant={'unstyled'}
|
|
aria-label={'edit'}
|
|
size={'xs'}
|
|
onClick={async (e) => {
|
|
e.stopPropagation();
|
|
|
|
await delChatHistoryById(item._id);
|
|
loadChatHistory();
|
|
if (item._id === chatId) {
|
|
resetChat();
|
|
}
|
|
}}
|
|
/>
|
|
</Box>
|
|
</Flex>
|
|
))}
|
|
</>
|
|
);
|
|
|
|
const RenderButton = ({
|
|
onClick,
|
|
children
|
|
}: {
|
|
onClick: () => void;
|
|
children: JSX.Element | string;
|
|
}) => (
|
|
<Box px={3} mb={2}>
|
|
<Flex
|
|
alignItems={'center'}
|
|
p={2}
|
|
cursor={'pointer'}
|
|
borderRadius={'md'}
|
|
_hover={{
|
|
backgroundColor: 'rgba(255,255,255,0.2)'
|
|
}}
|
|
onClick={onClick}
|
|
>
|
|
{children}
|
|
</Flex>
|
|
</Box>
|
|
);
|
|
|
|
return (
|
|
<Flex
|
|
flexDirection={'column'}
|
|
w={'100%'}
|
|
h={'100%'}
|
|
py={3}
|
|
backgroundColor={useColorModeValue('blackAlpha.800', 'blackAlpha.500')}
|
|
color={'white'}
|
|
>
|
|
{/* 新对话 */}
|
|
{getToken() && (
|
|
<Button
|
|
w={'90%'}
|
|
variant={'white'}
|
|
h={'40px'}
|
|
mb={2}
|
|
mx={'auto'}
|
|
leftIcon={<AddIcon />}
|
|
onClick={() => resetChat()}
|
|
>
|
|
新对话
|
|
</Button>
|
|
)}
|
|
{/* 我的模型 & 历史记录 折叠框*/}
|
|
<Box flex={'1 0 0'} px={3} h={0} overflowY={'auto'}>
|
|
{isSuccess && (
|
|
<>
|
|
<Box>
|
|
{models.map((item) => (
|
|
<Flex
|
|
key={item.id}
|
|
alignItems={'center'}
|
|
p={3}
|
|
borderRadius={'md'}
|
|
mb={2}
|
|
cursor={'pointer'}
|
|
_hover={{
|
|
backgroundColor: 'rgba(255,255,255,0.1)'
|
|
}}
|
|
fontSize={'xs'}
|
|
border={'1px solid transparent'}
|
|
{...(item.id === modelId
|
|
? {
|
|
borderColor: 'rgba(255,255,255,0.5)',
|
|
backgroundColor: 'rgba(255,255,255,0.1)'
|
|
}
|
|
: {})}
|
|
onClick={async () => {
|
|
if (item.id === modelId) return;
|
|
resetChat(item.id);
|
|
onClose();
|
|
}}
|
|
>
|
|
<MyIcon name={item.icon} mr={2} color={'white'} w={'16px'} h={'16px'} />
|
|
<Box className={'textEllipsis'} flex={'1 0 0'} w={0}>
|
|
{item.name}
|
|
</Box>
|
|
</Flex>
|
|
))}
|
|
</Box>
|
|
</>
|
|
)}
|
|
<Accordion allowToggle>
|
|
<AccordionItem borderTop={0} borderBottom={0}>
|
|
<AccordionButton borderRadius={'md'} pl={1}>
|
|
<Box as="span" flex="1" textAlign="left">
|
|
历史记录
|
|
</Box>
|
|
<AccordionIcon />
|
|
</AccordionButton>
|
|
<AccordionPanel pb={0} px={0}>
|
|
<RenderHistory />
|
|
</AccordionPanel>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
</Box>
|
|
|
|
<Divider my={3} colorScheme={useColorModeValue('gray', 'white')} />
|
|
|
|
{history.length > 0 && (
|
|
<Menu autoSelect={false}>
|
|
<MenuButton
|
|
mx={3}
|
|
mb={2}
|
|
p={2}
|
|
display={'flex'}
|
|
alignItems={'center'}
|
|
cursor={'pointer'}
|
|
borderRadius={'md'}
|
|
textAlign={'left'}
|
|
_hover={{
|
|
backgroundColor: 'rgba(255,255,255,0.2)'
|
|
}}
|
|
>
|
|
<MyIcon name="export" fill={'white'} w={'18px'} h={'18px'} mr={4} />
|
|
导出聊天
|
|
</MenuButton>
|
|
<MenuList fontSize={'sm'} color={'blackAlpha.800'}>
|
|
<MenuItem onClick={onclickExportHtml}>HTML格式</MenuItem>
|
|
<MenuItem onClick={onclickExportPdf}>PDF格式</MenuItem>
|
|
<MenuItem onClick={onclickExportMd}>Markdown格式</MenuItem>
|
|
</MenuList>
|
|
</Menu>
|
|
)}
|
|
|
|
<RenderButton onClick={() => router.push('/')}>
|
|
<>
|
|
<MyIcon name="home" fill={'white'} w={'18px'} h={'18px'} mr={4} />
|
|
首页
|
|
</>
|
|
</RenderButton>
|
|
|
|
<RenderButton onClick={() => router.push('/number/setting')}>
|
|
<>
|
|
<MyIcon name="pay" fill={'white'} w={'16px'} h={'16px'} mr={4} />
|
|
充值
|
|
</>
|
|
</RenderButton>
|
|
|
|
<Flex alignItems={'center'} mr={4}>
|
|
<Box flex={1}>
|
|
<RenderButton onClick={onOpenWx}>交流群</RenderButton>
|
|
</Box>
|
|
<IconButton
|
|
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
|
aria-label={''}
|
|
variant={'outline'}
|
|
w={'16px'}
|
|
colorScheme={'white'}
|
|
_hover={{
|
|
backgroundColor: 'rgba(255,255,255,0.2)'
|
|
}}
|
|
onClick={toggleColorMode}
|
|
/>
|
|
</Flex>
|
|
|
|
{/* wx 联系 */}
|
|
{isOpenWx && <WxConcat onClose={onCloseWx} />}
|
|
</Flex>
|
|
);
|
|
};
|
|
|
|
export default SlideBar;
|