import React, { useCallback, useState, useRef, useMemo, useEffect } from 'react'; import { useRouter } from 'next/router'; import Image from 'next/image'; import { getInitChatSiteInfo, getChatSiteId, postGPT3SendPrompt, delChatRecordByIndex, postSaveChat } from '@/api/chat'; import type { InitChatResponse } from '@/api/response/chat'; import { ChatSiteItemType } from '@/types/chat'; import { Textarea, Box, Flex, useDisclosure, Drawer, DrawerOverlay, DrawerContent, useColorModeValue, Menu, MenuButton, MenuList, MenuItem } from '@chakra-ui/react'; import { useToast } from '@/hooks/useToast'; import { useScreen } from '@/hooks/useScreen'; import { useQuery } from '@tanstack/react-query'; import { ChatModelNameEnum } from '@/constants/model'; import dynamic from 'next/dynamic'; import { useGlobalStore } from '@/store/global'; import { useChatStore } from '@/store/chat'; import { useCopyData } from '@/utils/tools'; import { streamFetch } from '@/api/fetch'; import SlideBar from './components/SlideBar'; import Empty from './components/Empty'; import Icon from '@/components/Icon'; import { encode } from 'gpt-token-utils'; import { modelList } from '@/constants/model'; const Markdown = dynamic(() => import('@/components/Markdown')); const textareaMinH = '22px'; interface ChatType extends InitChatResponse { history: ChatSiteItemType[]; } const Chat = ({ chatId }: { chatId: string }) => { const { toast } = useToast(); const router = useRouter(); const ChatBox = useRef(null); const TextareaDom = useRef(null); // 中断请求 const controller = useRef(new AbortController()); const [chatData, setChatData] = useState({ chatId: '', modelId: '', name: '', avatar: '', intro: '', chatModel: '', modelName: '', history: [] }); // 聊天框整体数据 const [inputVal, setInputVal] = useState(''); // 输入的内容 const isChatting = useMemo( () => chatData.history[chatData.history.length - 1]?.status === 'loading', [chatData.history] ); const { copyData } = useCopyData(); const { isPc, media } = useScreen(); const { setLoading } = useGlobalStore(); const { isOpen: isOpenSlider, onClose: onCloseSlider, onOpen: onOpenSlider } = useDisclosure(); const { pushChatHistory } = useChatStore(); // 滚动到底部 const scrollToBottom = useCallback(() => { setTimeout(() => { ChatBox.current && ChatBox.current.scrollTo({ top: ChatBox.current.scrollHeight, behavior: 'smooth' }); }, 100); }, []); // 重置输入内容 const resetInputVal = useCallback((val: string) => { setInputVal(val); setTimeout(() => { /* 回到最小高度 */ if (TextareaDom.current) { TextareaDom.current.style.height = val === '' ? textareaMinH : `${TextareaDom.current.scrollHeight}px`; } }, 100); }, []); // 重载对话 const resetChat = useCallback(async () => { if (!chatData) return; try { router.replace(`/chat?chatId=${await getChatSiteId(chatData.modelId)}`); } catch (error: any) { toast({ title: error?.message || '生成新对话失败', status: 'warning' }); } onCloseSlider(); }, [chatData, onCloseSlider, router, toast]); // gpt 对话 const gptChatPrompt = useCallback( async (prompts: ChatSiteItemType) => { const urlMap: Record = { [ChatModelNameEnum.GPT35]: '/api/chat/chatGpt', [ChatModelNameEnum.VECTOR_GPT]: '/api/chat/vectorGpt', [ChatModelNameEnum.GPT3]: '/api/chat/gpt3' }; if (!urlMap[chatData.modelName]) return Promise.reject('找不到模型'); const prompt = { obj: prompts.obj, value: prompts.value }; // 流请求,获取数据 const res = await streamFetch({ url: urlMap[chatData.modelName], data: { prompt, chatId }, onMessage: (text: string) => { setChatData((state) => ({ ...state, history: state.history.map((item, index) => { if (index !== state.history.length - 1) return item; return { ...item, value: item.value + text }; }) })); }, abortSignal: controller.current }); // 保存对话信息 try { await postSaveChat({ chatId, prompts: [ prompt, { obj: 'AI', value: res as string } ] }); } catch (err) { toast({ title: '对话出现异常, 继续对话会导致上下文丢失,请刷新页面', status: 'warning', duration: 3000, isClosable: true }); } // 设置完成状态 setChatData((state) => ({ ...state, history: state.history.map((item, index) => { if (index !== state.history.length - 1) return item; return { ...item, status: 'finish' }; }) })); }, [chatData.modelName, chatId, toast] ); /** * 发送一个内容 */ const sendPrompt = useCallback(async () => { if (isChatting) { toast({ title: '正在聊天中...请等待结束', status: 'warning' }); return; } const storeInput = inputVal; // 去除空行 const val = inputVal.trim().replace(/\n\s*/g, '\n'); if (!chatData?.modelId || !val) { toast({ title: '内容为空', status: 'warning' }); return; } // 长度校验 const tokens = encode(val).length; const model = modelList.find((item) => item.model === chatData.modelName); if (model && tokens >= model.maxToken) { toast({ title: '单次输入超出 4000 tokens', status: 'warning' }); return; } const newChatList: ChatSiteItemType[] = [ ...chatData.history, { obj: 'Human', value: val, status: 'finish' }, { obj: 'AI', value: '', status: 'loading' } ]; // 插入内容 setChatData((state) => ({ ...state, history: newChatList })); // 清空输入内容 resetInputVal(''); scrollToBottom(); try { await gptChatPrompt(newChatList[newChatList.length - 2]); // 如果是 Human 第一次发送,插入历史记录 const humanChat = newChatList.filter((item) => item.obj === 'Human'); if (humanChat.length === 1) { pushChatHistory({ chatId, title: humanChat[0].value }); } } catch (err: any) { toast({ title: typeof err === 'string' ? err : err?.message || '聊天出错了~', status: 'warning', duration: 5000, isClosable: true }); resetInputVal(storeInput); setChatData((state) => ({ ...state, history: newChatList.slice(0, newChatList.length - 2) })); } }, [ inputVal, chatData, isChatting, resetInputVal, scrollToBottom, toast, gptChatPrompt, pushChatHistory, chatId ]); // 删除一句话 const delChatRecord = useCallback( async (index: number) => { setLoading(true); try { // 删除数据库最后一句 await delChatRecordByIndex(chatId, index); setChatData((state) => ({ ...state, history: state.history.filter((_, i) => i !== index) })); } catch (err) { console.log(err); } setLoading(false); }, [chatId, setLoading] ); // 复制内容 const onclickCopy = useCallback( (chatId: string) => { const dom = document.getElementById(chatId); const innerText = dom?.innerText; innerText && copyData(innerText); }, [copyData] ); useEffect(() => { controller.current = new AbortController(); return () => { // eslint-disable-next-line react-hooks/exhaustive-deps controller.current?.abort(); }; }, [chatId]); // 初始化聊天框 useQuery( ['init', chatId], () => { setLoading(true); return getInitChatSiteInfo(chatId); }, { onSuccess(res) { setChatData({ ...res, history: res.history.map((item) => ({ ...item, status: 'finish' })) }); if (res.history.length > 0) { setTimeout(() => { scrollToBottom(); }, 500); } }, onError(e: any) { toast({ title: e?.message || '初始化异常,请检查地址', status: 'error', isClosable: true, duration: 5000 }); }, onSettled() { setLoading(false); } } ); return ( {isPc ? ( ) : ( {chatData?.name} )} {/* 聊天内容 */} {chatData.history.map((item, index) => ( /icon/logo.png onclickCopy(`chat${index}`)}>复制 delChatRecord(index)}>删除该行 {item.obj === 'AI' ? ( ) : ( {item.value} )} ))} {chatData.history.length === 0 && } {/* 发送区 */} {/* 输入框 */}