feat: chat ui

This commit is contained in:
archer 2023-05-12 00:09:10 +08:00
parent 39f9080eb2
commit 1226c3efb7
No known key found for this signature in database
GPG Key ID: 569A5660D2379E28
6 changed files with 195 additions and 104 deletions

View File

@ -11,5 +11,6 @@ chatgpt 上下文最长 4096 tokens, 会自动截取上下文,超过 4096 部
服务器代理不稳定,可以过一会儿再尝试。 或者可以访问国外服务器: [FastGpt](https://fastgpt.run/) 服务器代理不稳定,可以过一会儿再尝试。 或者可以访问国外服务器: [FastGpt](https://fastgpt.run/)
**其他问题** **其他问题**
请 WX 联系: fastgpt123 请 WX 联系: fastgpt123
![](/imgs/wxqun300.jpg) | 交流群 | 小助手 |
![](/imgs/wx300.jpg) | ----------------------- | -------------------- |
| ![](/imgs/wxqun300.jpg) | ![](/imgs/wx300.jpg) |

View File

@ -9,9 +9,11 @@ import Navbar from './navbar';
import NavbarPhone from './navbarPhone'; import NavbarPhone from './navbarPhone';
const pcUnShowLayoutRoute: Record<string, boolean> = { const pcUnShowLayoutRoute: Record<string, boolean> = {
'/': true,
'/login': true '/login': true
}; };
const phoneUnShowLayoutRoute: Record<string, boolean> = { const phoneUnShowLayoutRoute: Record<string, boolean> = {
'/': true,
'/login': true '/login': true
}; };

View File

@ -337,10 +337,10 @@
} }
.markdown { .markdown {
text-align: justify; text-align: justify;
overflow-y: hidden;
tab-size: 4; tab-size: 4;
word-spacing: normal; word-spacing: normal;
word-break: break-all; word-break: break-all;
width: 100%;
p { p {
white-space: pre-line; white-space: pre-line;
@ -353,13 +353,13 @@
margin: 0; margin: 0;
border: none; border: none;
border-radius: 0; border-radius: 0;
background-color: #222 !important; background-color: #292b33 !important;
overflow-x: auto; overflow-x: auto;
color: #fff; color: #fff;
} }
pre code { pre code {
background-color: #222 !important; background-color: #292b33 !important;
width: 100%; width: 100%;
} }

View File

@ -29,7 +29,7 @@ const Markdown = ({ source, isChatting = false }: { source: string; isChatting?:
const code = String(children); const code = String(children);
return !inline || match ? ( return !inline || match ? (
<Box my={3} borderRadius={'md'} overflow={'hidden'} backgroundColor={'#222'}> <Box my={3} borderRadius={'md'} overflow={'overlay'} backgroundColor={'#222'}>
<Flex <Flex
className="code-header" className="code-header"
py={2} py={2}

View File

@ -1,4 +1,4 @@
import React, { useCallback, useState, useRef, useMemo, useEffect } from 'react'; import React, { useCallback, useState, useRef, useMemo, useEffect, MouseEvent } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { import {
getInitChatSiteInfo, getInitChatSiteInfo,
@ -26,7 +26,10 @@ import {
useDisclosure, useDisclosure,
Drawer, Drawer,
DrawerOverlay, DrawerOverlay,
DrawerContent DrawerContent,
Card,
Tooltip,
useOutsideClick
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useScreen } from '@/hooks/useScreen'; import { useScreen } from '@/hooks/useScreen';
@ -76,6 +79,8 @@ const Chat = ({
const ChatBox = useRef<HTMLDivElement>(null); const ChatBox = useRef<HTMLDivElement>(null);
const TextareaDom = useRef<HTMLTextAreaElement>(null); const TextareaDom = useRef<HTMLTextAreaElement>(null);
const ContextMenuRef = useRef(null);
const PhoneContextShow = useRef(false);
// 中断请求 // 中断请求
const controller = useRef(new AbortController()); const controller = useRef(new AbortController());
@ -83,6 +88,12 @@ const Chat = ({
const [inputVal, setInputVal] = useState(''); // user input prompt const [inputVal, setInputVal] = useState(''); // user input prompt
const [showSystemPrompt, setShowSystemPrompt] = useState(''); const [showSystemPrompt, setShowSystemPrompt] = useState('');
const [messageContextMenuData, setMessageContextMenuData] = useState<{
// message messageContextMenuData
left: number;
top: number;
message: ChatSiteItemType;
}>();
const { const {
lastChatModelId, lastChatModelId,
@ -107,6 +118,24 @@ const Chat = ({
const { Loading, setIsLoading } = useLoading(); const { Loading, setIsLoading } = useLoading();
const { userInfo } = useUserStore(); const { userInfo } = useUserStore();
const { isOpen: isOpenSlider, onClose: onCloseSlider, onOpen: onOpenSlider } = useDisclosure(); const { isOpen: isOpenSlider, onClose: onCloseSlider, onOpen: onOpenSlider } = useDisclosure();
// close contextMenu
useOutsideClick({
ref: ContextMenuRef,
handler: () => {
// 移动端长按后会将其设置为true松手时候也会触发一次松手的时候需要忽略一次。
if (PhoneContextShow.current) {
PhoneContextShow.current = false;
} else {
messageContextMenuData &&
setTimeout(() => {
setMessageContextMenuData(undefined);
window.getSelection?.()?.empty?.();
window.getSelection?.()?.removeAllRanges?.();
document?.getSelection()?.empty();
});
}
}
});
// 滚动到底部 // 滚动到底部
const scrollToBottom = useCallback((behavior: 'smooth' | 'auto' = 'smooth') => { const scrollToBottom = useCallback((behavior: 'smooth' | 'auto' = 'smooth') => {
@ -234,6 +263,9 @@ const Chat = ({
// refresh history // refresh history
loadHistory({ pageNum: 1, init: true }); loadHistory({ pageNum: 1, init: true });
setTimeout(() => {
generatingMessage();
}, 100);
}, },
[ [
chatId, chatId,
@ -327,24 +359,26 @@ const Chat = ({
]); ]);
// 删除一句话 // 删除一句话
const delChatRecord = useCallback( const delChatRecord = useCallback(async () => {
async (index: number, id: string) => { if (!messageContextMenuData) return;
setIsLoading(true); setIsLoading(true);
try { const index = chatData.history.findIndex(
// 删除数据库最后一句 (item) => item._id === messageContextMenuData.message._id
await delChatRecordByIndex(chatId, id); );
setChatData((state) => ({ try {
...state, // 删除数据库最后一句
history: state.history.filter((_, i) => i !== index) await delChatRecordByIndex(chatId, messageContextMenuData.message._id);
}));
} catch (err) { setChatData((state) => ({
console.log(err); ...state,
} history: state.history.filter((_, i) => i !== index)
setIsLoading(false); }));
}, } catch (err) {
[chatId, setChatData, setIsLoading] console.log(err);
); }
setIsLoading(false);
}, [chatData.history, chatId, messageContextMenuData, setChatData, setIsLoading]);
// 复制内容 // 复制内容
const onclickCopy = useCallback( const onclickCopy = useCallback(
@ -433,6 +467,34 @@ const Chat = ({
[loadHistory] [loadHistory]
); );
// onclick chat message context
const onclickContextMenu = useCallback(
(e: MouseEvent<HTMLDivElement>, message: ChatSiteItemType) => {
e.preventDefault(); // 阻止默认右键菜单
// select all text
const range = document.createRange();
range.selectNodeContents(e.currentTarget as HTMLDivElement);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
navigator.vibrate?.(50); // 震动 50 毫秒
if (!isPcDevice) {
PhoneContextShow.current = true;
}
setMessageContextMenuData({
left: e.clientX,
top: e.clientY,
message
});
return false;
},
[isPcDevice]
);
// 获取对话信息 // 获取对话信息
const loadChatInfo = useCallback( const loadChatInfo = useCallback(
async ({ async ({
@ -511,6 +573,7 @@ const Chat = ({
}); });
}); });
// abort stream
useEffect(() => { useEffect(() => {
return () => { return () => {
isLeavePage.current = true; isLeavePage.current = true;
@ -522,7 +585,7 @@ const Chat = ({
<Flex <Flex
h={'100%'} h={'100%'}
flexDirection={['column', 'row']} flexDirection={['column', 'row']}
backgroundColor={useColorModeValue('white', '')} backgroundColor={useColorModeValue('#fefefe', '')}
> >
{/* pc always show history. phone is only show when modelId is present */} {/* pc always show history. phone is only show when modelId is present */}
{isPc || !modelId ? ( {isPc || !modelId ? (
@ -605,6 +668,7 @@ const Chat = ({
position={'relative'} position={'relative'}
h={[0, '100%']} h={[0, '100%']}
w={['100%', 0]} w={['100%', 0]}
pt={[2, 4]}
flex={'1 0 0'} flex={'1 0 0'}
flexDirection={'column'} flexDirection={'column'}
> >
@ -617,20 +681,25 @@ const Chat = ({
w={'100%'} w={'100%'}
overflow={'overlay'} overflow={'overlay'}
> >
{chatData.history.map((item, index) => ( <Box maxW={['auto', '800px', '1000px']} m={'auto'}>
<Box {chatData.history.map((item, index) => (
key={item._id} <Flex key={item._id} alignItems={'flex-start'} py={2} px={[2, 4]}>
py={[6, 9]} {item.obj === 'Human' && <Box flex={1} />}
px={[2, 4]} {/* avatar */}
backgroundColor={ <Box
index % 2 !== 0 ? useColorModeValue('blackAlpha.50', 'gray.700') : '' {...(item.obj === 'AI'
} ? {
color={useColorModeValue('blackAlpha.700', 'white')} order: 1,
borderBottom={'1px solid rgba(0,0,0,0.1)'} mr: ['6px', 4],
> cursor: 'pointer',
<Flex maxW={'750px'} m={'auto'} alignItems={'flex-start'}> onClick: () => router.push(`/model?modelId=${chatData.modelId}`)
<Menu autoSelect={false}> }
<MenuButton as={Box} mr={[1, 4]} cursor={'pointer'}> : {
order: 3,
ml: ['6px', 4]
})}
>
<Tooltip label={item.obj === 'AI' ? 'AI助手详情' : ''}>
<Image <Image
className="avatar" className="avatar"
src={ src={
@ -639,75 +708,73 @@ const Chat = ({
: chatData.model.avatar || LOGO_ICON : chatData.model.avatar || LOGO_ICON
} }
alt="avatar" alt="avatar"
w={['20px', '30px']} w={['20px', '34px']}
maxH={'50px'} h={['20px', '34px']}
borderRadius={'50%'}
objectFit={'contain'} objectFit={'contain'}
/> />
</MenuButton> </Tooltip>
<MenuList fontSize={'sm'}> </Box>
{chatData.model.canUse && item.obj === 'AI' && ( {/* message */}
<MenuItem onClick={() => router.push(`/model?modelId=${chatData.modelId}`)}> <Flex order={2} maxW={['calc(100% - 50px)', '80%']}>
AI助手详情
</MenuItem>
)}
<MenuItem onClick={() => onclickCopy(item.value)}></MenuItem>
<MenuItem onClick={() => delChatRecord(index, item._id)}></MenuItem>
</MenuList>
</Menu>
<Box flex={'1 0 0'} w={0} overflow={'hidden'}>
{item.obj === 'AI' ? ( {item.obj === 'AI' ? (
<> <Box w={'100%'}>
<Markdown {isPc && (
source={item.value} <Box color={'myGray.600'} fontSize={'sm'} mb={1}>
isChatting={isChatting && index === chatData.history.length - 1} {chatData.model.name}
/> </Box>
{item.systemPrompt && (
<Button
size={'xs'}
mt={2}
fontWeight={'normal'}
colorScheme={'gray'}
variant={'outline'}
onClick={() => setShowSystemPrompt(item.systemPrompt || '')}
>
</Button>
)} )}
</> <Card
bg={'white'}
px={4}
py={3}
borderRadius={'0 8px 8px 8px'}
onContextMenu={(e) => onclickContextMenu(e, item)}
>
<Markdown
source={item.value}
isChatting={isChatting && index === chatData.history.length - 1}
/>
{item.systemPrompt && (
<Button
size={'xs'}
mt={2}
fontWeight={'normal'}
colorScheme={'gray'}
variant={'outline'}
w={'90px'}
onClick={() => setShowSystemPrompt(item.systemPrompt || '')}
>
</Button>
)}
</Card>
</Box>
) : ( ) : (
<Box className="markdown" whiteSpace={'pre-wrap'}> <Box>
<Box as={'p'}>{item.value}</Box> {isPc && (
<Box color={'myGray.600'} mb={1} fontSize={'sm'} textAlign={'right'}>
Human
</Box>
)}
<Card
className="markdown"
whiteSpace={'pre-wrap'}
px={4}
py={3}
borderRadius={'8px 0 8px 8px'}
bg={'myBlue.300'}
onContextMenu={(e) => onclickContextMenu(e, item)}
>
<Box as={'p'}>{item.value}</Box>
</Card>
</Box> </Box>
)} )}
</Box> </Flex>
{/* copy and clear icon */}
{isPc && (
<Flex h={'100%'} flexDirection={'column'} ml={2} w={'14px'} height={'100%'}>
<Box minH={'40px'} flex={1}>
<MyIcon
name="copy"
w={'14px'}
cursor={'pointer'}
color={'blackAlpha.700'}
onClick={() => onclickCopy(item.value)}
/>
</Box>
<MyIcon
name="delete"
w={'14px'}
cursor={'pointer'}
color={'blackAlpha.700'}
_hover={{
color: 'red.600'
}}
onClick={() => delChatRecord(index, item._id)}
/>
</Flex>
)}
</Flex> </Flex>
</Box> ))}
))} {chatData.history.length === 0 && <Empty model={chatData.model} />}
{chatData.history.length === 0 && <Empty model={chatData.model} />} </Box>
</Box> </Box>
{/* 发送区 */} {/* 发送区 */}
{chatData.model.canUse ? ( {chatData.model.canUse ? (
@ -715,7 +782,7 @@ const Chat = ({
<Box <Box
py={'18px'} py={'18px'}
position={'relative'} position={'relative'}
boxShadow={`0 0 15px rgba(0,0,0,0.1)`} boxShadow={`0 0 10px rgba(0,0,0,0.1)`}
borderTop={['1px solid', 0]} borderTop={['1px solid', 0]}
borderTopColor={useColorModeValue('gray.200', 'gray.700')} borderTopColor={useColorModeValue('gray.200', 'gray.700')}
borderRadius={['none', 'md']} borderRadius={['none', 'md']}
@ -751,7 +818,7 @@ const Chat = ({
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
// 触发快捷发送 // 触发快捷发送
if (isPc && e.keyCode === 13 && !e.shiftKey) { if (isPcDevice && e.keyCode === 13 && !e.shiftKey) {
sendPrompt(); sendPrompt();
e.preventDefault(); e.preventDefault();
} }
@ -817,6 +884,25 @@ const Chat = ({
</ModalContent> </ModalContent>
</Modal> </Modal>
} }
{/* context menu */}
{messageContextMenuData && (
<Box
zIndex={10}
position={'fixed'}
top={messageContextMenuData.top}
left={messageContextMenuData.left}
>
<Box ref={ContextMenuRef}></Box>
<Menu isOpen>
<MenuList minW={`100px !important`}>
<MenuItem onClick={() => onclickCopy(messageContextMenuData.message.value)}>
</MenuItem>
<MenuItem onClick={delChatRecord}></MenuItem>
</MenuList>
</Menu>
</Box>
)}
</Flex> </Flex>
); );
}; };

View File

@ -141,6 +141,7 @@ const Home = () => {
flexDirection={'column'} flexDirection={'column'}
alignItems={'center'} alignItems={'center'}
pt={'20vh'} pt={'20vh'}
overflow={'overlay'}
> >
<Box id={'particles-js'} position={'absolute'} top={0} left={0} right={0} bottom={0} /> <Box id={'particles-js'} position={'absolute'} top={0} left={0} right={0} bottom={0} />
<Image src="/icon/logo.png" w={['70px', '120px']} h={['70px', '120px']} alt={''}></Image> <Image src="/icon/logo.png" w={['70px', '120px']} h={['70px', '120px']} alt={''}></Image>
@ -162,7 +163,8 @@ const Home = () => {
<Button <Button
my={5} my={5}
fontSize={['xl', '3xl']} fontSize={['xl', '3xl']}
h={['35px', '40px']} h={'auto'}
py={[2, 3]}
onClick={() => router.push(`/model`)} onClick={() => router.push(`/model`)}
> >