diff --git a/client/public/imgs/module/userGuide.png b/client/public/imgs/module/userGuide.png new file mode 100644 index 000000000..598f9ed5e Binary files /dev/null and b/client/public/imgs/module/userGuide.png differ diff --git a/client/src/api/chat.ts b/client/src/api/chat.ts index 8521ca13e..2a56b9b53 100644 --- a/client/src/api/chat.ts +++ b/client/src/api/chat.ts @@ -58,15 +58,15 @@ export const putChatHistory = (data: UpdateHistoryProps) => */ export const createShareChat = ( data: ShareChatEditType & { - modelId: string; + appId: string; } ) => POST(`/chat/shareChat/create`, data); /** * get shareChat */ -export const getShareChatList = (modelId: string) => - GET(`/chat/shareChat/list?modelId=${modelId}`); +export const getShareChatList = (appId: string) => + GET(`/chat/shareChat/list`, { appId }); /** * delete a shareChat @@ -76,5 +76,5 @@ export const delShareChatById = (id: string) => DELETE(`/chat/shareChat/delete?i /** * 初始化分享聊天 */ -export const initShareChatInfo = (data: { shareId: string; password: string }) => - GET(`/chat/shareChat/init?${Obj2Query(data)}`); +export const initShareChatInfo = (data: { shareId: string }) => + GET(`/chat/shareChat/init`, data); diff --git a/client/src/api/fetch.ts b/client/src/api/fetch.ts index 39c0766c5..f64586ab2 100644 --- a/client/src/api/fetch.ts +++ b/client/src/api/fetch.ts @@ -1,18 +1,23 @@ -import { Props } from '@/pages/api/openapi/v1/chat/completions'; import { sseResponseEventEnum } from '@/constants/chat'; import { getErrText } from '@/utils/tools'; import { parseStreamChunk } from '@/utils/adapt'; interface StreamFetchProps { - data: Props; + url?: string; + data: Record; onMessage: (text: string) => void; abortSignal: AbortController; } -export const streamFetch = ({ data, onMessage, abortSignal }: StreamFetchProps) => +export const streamFetch = ({ + url = '/api/openapi/v1/chat/completions2', + data, + onMessage, + abortSignal +}: StreamFetchProps) => new Promise<{ responseText: string; errMsg: string; newChatId: string | null }>( async (resolve, reject) => { try { - const response = await window.fetch('/api/openapi/v1/chat/completions2', { + const response = await window.fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' diff --git a/client/src/api/response/chat.d.ts b/client/src/api/response/chat.d.ts index 6ce97c30c..4e5e3a16b 100644 --- a/client/src/api/response/chat.d.ts +++ b/client/src/api/response/chat.d.ts @@ -1,5 +1,6 @@ import type { ChatPopulate, AppSchema } from '@/types/mongoSchema'; import type { ChatItemType } from '@/types/chat'; +import { VariableItemType } from '@/types/app'; export interface InitChatResponse { chatId: string; @@ -17,13 +18,13 @@ export interface InitChatResponse { } export interface InitShareChatResponse { - maxContext: number; userAvatar: string; - appId: string; - model: { + maxContext: number; + app: { + variableModules?: VariableItemType[]; + welcomeText?: string; name: string; avatar: string; intro: string; }; - chatModel: AppSchema['chat']['chatModel']; // 对话模型名 } diff --git a/client/src/hooks/useChat.module.scss b/client/src/components/ChatBox/index.module.scss similarity index 100% rename from client/src/hooks/useChat.module.scss rename to client/src/components/ChatBox/index.module.scss diff --git a/client/src/components/ChatBox/index.tsx b/client/src/components/ChatBox/index.tsx new file mode 100644 index 000000000..d6ad380d4 --- /dev/null +++ b/client/src/components/ChatBox/index.tsx @@ -0,0 +1,601 @@ +import React, { + useCallback, + useRef, + useState, + useMemo, + forwardRef, + useImperativeHandle, + ForwardedRef +} from 'react'; +import { throttle } from 'lodash'; +import { ChatSiteItemType } from '@/types/chat'; +import { useToast } from '@/hooks/useToast'; +import { useCopyData, voiceBroadcast, hasVoiceApi } 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 } 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 styles from './index.module.scss'; +import MyTooltip from '../MyTooltip'; + +const textareaMinH = '22px'; +export type StartChatFnProps = { + messages: MessageItemType[]; + controller: AbortController; + variables: Record; + generatingMessage: (text: string) => void; +}; + +export type ComponentRef = { + resetVariables: (data?: Record) => void; + resetHistory: (history: ChatSiteItemType[]) => void; +}; + +const VariableLabel = ({ + required = false, + children +}: { + required?: boolean; + children: React.ReactNode | string; +}) => ( + + {children} + {required && ( + + * + + )} + +); + +const ChatBox = ( + { + appAvatar, + variableModules, + welcomeText, + onUpdateVariable, + onStartChat, + onDelMessage + }: { + appAvatar: string; + variableModules?: VariableItemType[]; + welcomeText?: string; + onUpdateVariable?: (e: Record) => void; + onStartChat: (e: StartChatFnProps) => Promise<{ responseText: string }>; + onDelMessage?: (e: { id?: 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 [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 isLargeWidth = ChatBoxRef?.current?.clientWidth && ChatBoxRef?.current?.clientWidth >= 900; + + const { register, reset, 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 }); + + await onStartChat({ + messages, + controller: abortSignal, + generatingMessage, + variables: data + }); + + // 设置聊天内容为完成状态 + setChatHistory((state) => + state.map((item, index) => { + if (index !== state.length - 1) return item; + return { + ...item, + status: 'finish' + }; + }) + ); + + setTimeout(() => { + generatingScroll(); + TextareaDom.current?.focus(); + }, 100); + } catch (err: any) { + toast({ + title: typeof err === 'string' ? err : err?.message || '聊天出错了~', + status: 'warning', + duration: 5000, + isClosable: true + }); + + resetInputVal(value); + + setChatHistory(newChatList.slice(0, newChatList.length - 2)); + } + }, + [ + isChatting, + chatHistory, + setChatHistory, + resetInputVal, + toast, + scrollToBottom, + onStartChat, + generatingMessage, + generatingScroll + ] + ); + + useImperativeHandle(ref, () => ({ + resetVariables(e) { + const defaultVal: Record = {}; + variableModules?.forEach((item) => { + defaultVal[item.key] = ''; + }); + + reset(e || defaultVal); + setVariables(e || defaultVal); + }, + resetHistory(e) { + setChatHistory(e); + } + })); + + 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', + display: ['flex', 'none'], + color: 'myGray.400', + pl: 1, + mt: 2, + position: 'absolute' as any, + zIndex: 1 + }; + + return ( + + + {/* variable input */} + {(variableModules || welcomeText) && ( + + {/* avatar */} + + {/* message */} + + + {welcomeText && ( + + {welcomeText} + + )} + {variableModules && ( + + {variableModules.map((item) => ( + + {item.label} + {item.type === VariableInputEnum.input && ( + + )} + {item.type === VariableInputEnum.select && ( + ({ + label: item.value, + value: item.value + }))} + {...register(item.key, { + required: item.required + })} + onchange={(e) => { + setValue(item.key, e); + // setRefresh((state) => !state); + }} + /> + )} + + ))} + {!variableIsFinish && ( + + )} + + )} + + + + )} + {/* chat history */} + + {chatHistory.map((item, index) => ( + + {item.obj === 'Human' && } + {/* avatar */} + + {/* message */} + + {item.obj === 'AI' ? ( + + + + + + + onclickCopy(item.value)} + /> + + {onDelMessage && ( + + { + setChatHistory((state) => + state.filter((chat) => chat._id !== item._id) + ); + onDelMessage({ + id: item._id, + index + }); + }} + /> + + )} + {hasVoiceApi && ( + + voiceBroadcast({ text: item.value })} + /> + + )} + + + ) : ( + + + {item.value} + + + + onclickCopy(item.value)} + /> + + {onDelMessage && ( + + { + setChatHistory((state) => + state.filter((chat) => chat._id !== item._id) + ); + onDelMessage({ + id: item._id, + index + }); + }} + /> + + )} + + + )} + + + ))} + + + {variableIsFinish ? ( + + + {/* 输入框 */} +