From eb768d9c0485cf4ed68349d372d4634b063928b1 Mon Sep 17 00:00:00 2001 From: archer <545436317@qq.com> Date: Tue, 11 Jul 2023 15:57:01 +0800 Subject: [PATCH] chat box --- client/public/imgs/module/userGuide.png | Bin 0 -> 1215 bytes client/src/api/chat.ts | 10 +- client/src/api/fetch.ts | 13 +- client/src/api/response/chat.d.ts | 9 +- .../ChatBox/index.module.scss} | 0 client/src/components/ChatBox/index.tsx | 601 ++++++++++++++++++ client/src/components/Icon/icons/copy.svg | 2 +- .../components/Icon/icons/light/setTop.svg | 1 + .../Icon/icons/modules/variable.svg | 1 + .../Icon/icons/modules/welcomeText.svg | 1 + client/src/components/Icon/icons/voice.svg | 1 + client/src/components/Icon/index.tsx | 13 +- client/src/components/MyTooltip/index.tsx | 1 + client/src/components/Select/index.tsx | 10 +- client/src/components/Tag/index.tsx | 4 +- client/src/constants/app.ts | 7 + client/src/constants/flow/ModuleTemplate.ts | 23 +- client/src/constants/flow/index.ts | 3 +- client/src/constants/theme.ts | 3 +- client/src/hooks/useChat.tsx | 367 ----------- client/src/pages/api/chat/chatTest.ts | 77 +++ client/src/pages/api/chat/shareChat/create.ts | 17 +- client/src/pages/api/chat/shareChat/init.ts | 32 +- client/src/pages/api/chat/shareChat/list.ts | 12 +- .../src/pages/api/openapi/modules/chat/gpt.ts | 1 + .../pages/api/openapi/v1/chat/completions2.ts | 42 +- .../src/pages/app/detail/components/API.tsx | 6 +- .../pages/app/detail/components/Settings.tsx | 8 +- .../src/pages/app/detail/components/Share.tsx | 26 +- .../components/edit/components/ChatTest.tsx | 201 ++++-- .../edit/components/NodeKbSearch.tsx | 8 +- .../edit/components/NodeUserGuide.tsx | 339 ++++++++++ .../edit/components/TemplateList.tsx | 8 +- .../edit/components/modules/NodeCard.tsx | 18 +- .../edit/components/render/RenderInput.tsx | 2 +- .../app/detail/components/edit/index.tsx | 30 +- client/src/pages/app/detail/index.tsx | 8 +- .../chat/components/ChatHistorySlider.tsx | 160 +++++ .../pages/chat/components/ShareHistory.tsx | 212 ------ client/src/pages/chat/share.tsx | 595 +++++++---------- client/src/service/models/shareChat.ts | 11 +- client/src/service/utils/auth.ts | 19 +- client/src/store/chat.ts | 133 +--- client/src/store/shareChat.ts | 146 +++++ client/src/types/app.d.ts | 24 +- client/src/types/chat.d.ts | 20 +- client/src/types/mongoSchema.d.ts | 4 +- 47 files changed, 1949 insertions(+), 1280 deletions(-) create mode 100644 client/public/imgs/module/userGuide.png rename client/src/{hooks/useChat.module.scss => components/ChatBox/index.module.scss} (100%) create mode 100644 client/src/components/ChatBox/index.tsx create mode 100644 client/src/components/Icon/icons/light/setTop.svg create mode 100644 client/src/components/Icon/icons/modules/variable.svg create mode 100644 client/src/components/Icon/icons/modules/welcomeText.svg create mode 100644 client/src/components/Icon/icons/voice.svg delete mode 100644 client/src/hooks/useChat.tsx create mode 100644 client/src/pages/api/chat/chatTest.ts create mode 100644 client/src/pages/app/detail/components/edit/components/NodeUserGuide.tsx create mode 100644 client/src/pages/chat/components/ChatHistorySlider.tsx delete mode 100644 client/src/pages/chat/components/ShareHistory.tsx create mode 100644 client/src/store/shareChat.ts diff --git a/client/public/imgs/module/userGuide.png b/client/public/imgs/module/userGuide.png new file mode 100644 index 0000000000000000000000000000000000000000..598f9ed5eec4b751bba24406784a560925351e33 GIT binary patch literal 1215 zcmV;w1VHPx(c}YY;RCr$Pn_W`dFc5%OHka^I!$}|yP;LQv>@=JKat6}rBjFbOJdmEGe)A=2 zt0UPVwzalaE7`2iWvy;MK@Q#3{JuADG;$-CC|58;uD4#3qW@Da|U_-_-O;jEFz8< z93SBfAiH`vheEt_i4XW(3Q;_Fh2R9>fCsrfp+cDa@P(~IQ1eL6$aje$dwF#((8W!bP4{^XTymH+}JKFRCS467vF2!Qf{ zwY_wVFh&E6M2O1j1fQUxw97o(W^DGx#d3BBqDqVJv3nGs3ep;-gjf&r4kY((~T{pfvt1pufSlcEbXbW-IU z07%%1KoI~p<1zyDXKBb$Ho7d?7S;0 z`glvgAp`X8dl(Bq%+qzyJ9#uO@d&ZDegpx4nWwGcuJ7@@WB>~MfpRY)HR z9<4V|c5i14k$ptmA+g3=c{>-7dyIrmkk6Cr%p<)DnblWXck z9Gem_A^>7x;Wlt`<-ZwXCn!|Qp~^YT*-lh#hAQgpqi}YDLIp>aV~k(*y#&Db1MHog zmjfj2F|0&%->VZL^z_B50f23uRL4e@BlOkC-)l!=?8{JQf@ z@c(kOy?TNikT@^N(H=0O3UZOb4T+--g5MBQD}r#BT6_Q;g(dg`s6n{4=K3LK zEVPag05pX1l^IpJ;Z~UydO)uFLIEft9N0@!6c`}^?An`@H4FjZ1H{m8j8bOUCNm^} z28hpmk+KiM7yueeLsf{DFzRoK+vaJg@6p3m)tL3AsSrIXeMR+ptrTZk=e}}e*l1Ff d?{>Xj`xmSFFqv0@P^AC>002ovPDHLkV1g{#E */ 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 ? ( + + + {/* 输入框 */} +