import React, { useCallback, useRef, useState, useMemo, forwardRef, useImperativeHandle, ForwardedRef } from 'react'; import { throttle } from 'lodash'; import { ChatItemType, ChatSiteItemType, ExportChatType } from '@/types/chat'; import { useToast } from '@/hooks/useToast'; import { useCopyData, voiceBroadcast, hasVoiceApi, getErrText } from '@/utils/tools'; import { Box, Card, Flex, Input, Textarea, Button, useTheme } from '@chakra-ui/react'; import { useUserStore } from '@/store/user'; import { Types } from 'mongoose'; import { HUMAN_ICON, quoteLenKey, rawSearchKey } from '@/constants/chat'; import Markdown from '@/components/Markdown'; import MyIcon from '@/components/Icon'; import Avatar from '@/components/Avatar'; import { adaptChatItem_openAI } from '@/utils/plugin/openai'; import { VariableItemType } from '@/types/app'; import { VariableInputEnum } from '@/constants/app'; import { useForm } from 'react-hook-form'; import MySelect from '@/components/Select'; import { MessageItemType } from '@/pages/api/openapi/v1/chat/completions'; import MyTooltip from '../MyTooltip'; import { fileDownload } from '@/utils/file'; import { htmlTemplate } from '@/constants/common'; import dynamic from 'next/dynamic'; const QuoteModal = dynamic(() => import('./QuoteModal')); import styles from './index.module.scss'; import { QuoteItemType } from '@/pages/api/app/modules/kb/search'; 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: (history: ChatSiteItemType[]) => void; scrollToBottom: (behavior?: 'smooth' | 'auto') => void; }; const VariableLabel = ({ required = false, children }: { required?: boolean; children: React.ReactNode | string; }) => ( {children} {required && ( * )} ); const ChatBox = ( { historyId, appAvatar, variableModules, welcomeText, onUpdateVariable, onStartChat, onDelMessage }: { historyId?: string; appAvatar: string; variableModules?: VariableItemType[]; welcomeText?: string; onUpdateVariable?: (e: Record) => void; onStartChat: ( e: StartChatFnProps ) => Promise<{ responseText?: string; rawSearch?: QuoteItemType[] }>; onDelMessage?: (e: { contentId?: string; index: number }) => void; }, ref: ForwardedRef ) => { const ChatBoxRef = useRef(null); const theme = useTheme(); const { copyData } = useCopyData(); const { toast } = useToast(); const { userInfo } = useUserStore(); const TextareaDom = useRef(null); const controller = useRef(new AbortController()); const [refresh, setRefresh] = useState(false); const [variables, setVariables] = useState>({}); const [chatHistory, setChatHistory] = useState([]); const [quoteModalData, setQuoteModalData] = useState<{ contentId?: string; rawSearch?: QuoteItemType[]; }>(); 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 isLargeWidth = ChatBoxRef?.current?.clientWidth && ChatBoxRef?.current?.clientWidth >= 900; 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 = {}) => { if (isChatting) { toast({ title: '正在聊天中...请等待结束', status: 'warning' }); return; } // get input value const value = TextareaDom.current?.value || ''; const val = value.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 { rawSearch } = 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', rawSearch }; }) ); setTimeout(() => { generatingScroll(); TextareaDom.current?.focus(); }, 100); } catch (err: any) { toast({ title: getErrText(err, '聊天出错了~'), status: 'error', duration: 5000, isClosable: true }); if (!err?.responseText) { resetInputVal(value); 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, setChatHistory, resetInputVal, toast, scrollToBottom, onStartChat, generatingMessage, generatingScroll ] ); 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, position: 'absolute' as any, zIndex: 1, w: '100%' }; const hasVariableInput = useMemo( () => variableModules || welcomeText, [variableModules, welcomeText] ); return ( {/* variable input */} {hasVariableInput && ( {/* avatar */} {/* message */} {welcomeText && ( {welcomeText} )} {variableModules && ( {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' && } {/* avatar */} {/* message */} {item.obj === 'AI' ? ( {(!!item[quoteLenKey] || !!item[rawSearchKey]?.length) && ( )} onclickCopy(item.value)} /> {onDelMessage && ( { setChatHistory((state) => state.filter((chat) => chat._id !== item._id) ); onDelMessage({ contentId: item._id, index }); }} /> )} {hasVoiceApi && ( voiceBroadcast({ text: item.value })} /> )} ) : ( {item.value} onclickCopy(item.value)} /> {onDelMessage && ( { setChatHistory((state) => state.filter((chat) => chat._id !== item._id) ); onDelMessage({ contentId: item._id, index }); }} /> )} )} ))} {/* input */} {variableIsFinish ? ( {/* 输入框 */}