diff --git a/client/src/api/response/chat.d.ts b/client/src/api/response/chat.d.ts index 7b458c07a..6ce97c30c 100644 --- a/client/src/api/response/chat.d.ts +++ b/client/src/api/response/chat.d.ts @@ -19,6 +19,7 @@ export interface InitChatResponse { export interface InitShareChatResponse { maxContext: number; userAvatar: string; + appId: string; model: { name: string; avatar: string; diff --git a/client/src/components/Icon/icons/appStore.svg b/client/src/components/Icon/icons/fill/app.svg similarity index 100% rename from client/src/components/Icon/icons/appStore.svg rename to client/src/components/Icon/icons/fill/app.svg diff --git a/client/src/components/Icon/icons/fill/back.svg b/client/src/components/Icon/icons/fill/back.svg new file mode 100644 index 000000000..5064057b0 --- /dev/null +++ b/client/src/components/Icon/icons/fill/back.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/components/Icon/icons/fill/chat.svg b/client/src/components/Icon/icons/fill/chat.svg new file mode 100644 index 000000000..042a434f5 --- /dev/null +++ b/client/src/components/Icon/icons/fill/chat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/components/Icon/icons/fill/db.svg b/client/src/components/Icon/icons/fill/db.svg new file mode 100644 index 000000000..0cba56612 --- /dev/null +++ b/client/src/components/Icon/icons/fill/db.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/components/Icon/icons/fill/me.svg b/client/src/components/Icon/icons/fill/me.svg new file mode 100644 index 000000000..e9f4226c5 --- /dev/null +++ b/client/src/components/Icon/icons/fill/me.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/components/Icon/icons/git.svg b/client/src/components/Icon/icons/git.svg index 665bda378..dc921e04d 100644 --- a/client/src/components/Icon/icons/git.svg +++ b/client/src/components/Icon/icons/git.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/client/src/components/Icon/icons/kb.svg b/client/src/components/Icon/icons/kb.svg deleted file mode 100644 index b5b7e8e86..000000000 --- a/client/src/components/Icon/icons/kb.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/client/src/components/Icon/icons/light/app.svg b/client/src/components/Icon/icons/light/app.svg new file mode 100644 index 000000000..852db438a --- /dev/null +++ b/client/src/components/Icon/icons/light/app.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/components/Icon/icons/light/appApi.svg b/client/src/components/Icon/icons/light/appApi.svg new file mode 100644 index 000000000..e906d9b3a --- /dev/null +++ b/client/src/components/Icon/icons/light/appApi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/components/Icon/icons/light/chat.svg b/client/src/components/Icon/icons/light/chat.svg index c2561573d..314547a0e 100644 --- a/client/src/components/Icon/icons/light/chat.svg +++ b/client/src/components/Icon/icons/light/chat.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/client/src/components/Icon/icons/light/clear.svg b/client/src/components/Icon/icons/light/clear.svg new file mode 100644 index 000000000..06515cad5 --- /dev/null +++ b/client/src/components/Icon/icons/light/clear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/components/Icon/icons/light/db.svg b/client/src/components/Icon/icons/light/db.svg new file mode 100644 index 000000000..b784f35dd --- /dev/null +++ b/client/src/components/Icon/icons/light/db.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/components/Icon/icons/light/me.svg b/client/src/components/Icon/icons/light/me.svg new file mode 100644 index 000000000..9147dba37 --- /dev/null +++ b/client/src/components/Icon/icons/light/me.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/components/Icon/icons/light/overview.svg b/client/src/components/Icon/icons/light/overview.svg new file mode 100644 index 000000000..fcfb64a38 --- /dev/null +++ b/client/src/components/Icon/icons/light/overview.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/components/Icon/icons/light/setting.svg b/client/src/components/Icon/icons/light/setting.svg new file mode 100644 index 000000000..a4de19777 --- /dev/null +++ b/client/src/components/Icon/icons/light/setting.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/components/Icon/icons/light/share.svg b/client/src/components/Icon/icons/light/share.svg new file mode 100644 index 000000000..414ae060c --- /dev/null +++ b/client/src/components/Icon/icons/light/share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/components/Icon/index.tsx b/client/src/components/Icon/index.tsx index 895fe49cf..61f5f2181 100644 --- a/client/src/components/Icon/index.tsx +++ b/client/src/components/Icon/index.tsx @@ -6,15 +6,14 @@ const map = { model: require('./icons/model.svg').default, copy: require('./icons/copy.svg').default, chatSend: require('./icons/chatSend.svg').default, - user: require('./icons/user.svg').default, delete: require('./icons/delete.svg').default, withdraw: require('./icons/withdraw.svg').default, stop: require('./icons/stop.svg').default, collectionLight: require('./icons/collectionLight.svg').default, collectionSolid: require('./icons/collectionSolid.svg').default, - chat: require('./icons/chat.svg').default, empty: require('./icons/empty.svg').default, back: require('./icons/back.svg').default, + backFill: require('./icons/fill/back.svg').default, more: require('./icons/more.svg').default, tabbarChat: require('./icons/phoneTabbar/chat.svg').default, tabbarModel: require('./icons/phoneTabbar/model.svg').default, @@ -24,8 +23,6 @@ const map = { wx: require('./icons/wx.svg').default, out: require('./icons/out.svg').default, git: require('./icons/git.svg').default, - kb: require('./icons/kb.svg').default, - appStore: require('./icons/appStore.svg').default, menu: require('./icons/menu.svg').default, edit: require('./icons/edit.svg').default, inform: require('./icons/inform.svg').default, @@ -37,7 +34,19 @@ const map = { apikey: require('./icons/apikey.svg').default, save: require('./icons/save.svg').default, minus: require('./icons/minus.svg').default, - chatLight: require('./icons/light/chat.svg').default + chatLight: require('./icons/light/chat.svg').default, + chatFill: require('./icons/fill/chat.svg').default, + clearLight: require('./icons/light/clear.svg').default, + apiLight: require('./icons/light/appApi.svg').default, + overviewLight: require('./icons/light/overview.svg').default, + settingLight: require('./icons/light/setting.svg').default, + shareLight: require('./icons/light/share.svg').default, + dbLight: require('./icons/light/db.svg').default, + dbFill: require('./icons/fill/db.svg').default, + appLight: require('./icons/light/app.svg').default, + appFill: require('./icons/fill/app.svg').default, + meLight: require('./icons/light/me.svg').default, + meFill: require('./icons/fill/me.svg').default }; export type IconName = keyof typeof map; diff --git a/client/src/components/Layout/index.tsx b/client/src/components/Layout/index.tsx index 27f3f9601..803f99831 100644 --- a/client/src/components/Layout/index.tsx +++ b/client/src/components/Layout/index.tsx @@ -61,16 +61,16 @@ const Layout = ({ children }: { children: JSX.Element }) => { return ( <> - + {pcUnShowLayoutRoute[router.pathname] ? ( {children} ) : ( <> - + - + {children} diff --git a/client/src/components/Layout/navbar.tsx b/client/src/components/Layout/navbar.tsx index f22d1f24a..dbd0b6007 100644 --- a/client/src/components/Layout/navbar.tsx +++ b/client/src/components/Layout/navbar.tsx @@ -22,31 +22,36 @@ const Navbar = ({ unread }: { unread: number }) => { () => [ { label: '聊天', - icon: 'chat', + icon: 'chatLight', + activeIcon: 'chatFill', link: `/chat?appId=${lastChatModelId}&chatId=${lastChatId}`, activeLink: ['/chat'] }, { label: '应用', - icon: 'model', + icon: 'tabbarModel', + activeIcon: 'model', link: `/app/list`, activeLink: ['/app/list', '/app/detail'] }, { label: '知识库', - icon: 'kb', + icon: 'dbLight', + activeIcon: 'dbFill', link: `/kb`, activeLink: ['/kb'] }, { label: '市场', - icon: 'appStore', + icon: 'appLight', + activeIcon: 'appFill', link: '/appStore', activeLink: ['/appStore'] }, { label: '账号', - icon: 'user', + icon: 'meLight', + activeIcon: 'meFill', link: '/number', activeLink: ['/number'] } @@ -65,7 +70,7 @@ const Navbar = ({ unread }: { unread: number }) => { h: '54px', borderRadius: 'md', _hover: { - color: '#ffffff' + bg: 'myWhite.600' } }; @@ -74,17 +79,17 @@ const Navbar = ({ unread }: { unread: number }) => { flexDirection={'column'} alignItems={'center'} pt={6} - backgroundImage={'linear-gradient(to bottom right,#465069,#000000)'} + bg={'white'} h={'100%'} w={'100%'} - boxShadow={'4px 0px 4px 0px rgba(43, 45, 55, 0.01)'} + boxShadow={'2px 0px 8px 0px rgba(0,0,0,0.1)'} userSelect={'none'} > {/* logo */} router.push('/number')} @@ -101,15 +106,24 @@ const Navbar = ({ unread }: { unread: number }) => { {...itemStyles} {...(item.activeLink.includes(router.pathname) ? { - color: '#ffffff ', - backgroundImage: 'linear-gradient(to bottom right, #2152d9 0%, #4e83fd 100%)' + color: 'myBlue.700', + bg: 'white !important', + boxShadow: '1px 1px 10px rgba(0,0,0,0.2)' } : { - color: '#9096a5', + color: 'myGray.500', backgroundColor: 'transparent' })} > - + {item.label} diff --git a/client/src/components/PageContainer/index.tsx b/client/src/components/PageContainer/index.tsx index 0745de257..8959e0516 100644 --- a/client/src/components/PageContainer/index.tsx +++ b/client/src/components/PageContainer/index.tsx @@ -4,7 +4,7 @@ import { Box, useTheme, type BoxProps } from '@chakra-ui/react'; const PageContainer = ({ children, ...props }: BoxProps) => { const theme = useTheme(); return ( - + - + {item.label} ))} diff --git a/client/src/hooks/useChat.module.scss b/client/src/hooks/useChat.module.scss new file mode 100644 index 000000000..477d13f08 --- /dev/null +++ b/client/src/hooks/useChat.module.scss @@ -0,0 +1,30 @@ +.stopIcon { + animation: zoomStopIcon 0.4s infinite alternate; +} +@keyframes zoomStopIcon { + 0% { + transform: scale(0.8); + } + 100% { + transform: scale(1.2); + } +} + +.newChat { + .modelListContainer { + height: 0; + overflow: hidden; + } + .modelList { + border-radius: 6px; + } + &:hover { + .modelListContainer { + height: 60vh; + } + .modelList { + box-shadow: 0 0 5px rgba($color: #000000, $alpha: 0.05); + border: 1px solid #dee0e2; + } + } +} diff --git a/client/src/hooks/useChat.tsx b/client/src/hooks/useChat.tsx new file mode 100644 index 000000000..9ef982f8f --- /dev/null +++ b/client/src/hooks/useChat.tsx @@ -0,0 +1,367 @@ +import { useCallback, useRef, useState, useEffect, useMemo } from 'react'; +import { throttle } from 'lodash'; +import { ChatSiteItemType } from '@/types/chat'; +import { useToast } from './useToast'; +import { useCopyData } from '@/utils/tools'; +import { Box, Card, Flex, Textarea } from '@chakra-ui/react'; +import { useUserStore } from '@/store/user'; +import { useRouter } from 'next/router'; + +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 styles from './useChat.module.scss'; +import { adaptChatItem_openAI } from '@/utils/plugin/openai'; +import { streamFetch } from '@/api/fetch'; + +const textareaMinH = '22px'; + +export const useChat = ({ appId }: { appId: string }) => { + const router = useRouter(); + const ChatBoxParentRef = useRef(null); + const TextareaDom = useRef(null); + + // stop chat + const controller = useRef(new AbortController()); + const isLeavePage = useRef(false); + + const [chatHistory, setChatHistory] = useState([]); + const { toast } = useToast(); + const { copyData } = useCopyData(); + const { userInfo } = useUserStore(); + + const isChatting = useMemo( + () => chatHistory[chatHistory.length - 1]?.status === 'loading', + [chatHistory] + ); + const isLargeWidth = + ChatBoxParentRef?.current?.clientWidth && ChatBoxParentRef?.current?.clientWidth > 900; + + // 滚动到底部 + const scrollToBottom = useCallback((behavior: 'smooth' | 'auto' = 'smooth') => { + if (!ChatBoxParentRef.current) return; + console.log(ChatBoxParentRef.current.scrollHeight); + + ChatBoxParentRef.current.scrollTo({ + top: ChatBoxParentRef.current.scrollHeight, + behavior + }); + }, []); + + // 聊天信息生成中……获取当前滚动条位置,判断是否需要滚动到底部 + // eslint-disable-next-line react-hooks/exhaustive-deps + const generatingMessage = useCallback( + throttle(() => { + if (!ChatBoxParentRef.current) return; + const isBottom = + ChatBoxParentRef.current.scrollTop + ChatBoxParentRef.current.clientHeight + 150 >= + ChatBoxParentRef.current.scrollHeight; + + isBottom && scrollToBottom('auto'); + }, 100), + [] + ); + + // 复制内容 + const onclickCopy = useCallback( + (value: string) => { + const val = value.replace(/\n+/g, '\n'); + copyData(val); + }, + [copyData] + ); + + // 重置输入内容 + const resetInputVal = useCallback((val: string) => { + if (!TextareaDom.current) return; + TextareaDom.current.value = val; + setTimeout(() => { + /* 回到最小高度 */ + if (TextareaDom.current) { + TextareaDom.current.style.height = + val === '' ? textareaMinH : `${TextareaDom.current.scrollHeight}px`; + } + }, 100); + }, []); + + const startChat = useCallback( + async (prompts: ChatSiteItemType[]) => { + // create abort obj + const abortSignal = new AbortController(); + controller.current = abortSignal; + isLeavePage.current = false; + + const messages = adaptChatItem_openAI({ messages: prompts, reserveId: true }); + + // 流请求,获取数据 + await streamFetch({ + data: { + messages, + appId, + model: '' + }, + onMessage: (text: string) => { + setChatHistory((state) => + state.map((item, index) => { + if (index !== state.length - 1) return item; + return { + ...item, + value: item.value + text + }; + }) + ); + generatingMessage(); + }, + abortSignal + }); + + // 重置了页面,说明退出了当前聊天, 不缓存任何内容 + if (isLeavePage.current) { + return; + } + + // 设置聊天内容为完成状态 + setChatHistory((state) => + state.map((item, index) => { + if (index !== state.length - 1) return item; + return { + ...item, + status: 'finish' + }; + }) + ); + + setTimeout(() => { + generatingMessage(); + TextareaDom.current?.focus(); + }, 100); + }, + [appId, generatingMessage] + ); + + /** + * user confirm send prompt + */ + const sendPrompt = useCallback(async () => { + 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 { + await startChat(newChatList); + } 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, resetInputVal, toast, scrollToBottom, startChat]); + + const ChatBox = useCallback( + ({ appAvatar }: { appAvatar: string }) => { + return ( + + {chatHistory.map((item, index) => ( + + {item.obj === 'Human' && } + {/* avatar */} + + {/* message */} + + {item.obj === 'AI' ? ( + + + + + + ) : ( + + + {item.value} + + + )} + + + ))} + + ); + }, + [chatHistory, isChatting, userInfo?.avatar] + ); + + const ChatInput = useCallback(() => { + return ( + + + {/* 输入框 */} +