import React, { useCallback, useRef, useState, useMemo, forwardRef, useImperativeHandle, ForwardedRef, useEffect } from 'react'; import { throttle } from 'lodash'; import { ChatHistoryItemResType, ChatItemType, ChatSiteItemType, ExportChatType } from '@/types/chat'; import { useToast } from '@/hooks/useToast'; import { useCopyData, voiceBroadcast, cancelBroadcast, hasVoiceApi, getErrText } from '@/utils/tools'; import { Box, Card, Flex, Input, Textarea, Button, useTheme, BoxProps } from '@chakra-ui/react'; import { feConfigs } from '@/store/static'; import { Types } from 'mongoose'; import { EventNameEnum } from '../Markdown/constant'; import { adaptChatItem_openAI } from '@/utils/plugin/openai'; import { useMarkdown } from '@/hooks/useMarkdown'; import { VariableItemType } from '@/types/app'; import { VariableInputEnum } from '@/constants/app'; import { useForm } from 'react-hook-form'; import { MessageItemType } from '@/pages/api/openapi/v1/chat/completions'; import { fileDownload } from '@/utils/file'; import { htmlTemplate } from '@/constants/common'; import { useRouter } from 'next/router'; import { useGlobalStore } from '@/store/global'; import { TaskResponseKeyEnum } from '@/constants/chat'; import MyIcon from '@/components/Icon'; import Avatar from '@/components/Avatar'; import Markdown from '@/components/Markdown'; import MySelect from '@/components/Select'; import MyTooltip from '../MyTooltip'; import dynamic from 'next/dynamic'; const ResponseDetailModal = dynamic(() => import('./ResponseDetailModal')); import styles from './index.module.scss'; const textareaMinH = '22px'; export type StartChatFnProps = { messages: MessageItemType[]; controller: AbortController; variables: Record; generatingMessage: (text: string) => void; }; export type ComponentRef = { getChatHistory: () => ChatSiteItemType[]; resetVariables: (data?: Record) => void; resetHistory: (chatId: ChatSiteItemType[]) => void; scrollToBottom: (behavior?: 'smooth' | 'auto') => void; }; const VariableLabel = ({ required = false, children }: { required?: boolean; children: React.ReactNode | string; }) => ( {children} {required && ( * )} ); const Empty = () => { const { data: chatProblem } = useMarkdown({ url: '/chatProblem.md' }); const { data: versionIntro } = useMarkdown({ url: '/versionIntro.md' }); return ( {/* version intro */} ); }; const ChatAvatar = ({ src, type }: { src?: string; type: 'Human' | 'AI' }) => { const theme = useTheme(); return ( ); }; const ChatBox = ( { showEmptyIntro = false, chatId, appAvatar, userAvatar, variableModules, welcomeText, onUpdateVariable, onStartChat, onDelMessage }: { showEmptyIntro?: boolean; chatId?: string; appAvatar?: string; userAvatar?: string; variableModules?: VariableItemType[]; welcomeText?: string; onUpdateVariable?: (e: Record) => void; onStartChat: (e: StartChatFnProps) => Promise<{ responseText: string; [TaskResponseKeyEnum.responseData]: ChatHistoryItemResType[]; }>; onDelMessage?: (e: { contentId?: string; index: number }) => void; }, ref: ForwardedRef ) => { const ChatBoxRef = useRef(null); const theme = useTheme(); const router = useRouter(); const { copyData } = useCopyData(); const { toast } = useToast(); const { isPc } = useGlobalStore(); const TextareaDom = useRef(null); const controller = useRef(new AbortController()); const [refresh, setRefresh] = useState(false); const [variables, setVariables] = useState>({}); const [chatHistory, setChatHistory] = useState([]); const isChatting = useMemo( () => chatHistory[chatHistory.length - 1]?.status === 'loading', [chatHistory] ); const variableIsFinish = useMemo(() => { if (!variableModules || chatHistory.length > 0) return true; for (let i = 0; i < variableModules.length; i++) { const item = variableModules[i]; if (item.required && !variables[item.key]) { return false; } } return true; }, [chatHistory.length, variableModules, variables]); const { register, reset, getValues, setValue, handleSubmit } = useForm>({ defaultValues: variables }); // 滚动到底部 const scrollToBottom = useCallback( (behavior: 'smooth' | 'auto' = 'smooth') => { if (!ChatBoxRef.current) return; ChatBoxRef.current.scrollTo({ top: ChatBoxRef.current.scrollHeight, behavior }); }, [ChatBoxRef] ); // 聊天信息生成中……获取当前滚动条位置,判断是否需要滚动到底部 const generatingScroll = useCallback( throttle(() => { if (!ChatBoxRef.current) return; const isBottom = ChatBoxRef.current.scrollTop + ChatBoxRef.current.clientHeight + 150 >= ChatBoxRef.current.scrollHeight; isBottom && scrollToBottom('auto'); }, 100), [] ); // eslint-disable-next-line react-hooks/exhaustive-deps const generatingMessage = useCallback( (text: string) => { setChatHistory((state) => state.map((item, index) => { if (index !== state.length - 1) return item; return { ...item, value: item.value + text }; }) ); generatingScroll(); }, [generatingScroll, setChatHistory] ); // 复制内容 const onclickCopy = useCallback( (value: string) => { const val = value.replace(/\n+/g, '\n'); copyData(val); }, [copyData] ); // 重置输入内容 const resetInputVal = useCallback((val: string) => { if (!TextareaDom.current) return; setTimeout(() => { /* 回到最小高度 */ if (TextareaDom.current) { TextareaDom.current.value = val; TextareaDom.current.style.height = val === '' ? textareaMinH : `${TextareaDom.current.scrollHeight}px`; } }, 100); }, []); /** * user confirm send prompt */ const sendPrompt = useCallback( async (data: Record = {}, inputVal = '') => { if (isChatting) { toast({ title: '正在聊天中...请等待结束', status: 'warning' }); return; } // get input value const val = inputVal.trim().replace(/\n\s*/g, '\n'); if (!val) { toast({ title: '内容为空', status: 'warning' }); return; } const newChatList: ChatSiteItemType[] = [ ...chatHistory, { _id: String(new Types.ObjectId()), obj: 'Human', value: val, status: 'finish' }, { _id: String(new Types.ObjectId()), obj: 'AI', value: '', status: 'loading' } ]; // 插入内容 setChatHistory(newChatList); // 清空输入内容 resetInputVal(''); setTimeout(() => { scrollToBottom(); }, 100); try { // create abort obj const abortSignal = new AbortController(); controller.current = abortSignal; const messages = adaptChatItem_openAI({ messages: newChatList, reserveId: true }); const { responseData } = await onStartChat({ messages, controller: abortSignal, generatingMessage, variables: data }); // set finish status setChatHistory((state) => state.map((item, index) => { if (index !== state.length - 1) return item; return { ...item, status: 'finish', responseData }; }) ); setTimeout(() => { generatingScroll(); isPc && TextareaDom.current?.focus(); }, 100); } catch (err: any) { toast({ title: getErrText(err, '聊天出错了~'), status: 'error', duration: 5000, isClosable: true }); if (!err?.responseText) { resetInputVal(inputVal); setChatHistory(newChatList.slice(0, newChatList.length - 2)); } // set finish status setChatHistory((state) => state.map((item, index) => { if (index !== state.length - 1) return item; return { ...item, status: 'finish' }; }) ); } }, [ isChatting, chatHistory, resetInputVal, toast, scrollToBottom, onStartChat, generatingMessage, generatingScroll, isPc ] ); useImperativeHandle(ref, () => ({ getChatHistory: () => chatHistory, resetVariables(e) { const defaultVal: Record = {}; variableModules?.forEach((item) => { defaultVal[item.key] = ''; }); reset(e || defaultVal); setVariables(e || defaultVal); }, resetHistory(e) { setChatHistory(e); }, scrollToBottom })); const controlIconStyle = { w: '14px', cursor: 'pointer', p: 1, bg: 'white', borderRadius: 'lg', boxShadow: '0 0 5px rgba(0,0,0,0.1)', border: theme.borders.base, mr: 3 }; const controlContainerStyle = { className: 'control', color: 'myGray.400', display: ['flex', 'none'], pl: 1, mt: 2 }; const MessageCardStyle: BoxProps = { px: 4, py: 3, borderRadius: '0 8px 8px 8px', boxShadow: '0 0 8px rgba(0,0,0,0.15)' }; const messageCardMaxW = ['calc(100% - 25px)', 'calc(100% - 40px)']; const showEmpty = useMemo( () => feConfigs?.show_emptyChat && showEmptyIntro && chatHistory.length === 0 && !variableModules?.length && !welcomeText, [chatHistory.length, showEmptyIntro, variableModules, welcomeText] ); useEffect(() => { return () => { controller.current?.abort(); // close voice cancelBroadcast(); }; }, [router.query]); useEffect(() => { const listen = () => { cancelBroadcast(); }; window.addEventListener('beforeunload', listen); return () => { window.removeEventListener('beforeunload', listen); }; }, []); return ( {showEmpty && } {!!welcomeText && ( {/* avatar */} {/* message */} { const val = e?.data; if (e?.event !== EventNameEnum.guideClick || !val) return; handleSubmit((data) => sendPrompt(data, val))(); }} /> )} {/* variable input */} {!!variableModules?.length && ( {/* avatar */} {/* message */} {variableModules.map((item) => ( {item.label} {item.type === VariableInputEnum.input && ( )} {item.type === VariableInputEnum.select && ( ({ label: item.value, value: item.value }))} value={getValues(item.key)} onchange={(e) => { setValue(item.key, e); setRefresh(!refresh); }} /> )} ))} {!variableIsFinish && ( )} )} {/* chat history */} {chatHistory.map((item, index) => ( {item.obj === 'Human' && ( <> onclickCopy(item.value)} /> {onDelMessage && ( { setChatHistory((state) => state.filter((chat) => chat._id !== item._id) ); onDelMessage({ contentId: item._id, index }); }} /> )} {item.value} )} {item.obj === 'AI' && ( <> onclickCopy(item.value)} /> {onDelMessage && ( { setChatHistory((state) => state.filter((chat) => chat._id !== item._id) ); onDelMessage({ contentId: item._id, index }); }} /> )} {hasVoiceApi && ( voiceBroadcast({ text: item.value })} /> )} )} ))} {/* input */} {variableIsFinish ? ( {/* 输入框 */}