diff --git a/client/src/api/app.ts b/client/src/api/app.ts index 284769378..c8cfdc54e 100644 --- a/client/src/api/app.ts +++ b/client/src/api/app.ts @@ -1,14 +1,13 @@ import { GET, POST, DELETE, PUT } from './request'; import type { AppSchema } from '@/types/mongoSchema'; -import type { AppModuleItemType, AppUpdateParams } from '@/types/app'; +import type { AppListItemType, AppUpdateParams } from '@/types/app'; import { RequestPaging } from '../types/index'; -import type { AppListResponse } from './response/app'; import type { Props as CreateAppProps } from '@/pages/api/app/create'; /** * 获取模型列表 */ -export const getMyModels = () => GET('/app/list'); +export const getMyModels = () => GET('/app/myApps'); /** * 创建一个模型 @@ -18,12 +17,12 @@ export const postCreateApp = (data: CreateAppProps) => POST('/app/create /** * 根据 ID 删除模型 */ -export const delModelById = (id: string) => DELETE(`/app/del?modelId=${id}`); +export const delModelById = (id: string) => DELETE(`/app/del?appId=${id}`); /** * 根据 ID 获取模型 */ -export const getModelById = (id: string) => GET(`/app/detail?modelId=${id}`); +export const getModelById = (id: string) => GET(`/app/detail?appId=${id}`); /** * 根据 ID 更新模型 @@ -41,5 +40,5 @@ export const getShareModelList = (data: { searchText?: string } & RequestPaging) /** * 收藏/取消收藏模型 */ -export const triggerModelCollection = (modelId: string) => - POST(`/app/share/collection?modelId=${modelId}`); +export const triggerModelCollection = (appId: string) => + POST(`/app/share/collection?appId=${appId}`); diff --git a/client/src/api/chat.ts b/client/src/api/chat.ts index 2a56b9b53..15d704a2f 100644 --- a/client/src/api/chat.ts +++ b/client/src/api/chat.ts @@ -1,5 +1,5 @@ import { GET, POST, DELETE, PUT } from './request'; -import type { HistoryItemType } from '@/types/chat'; +import type { ChatHistoryItemType } from '@/types/chat'; import type { InitChatResponse, InitShareChatResponse } from './response/chat'; import { RequestPaging } from '../types/index'; import type { ShareChatSchema } from '@/types/mongoSchema'; @@ -11,14 +11,14 @@ import type { Props as UpdateHistoryProps } from '@/pages/api/chat/history/updat /** * 获取初始化聊天内容 */ -export const getInitChatSiteInfo = (modelId: '' | string, chatId: '' | string) => - GET(`/chat/init?modelId=${modelId}&chatId=${chatId}`); +export const getInitChatSiteInfo = (data: { appId: string; historyId?: string }) => + GET(`/chat/init`, data); /** * 获取历史记录 */ -export const getChatHistory = (data: RequestPaging) => - POST('/chat/history/getHistory', data); +export const getChatHistory = (data: RequestPaging & { appId?: string }) => + POST('/chat/history/getHistory', data); /** * 删除一条历史记录 @@ -44,8 +44,8 @@ export const updateHistoryQuote = (params: { /** * 删除一句对话 */ -export const delChatRecordByIndex = (chatId: string, contentId: string) => - DELETE(`/chat/delChatRecordByContentId?chatId=${chatId}&contentId=${contentId}`); +export const delChatRecordByIndex = (data: { historyId: string; contentId: string }) => + DELETE(`/chat/delChatRecordByContentId`, data); /** * 修改历史记录: 标题/置顶 diff --git a/client/src/api/fetch.ts b/client/src/api/fetch.ts index f64586ab2..28b625831 100644 --- a/client/src/api/fetch.ts +++ b/client/src/api/fetch.ts @@ -9,12 +9,12 @@ interface StreamFetchProps { abortSignal: AbortController; } export const streamFetch = ({ - url = '/api/openapi/v1/chat/completions2', + url = '/api/openapi/v1/chat/completions', data, onMessage, abortSignal }: StreamFetchProps) => - new Promise<{ responseText: string; errMsg: string; newChatId: string | null }>( + new Promise<{ responseText: string; errMsg: string; newHistoryId: string | null }>( async (resolve, reject) => { try { const response = await window.fetch(url, { @@ -43,7 +43,7 @@ export const streamFetch = ({ // response data let responseText = ''; let errMsg = ''; - const newChatId = response.headers.get('newChatId'); + const newHistoryId = response.headers.get('newHistoryId'); const read = async () => { try { @@ -53,7 +53,7 @@ export const streamFetch = ({ return resolve({ responseText, errMsg, - newChatId + newHistoryId }); } else { return reject('响应过程出现异常~'); @@ -85,7 +85,7 @@ export const streamFetch = ({ return resolve({ responseText, errMsg, - newChatId + newHistoryId }); } reject(getErrText(err, '请求异常')); diff --git a/client/src/api/request.ts b/client/src/api/request.ts index 8d8b64826..354716757 100644 --- a/client/src/api/request.ts +++ b/client/src/api/request.ts @@ -92,8 +92,8 @@ function request(url: string, data: any, config: ConfigType, method: Method): an baseURL: '/api', url, method, - data: method === 'GET' ? null : data, - params: method === 'GET' ? data : null, // get请求不携带data,params放在url上 + data: ['POST', 'PUT'].includes(method) ? data : null, + params: !['POST', 'PUT'].includes(method) ? data : null, ...config // 用户自定义配置,可以覆盖前面的配置 }) .then((res) => checkRes(res.data)) @@ -119,6 +119,6 @@ export function PUT(url: string, data = {}, config: ConfigType = {}): Promise return request(url, data, config, 'PUT'); } -export function DELETE(url: string, config: ConfigType = {}): Promise { - return request(url, {}, config, 'DELETE'); +export function DELETE(url: string, data = {}, config: ConfigType = {}): Promise { + return request(url, data, config, 'DELETE'); } diff --git a/client/src/api/response/chat.d.ts b/client/src/api/response/chat.d.ts index 4e5e3a16b..60e5bea30 100644 --- a/client/src/api/response/chat.d.ts +++ b/client/src/api/response/chat.d.ts @@ -1,19 +1,20 @@ -import type { ChatPopulate, AppSchema } from '@/types/mongoSchema'; +import type { AppSchema } from '@/types/mongoSchema'; import type { ChatItemType } from '@/types/chat'; import { VariableItemType } from '@/types/app'; export interface InitChatResponse { - chatId: string; - modelId: string; - systemPrompt?: string; - limitPrompt?: string; - model: { + historyId: string; + appId: string; + app: { + variableModules?: VariableItemType[]; + welcomeText?: string; name: string; avatar: string; intro: string; canUse: boolean; }; - chatModel: AppSchema['chat']['chatModel']; // 对话模型名 + title: string; + variables: Record; history: ChatItemType[]; } diff --git a/client/src/components/ChatBox/index.tsx b/client/src/components/ChatBox/index.tsx index d6ad380d4..d55ccd62e 100644 --- a/client/src/components/ChatBox/index.tsx +++ b/client/src/components/ChatBox/index.tsx @@ -38,8 +38,10 @@ export type StartChatFnProps = { }; export type ComponentRef = { + getChatHistory: () => ChatSiteItemType[]; resetVariables: (data?: Record) => void; resetHistory: (history: ChatSiteItemType[]) => void; + scrollToBottom: (behavior?: 'smooth' | 'auto') => void; }; const VariableLabel = ({ @@ -73,7 +75,7 @@ const ChatBox = ( welcomeText?: string; onUpdateVariable?: (e: Record) => void; onStartChat: (e: StartChatFnProps) => Promise<{ responseText: string }>; - onDelMessage?: (e: { id?: string; index: number }) => void; + onDelMessage?: (e: { contentId?: string; index: number }) => void; }, ref: ForwardedRef ) => { @@ -279,6 +281,7 @@ const ChatBox = ( ); useImperativeHandle(ref, () => ({ + getChatHistory: () => chatHistory, resetVariables(e) { const defaultVal: Record = {}; variableModules?.forEach((item) => { @@ -290,7 +293,8 @@ const ChatBox = ( }, resetHistory(e) { setChatHistory(e); - } + }, + scrollToBottom })); const controlIconStyle = { @@ -305,210 +309,215 @@ const ChatBox = ( }; const controlContainerStyle = { className: 'control', - display: ['flex', 'none'], + display: isChatting ? 'none' : ['flex', 'none'], color: 'myGray.400', pl: 1, mt: 2, position: 'absolute' as any, - zIndex: 1 + zIndex: 1, + w: '100%' }; 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' && } + + + {/* variable input */} + {(variableModules || welcomeText) && ( + {/* avatar */} {/* message */} - - {item.obj === 'AI' ? ( - - - - - - - onclickCopy(item.value)} - /> - - {onDelMessage && ( - - { - setChatHistory((state) => - state.filter((chat) => chat._id !== item._id) - ); - onDelMessage({ - id: item._id, - index - }); - }} - /> - + + + {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 && ( + )} - {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 - }); - }} - /> - - )} - - - )} - + + )} + + - ))} + )} + {/* 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({ + 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 + }); + }} + /> + + )} + + + )} + + + ))} + {variableIsFinish ? ( @@ -531,7 +540,6 @@ const ChatBox = ( _focusVisible={{ border: 'none' }} - isDisabled={isChatting} placeholder="提问" resize={'none'} rows={1} diff --git a/client/src/components/Layout/index.tsx b/client/src/components/Layout/index.tsx index 803f99831..2eeff4c3d 100644 --- a/client/src/components/Layout/index.tsx +++ b/client/src/components/Layout/index.tsx @@ -15,7 +15,8 @@ const pcUnShowLayoutRoute: Record = { '/': true, '/login': true, '/chat/share': true, - '/app/edit': true + '/app/edit': true, + '/chat': true }; const phoneUnShowLayoutRoute: Record = { '/': true, diff --git a/client/src/components/Layout/navbar.tsx b/client/src/components/Layout/navbar.tsx index dbd0b6007..7dfe824f7 100644 --- a/client/src/components/Layout/navbar.tsx +++ b/client/src/components/Layout/navbar.tsx @@ -17,14 +17,14 @@ export enum NavbarTypeEnum { const Navbar = ({ unread }: { unread: number }) => { const router = useRouter(); const { userInfo, lastModelId } = useUserStore(); - const { lastChatModelId, lastChatId } = useChatStore(); + const { lastChatAppId, lastChatId } = useChatStore(); const navbarList = useMemo( () => [ { label: '聊天', icon: 'chatLight', activeIcon: 'chatFill', - link: `/chat?appId=${lastChatModelId}&chatId=${lastChatId}`, + link: `/chat?appId=${lastChatAppId}&chatId=${lastChatId}`, activeLink: ['/chat'] }, { @@ -56,7 +56,7 @@ const Navbar = ({ unread }: { unread: number }) => { activeLink: ['/number'] } ], - [lastChatId, lastChatModelId] + [lastChatId, lastChatAppId] ); const itemStyles: any = { diff --git a/client/src/components/Layout/navbarPhone.tsx b/client/src/components/Layout/navbarPhone.tsx index fd41f3179..be9150490 100644 --- a/client/src/components/Layout/navbarPhone.tsx +++ b/client/src/components/Layout/navbarPhone.tsx @@ -7,13 +7,13 @@ import Badge from '../Badge'; const NavbarPhone = ({ unread }: { unread: number }) => { const router = useRouter(); - const { lastChatModelId, lastChatId } = useChatStore(); + const { lastChatAppId, lastChatId } = useChatStore(); const navbarList = useMemo( () => [ { label: '聊天', icon: 'tabbarChat', - link: `/chat?appId=${lastChatModelId}&chatId=${lastChatId}`, + link: `/chat?appId=${lastChatAppId}&chatId=${lastChatId}`, activeLink: ['/chat'], unread: 0 }, @@ -39,7 +39,7 @@ const NavbarPhone = ({ unread }: { unread: number }) => { unread } ], - [lastChatId, lastChatModelId, unread] + [lastChatId, lastChatAppId, unread] ); return ( diff --git a/client/src/components/SideBar/index.tsx b/client/src/components/SideBar/index.tsx index 6d3c50956..d244c21c6 100644 --- a/client/src/components/SideBar/index.tsx +++ b/client/src/components/SideBar/index.tsx @@ -7,7 +7,7 @@ interface Props extends BoxProps {} const SideBar = (e?: Props) => { const { - w = ['100%', '0 0 250px', '0 0 280px', '0 0 310px', '0 0 340px'], + w = ['100%', '0 0 250px', '0 0 270px', '0 0 290px', '0 0 310px'], children, ...props } = e || {}; diff --git a/client/src/components/Tag/index.tsx b/client/src/components/Tag/index.tsx index 53c7d4432..5fb9b161c 100644 --- a/client/src/components/Tag/index.tsx +++ b/client/src/components/Tag/index.tsx @@ -10,7 +10,7 @@ const Tag = ({ children, colorSchema = 'blue', ...props }: Props) => { const theme = useMemo(() => { const map = { blue: { - borderColor: 'myBlue.700', + borderColor: 'myBlue.600', bg: '#F2FBFF', color: 'myBlue.700' }, diff --git a/client/src/pages/api/app/del.ts b/client/src/pages/api/app/del.ts index 91bfcfdb0..3a6c47a96 100644 --- a/client/src/pages/api/app/del.ts +++ b/client/src/pages/api/app/del.ts @@ -7,9 +7,9 @@ import { authApp } from '@/service/utils/auth'; /* 获取我的模型 */ export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { - const { modelId } = req.query as { modelId: string }; + const { appId } = req.query as { appId: string }; - if (!modelId) { + if (!appId) { throw new Error('参数错误'); } @@ -20,28 +20,28 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< // 验证是否是该用户的 model await authApp({ - appId: modelId, + appId, userId }); // 删除对应的聊天 await Chat.deleteMany({ - modelId + appId }); // 删除收藏列表 await Collection.deleteMany({ - modelId + modelId: appId }); // 删除分享链接 await ShareChat.deleteMany({ - modelId + appId }); // 删除模型 await App.deleteOne({ - _id: modelId, + _id: appId, userId }); diff --git a/client/src/pages/api/app/detail.tsx b/client/src/pages/api/app/detail.tsx index 5b20d78cd..2ef3f0319 100644 --- a/client/src/pages/api/app/detail.tsx +++ b/client/src/pages/api/app/detail.tsx @@ -7,9 +7,9 @@ import { authApp } from '@/service/utils/auth'; /* 获取我的模型 */ export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { - const { modelId } = req.query as { modelId: string }; + const { appId } = req.query as { appId: string }; - if (!modelId) { + if (!appId) { throw new Error('参数错误'); } @@ -19,7 +19,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< await connectToDatabase(); const { app } = await authApp({ - appId: modelId, + appId, userId, authOwner: false }); diff --git a/client/src/pages/api/app/list.ts b/client/src/pages/api/app/list.ts deleted file mode 100644 index 4b9e8d30a..000000000 --- a/client/src/pages/api/app/list.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; -import { jsonRes } from '@/service/response'; -import { connectToDatabase, Collection, App } from '@/service/mongo'; -import { authUser } from '@/service/utils/auth'; -import type { AppListResponse } from '@/api/response/app'; - -/* 获取模型列表 */ -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - try { - // 凭证校验 - const { userId } = await authUser({ req, authToken: true }); - - await connectToDatabase(); - - // 根据 userId 获取模型信息 - const [myApps, myCollections] = await Promise.all([ - App.find( - { - userId - }, - '_id avatar name intro' - ).sort({ - updateTime: -1 - }), - Collection.find({ userId }) - .populate({ - path: 'modelId', - select: '_id avatar name intro', - match: { 'share.isShare': true } - }) - .then((res) => res.filter((item) => item.modelId)) - ]); - - jsonRes(res, { - data: { - myApps: myApps.map((item) => ({ - _id: item._id, - name: item.name, - avatar: item.avatar, - intro: item.intro - })), - myCollectionApps: myCollections - .map((item: any) => ({ - _id: item.modelId?._id, - name: item.modelId?.name, - avatar: item.modelId?.avatar, - intro: item.modelId?.intro - })) - .filter((item) => !myApps.find((model) => String(model._id) === String(item._id))) // 去重 - } - }); - } catch (err) { - jsonRes(res, { - code: 500, - error: err - }); - } -} diff --git a/client/src/pages/api/app/myApps.ts b/client/src/pages/api/app/myApps.ts new file mode 100644 index 000000000..bec3d8103 --- /dev/null +++ b/client/src/pages/api/app/myApps.ts @@ -0,0 +1,33 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { jsonRes } from '@/service/response'; +import { connectToDatabase, App } from '@/service/mongo'; +import { authUser } from '@/service/utils/auth'; +import { AppListItemType } from '@/types/app'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + // 凭证校验 + const { userId } = await authUser({ req, authToken: true }); + + await connectToDatabase(); + + // 根据 userId 获取模型信息 + const myApps = await App.find( + { + userId + }, + '_id avatar name intro' + ).sort({ + updateTime: -1 + }); + + jsonRes(res, { + data: myApps + }); + } catch (err) { + jsonRes(res, { + code: 500, + error: err + }); + } +} diff --git a/client/src/pages/api/app/share/collection.ts b/client/src/pages/api/app/share/collection.ts index e009a298a..38af7811b 100644 --- a/client/src/pages/api/app/share/collection.ts +++ b/client/src/pages/api/app/share/collection.ts @@ -6,9 +6,9 @@ import { authUser } from '@/service/utils/auth'; /* 模型收藏切换 */ export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { - const { modelId } = req.query as { modelId: string }; + const { appId } = req.query as { appId: string }; - if (!modelId) { + if (!appId) { throw new Error('缺少参数'); } // 凭证校验 @@ -18,7 +18,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< const collectionRecord = await Collection.findOne({ userId, - modelId + modelId: appId }); if (collectionRecord) { @@ -26,12 +26,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< } else { await Collection.create({ userId, - modelId + modelId: appId }); } - await App.findByIdAndUpdate(modelId, { - 'share.collection': await Collection.countDocuments({ modelId }) + await App.findByIdAndUpdate(appId, { + 'share.collection': await Collection.countDocuments({ modelId: appId }) }); jsonRes(res); diff --git a/client/src/pages/api/chat/chatTest.ts b/client/src/pages/api/chat/chatTest.ts index d3e314a4b..18bfbfb24 100644 --- a/client/src/pages/api/chat/chatTest.ts +++ b/client/src/pages/api/chat/chatTest.ts @@ -6,14 +6,15 @@ import { sseResponseEventEnum } from '@/constants/chat'; import { sseResponse } from '@/service/utils/tools'; import { type ChatCompletionRequestMessage } from 'openai'; import { AppModuleItemType } from '@/types/app'; -import { dispatchModules } from '../openapi/v1/chat/completions2'; +import { dispatchModules } from '../openapi/v1/chat/completions'; +import { gptMessage2ChatType } from '@/utils/adapt'; export type MessageItemType = ChatCompletionRequestMessage & { _id?: string }; export type Props = { history: MessageItemType[]; prompt: string; modules: AppModuleItemType[]; - variable: Record; + variables: Record; }; export type ChatResponseType = { newChatId: string; @@ -29,7 +30,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) res.end(); }); - let { modules = [], history = [], prompt, variable = {} } = req.body as Props; + let { modules = [], history = [], prompt, variables = {} } = req.body as Props; try { if (!history || !modules || !prompt) { @@ -48,9 +49,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const { responseData } = await dispatchModules({ res, modules: modules, - variable, + variables, params: { - history, + history: gptMessage2ChatType(history), userChatInput: prompt }, stream: true diff --git a/client/src/pages/api/chat/delChatRecordByContentId.ts b/client/src/pages/api/chat/delChatRecordByContentId.ts index ddaab294b..b39c1aaa2 100644 --- a/client/src/pages/api/chat/delChatRecordByContentId.ts +++ b/client/src/pages/api/chat/delChatRecordByContentId.ts @@ -5,12 +5,10 @@ import { authUser } from '@/service/utils/auth'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { - const { chatId, contentId } = req.query as { - chatId: string; - contentId: string; - }; + const { historyId, contentId } = req.query as { historyId: string; contentId: string }; + console.log(historyId, contentId); - if (!chatId || !contentId) { + if (!historyId || !contentId) { throw new Error('缺少参数'); } @@ -19,7 +17,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // 凭证校验 const { userId } = await authUser({ req, authToken: true }); - const chatRecord = await Chat.findById(chatId); + const chatRecord = await Chat.findById(historyId); if (!chatRecord) { throw new Error('找不到对话'); @@ -28,7 +26,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // 删除一条数据库记录 await Chat.updateOne( { - _id: chatId, + _id: historyId, userId }, { $pull: { content: { _id: contentId } } } diff --git a/client/src/pages/api/chat/history/getHistory.ts b/client/src/pages/api/chat/history/getHistory.ts index a582055de..d40d66b14 100644 --- a/client/src/pages/api/chat/history/getHistory.ts +++ b/client/src/pages/api/chat/history/getHistory.ts @@ -2,31 +2,32 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { jsonRes } from '@/service/response'; import { connectToDatabase, Chat } from '@/service/mongo'; import { authUser } from '@/service/utils/auth'; -import type { HistoryItemType } from '@/types/chat'; +import type { ChatHistoryItemType } from '@/types/chat'; /* 获取历史记录 */ export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { + const { appId } = req.body as { appId?: string }; const { userId } = await authUser({ req, authToken: true }); await connectToDatabase(); const data = await Chat.find( { - userId + userId, + ...(appId && { appId }) }, - '_id title top customTitle modelId updateTime latestChat' + '_id title top customTitle appId updateTime' ) .sort({ top: -1, updateTime: -1 }) .limit(20); - jsonRes(res, { + jsonRes(res, { data: data.map((item) => ({ _id: item._id, updateTime: item.updateTime, - modelId: item.modelId, + appId: item.appId, title: item.customTitle || item.title, - latestChat: item.latestChat, top: item.top })) }); diff --git a/client/src/pages/api/chat/history/updateChatHistory.ts b/client/src/pages/api/chat/history/updateChatHistory.ts index 00835e75c..9eac2bfaf 100644 --- a/client/src/pages/api/chat/history/updateChatHistory.ts +++ b/client/src/pages/api/chat/history/updateChatHistory.ts @@ -4,7 +4,7 @@ import { connectToDatabase, Chat } from '@/service/mongo'; import { authUser } from '@/service/utils/auth'; export type Props = { - chatId: '' | string; + historyId: string; customTitle?: string; top?: boolean; }; @@ -12,7 +12,7 @@ export type Props = { /* 更新聊天标题 */ export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { - const { chatId, customTitle, top } = req.body as Props; + const { historyId, customTitle, top } = req.body as Props; const { userId } = await authUser({ req, authToken: true }); @@ -20,7 +20,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await Chat.findOneAndUpdate( { - _id: chatId, + _id: historyId, userId }, { diff --git a/client/src/pages/api/chat/init.ts b/client/src/pages/api/chat/init.ts index 918d01479..64f1dfea4 100644 --- a/client/src/pages/api/chat/init.ts +++ b/client/src/pages/api/chat/init.ts @@ -6,23 +6,25 @@ import { authUser } from '@/service/utils/auth'; import { ChatItemType } from '@/types/chat'; import { authApp } from '@/service/utils/auth'; import mongoose from 'mongoose'; -import type { AppSchema } from '@/types/mongoSchema'; +import type { AppSchema, ChatSchema } from '@/types/mongoSchema'; +import { FlowModuleTypeEnum } from '@/constants/flow'; +import { SystemInputEnum } from '@/constants/app'; /* 初始化我的聊天框,需要身份验证 */ export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { const { userId } = await authUser({ req, authToken: true }); - let { modelId, chatId } = req.query as { - modelId: '' | string; - chatId: '' | string; + let { appId, historyId } = req.query as { + appId: '' | string; + historyId: '' | string; }; await connectToDatabase(); - // 没有 modelId 时,直接获取用户的第一个id + // 没有 appId 时,直接获取用户的第一个id const app = await (async () => { - if (!modelId) { + if (!appId) { const myModel = await App.findOne({ userId }); if (!myModel) { const { _id } = await App.create({ @@ -36,7 +38,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } else { // 校验使用权限 const authRes = await authApp({ - appId: modelId, + appId, userId, authUser: false, authOwner: false @@ -45,63 +47,71 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } })(); - modelId = modelId || app._id; + appId = appId || app._id; // 历史记录 - let history: ChatItemType[] = []; - - if (chatId) { - // auth chatId - const chat = await Chat.countDocuments({ - _id: chatId, - userId - }); - if (chat === 0) { - throw new Error('聊天框不存在'); - } - // 获取 chat.content 数据 - history = await Chat.aggregate([ - { - $match: { - _id: new mongoose.Types.ObjectId(chatId), - userId: new mongoose.Types.ObjectId(userId) + const { chat, history = [] }: { chat?: ChatSchema; history?: ChatItemType[] } = + await (async () => { + if (historyId) { + // auth chatId + const chat = await Chat.findOne({ + _id: historyId, + userId + }); + if (!chat) { + throw new Error('聊天框不存在'); } - }, - { - $project: { - content: { - $slice: ['$content', -50] // 返回 content 数组的最后50个元素 + // 获取 chat.content 数据 + const history = await Chat.aggregate([ + { + $match: { + _id: new mongoose.Types.ObjectId(historyId), + userId: new mongoose.Types.ObjectId(userId) + } + }, + { + $project: { + content: { + $slice: ['$content', -50] // 返回 content 数组的最后50个元素 + } + } + }, + { $unwind: '$content' }, + { + $project: { + _id: '$content._id', + obj: '$content.obj', + value: '$content.value', + systemPrompt: '$content.systemPrompt', + quoteLen: { $size: { $ifNull: ['$content.quote', []] } } + } } - } - }, - { $unwind: '$content' }, - { - $project: { - _id: '$content._id', - obj: '$content.obj', - value: '$content.value', - systemPrompt: '$content.systemPrompt', - quoteLen: { $size: { $ifNull: ['$content.quote', []] } } - } + ]); + return { history, chat }; } - ]); - } + return {}; + })(); const isOwner = String(app.userId) === userId; jsonRes(res, { data: { - chatId: chatId || '', - modelId: modelId, - model: { + historyId, + appId, + app: { + variableModules: app.modules + .find((item) => item.flowType === FlowModuleTypeEnum.userGuide) + ?.inputs?.find((item) => item.key === SystemInputEnum.variables)?.value, + welcomeText: app.modules + .find((item) => item.flowType === FlowModuleTypeEnum.userGuide) + ?.inputs?.find((item) => item.key === SystemInputEnum.welcomeText)?.value, name: app.name, avatar: app.avatar, intro: app.intro, canUse: app.share.isShare || isOwner }, - chatModel: app.chat.chatModel, - systemPrompt: isOwner ? app.chat.systemPrompt : '', - limitPrompt: isOwner ? app.chat.limitPrompt : '', + title: chat?.title || '新对话', + variables: chat?.variables || {}, history } }); diff --git a/client/src/pages/api/chat/saveChat.ts b/client/src/pages/api/chat/saveChat.ts index b0ab366c2..2a7fea8c9 100644 --- a/client/src/pages/api/chat/saveChat.ts +++ b/client/src/pages/api/chat/saveChat.ts @@ -7,15 +7,16 @@ import { authUser } from '@/service/utils/auth'; import { Types } from 'mongoose'; type Props = { - chatId?: string; - modelId: string; + historyId?: string; + appId: string; + variables?: Record; prompts: [ChatItemType, ChatItemType]; }; /* 聊天内容存存储 */ export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { - const { chatId, modelId, prompts } = req.body as Props; + const { historyId, appId, prompts } = req.body as Props; if (!prompts) { throw new Error('缺少参数'); @@ -24,8 +25,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const { userId } = await authUser({ req, authToken: true }); const response = await saveChat({ - chatId, - modelId, + historyId, + appId, prompts, userId }); @@ -42,14 +43,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } export async function saveChat({ - newChatId, - chatId, - modelId, + newHistoryId, + historyId, + appId, prompts, + variables, userId -}: Props & { newChatId?: Types.ObjectId; userId: string }): Promise<{ newChatId: string }> { +}: Props & { newHistoryId?: Types.ObjectId; userId: string }): Promise<{ newHistoryId: string }> { await connectToDatabase(); - const { app } = await authApp({ appId: modelId, userId, authOwner: false }); + const { app } = await authApp({ appId, userId, authOwner: false }); const content = prompts.map((item) => ({ _id: item._id, @@ -60,43 +62,45 @@ export async function saveChat({ })); if (String(app.userId) === userId) { - await App.findByIdAndUpdate(modelId, { + await App.findByIdAndUpdate(appId, { updateTime: new Date() }); } const [response] = await Promise.all([ - ...(chatId + ...(historyId ? [ - Chat.findByIdAndUpdate(chatId, { + Chat.findByIdAndUpdate(historyId, { $push: { content: { $each: content } }, + variables, title: content[0].value.slice(0, 20), latestChat: content[1].value, updateTime: new Date() }).then(() => ({ - newChatId: '' + newHistoryId: '' })) ] : [ Chat.create({ - _id: newChatId, + _id: newHistoryId, userId, - modelId, + appId, + variables, content, title: content[0].value.slice(0, 20), latestChat: content[1].value }).then((res) => ({ - newChatId: String(res._id) + newHistoryId: String(res._id) })) ]), // update app ...(String(app.userId) === userId ? [ - App.findByIdAndUpdate(modelId, { + App.findByIdAndUpdate(appId, { updateTime: new Date() }) ] @@ -105,6 +109,6 @@ export async function saveChat({ return { // @ts-ignore - newChatId: response?.newChatId || '' + newHistoryId: response?.newHistoryId || '' }; } diff --git a/client/src/pages/api/openapi/chat/chat.ts b/client/src/pages/api/openapi/chat/chat.ts deleted file mode 100644 index 4933f860e..000000000 --- a/client/src/pages/api/openapi/chat/chat.ts +++ /dev/null @@ -1,189 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; -import { connectToDatabase } from '@/service/mongo'; -import { authUser, authApp, getApiKey } from '@/service/utils/auth'; -import { modelServiceToolMap, resStreamResponse } from '@/service/utils/chat'; -import { ChatItemType } from '@/types/chat'; -import { jsonRes } from '@/service/response'; -import { ChatModelMap } from '@/constants/model'; -import { pushChatBill } from '@/service/events/pushBill'; -import { ChatRoleEnum } from '@/constants/chat'; -import { withNextCors } from '@/service/utils/tools'; -import { BillTypeEnum } from '@/constants/user'; -import { appKbSearch } from '../kb/appKbSearch'; - -/* 发送提示词 */ -export default withNextCors(async function handler(req: NextApiRequest, res: NextApiResponse) { - res.on('close', () => { - res.end(); - }); - res.on('error', () => { - console.log('error: ', 'request error'); - res.end(); - }); - - try { - const { - chatId, - prompts, - modelId, - isStream = true - } = req.body as { - chatId?: string; - prompts: ChatItemType[]; - modelId: string; - isStream: boolean; - }; - - if (!prompts || !modelId) { - throw new Error('缺少参数'); - } - if (!Array.isArray(prompts)) { - throw new Error('prompts is not array'); - } - if (prompts.length > 30 || prompts.length === 0) { - throw new Error('Prompts arr length range 1-30'); - } - - await connectToDatabase(); - let startTime = Date.now(); - - /* 凭证校验 */ - const { userId } = await authUser({ req }); - - const { app } = await authApp({ - userId, - appId: modelId - }); - - /* get api key */ - const { systemAuthKey: apiKey } = await getApiKey({ - model: app.chat.chatModel, - userId, - mustPay: true - }); - - const modelConstantsData = ChatModelMap[app.chat.chatModel]; - const prompt = prompts[prompts.length - 1]; - - const { - userSystemPrompt = [], - userLimitPrompt = [], - quotePrompt = [] - } = await (async () => { - // 使用了知识库搜索 - if (app.chat.relatedKbs?.length > 0) { - const { quotePrompt, userSystemPrompt, userLimitPrompt } = await appKbSearch({ - model: app, - userId, - fixedQuote: [], - prompt: prompt, - similarity: app.chat.searchSimilarity, - limit: app.chat.searchLimit - }); - - return { - userSystemPrompt, - userLimitPrompt, - quotePrompt: [quotePrompt] - }; - } - return { - userSystemPrompt: app.chat.systemPrompt - ? [ - { - obj: ChatRoleEnum.System, - value: app.chat.systemPrompt - } - ] - : [], - userLimitPrompt: app.chat.limitPrompt - ? [ - { - obj: ChatRoleEnum.Human, - value: app.chat.limitPrompt - } - ] - : [] - }; - })(); - - // search result is empty - if (app.chat.relatedKbs?.length > 0 && !quotePrompt[0]?.value && app.chat.searchEmptyText) { - const response = app.chat.searchEmptyText; - return res.end(response); - } - - // 读取对话内容 - const completePrompts = [ - ...quotePrompt, - ...userSystemPrompt, - ...prompts.slice(0, -1), - ...userLimitPrompt, - prompt - ]; - - // 计算温度 - const temperature = (modelConstantsData.maxTemperature * (app.chat.temperature / 10)).toFixed( - 2 - ); - - // 发出请求 - const { streamResponse, responseMessages, responseText, totalTokens } = - await modelServiceToolMap.chatCompletion({ - model: app.chat.chatModel, - apiKey, - temperature: +temperature, - messages: completePrompts, - stream: isStream, - res - }); - - console.log('api response time:', `${(Date.now() - startTime) / 1000}s`); - - if (res.closed) return res.end(); - - const { textLen = 0, tokens = totalTokens } = await (async () => { - if (isStream) { - try { - const { finishMessages, totalTokens } = await resStreamResponse({ - model: app.chat.chatModel, - res, - chatResponse: streamResponse, - prompts: responseMessages - }); - res.end(); - return { - textLen: finishMessages.map((item) => item.value).join('').length, - tokens: totalTokens - }; - } catch (error) { - res.end(); - console.log('error,结束', error); - } - } else { - jsonRes(res, { - data: responseText - }); - return { - textLen: responseMessages.map((item) => item.value).join('').length - }; - } - return {}; - })(); - - pushChatBill({ - isPay: true, - chatModel: app.chat.chatModel, - userId, - textLen, - tokens, - type: BillTypeEnum.openapiChat - }); - } catch (err: any) { - res.status(500); - jsonRes(res, { - code: 500, - error: err - }); - } -}); diff --git a/client/src/pages/api/openapi/v1/chat/completions.ts b/client/src/pages/api/openapi/v1/chat/completions.ts index 5b8c3540f..4c35dae05 100644 --- a/client/src/pages/api/openapi/v1/chat/completions.ts +++ b/client/src/pages/api/openapi/v1/chat/completions.ts @@ -1,43 +1,41 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { connectToDatabase } from '@/service/mongo'; -import { authUser, authApp, getApiKey, authShareChat } from '@/service/utils/auth'; -import { modelServiceToolMap, V2_StreamResponse } from '@/service/utils/chat'; -import { jsonRes } from '@/service/response'; -import { ChatModelMap } from '@/constants/model'; -import { pushChatBill, updateShareChatBill } from '@/service/events/pushBill'; +import { authUser, authApp, authShareChat } from '@/service/utils/auth'; +import { sseErrRes, jsonRes } from '@/service/response'; import { ChatRoleEnum, sseResponseEventEnum } from '@/constants/chat'; import { withNextCors } from '@/service/utils/tools'; -import { BillTypeEnum } from '@/constants/user'; -import { appKbSearch } from '../../../openapi/kb/appKbSearch'; import type { CreateChatCompletionRequest } from 'openai'; import { gptMessage2ChatType, textAdaptGptResponse } from '@/utils/adapt'; import { getChatHistory } from './getHistory'; import { saveChat } from '@/pages/api/chat/saveChat'; import { sseResponse } from '@/service/utils/tools'; import { type ChatCompletionRequestMessage } from 'openai'; +import { SpecificInputEnum, AppModuleItemTypeEnum } from '@/constants/app'; import { Types } from 'mongoose'; -import { sensitiveCheck } from '../../text/sensitiveCheck'; +import { moduleFetch } from '@/service/api/request'; +import { AppModuleItemType, RunningModuleItemType } from '@/types/app'; +import { FlowInputItemTypeEnum } from '@/constants/flow'; export type MessageItemType = ChatCompletionRequestMessage & { _id?: string }; type FastGptWebChatProps = { - chatId?: string; // undefined: nonuse history, '': new chat, 'xxxxx': use history + historyId?: string; // undefined: nonuse history, '': new chat, 'xxxxx': use history appId?: string; }; type FastGptShareChatProps = { - password?: string; shareId?: string; }; export type Props = CreateChatCompletionRequest & FastGptWebChatProps & FastGptShareChatProps & { messages: MessageItemType[]; + stream?: boolean; + variables: Record; }; export type ChatResponseType = { newChatId: string; quoteLen?: number; }; -/* 发送提示词 */ export default withNextCors(async function handler(req: NextApiRequest, res: NextApiResponse) { res.on('close', () => { res.end(); @@ -47,8 +45,14 @@ export default withNextCors(async function handler(req: NextApiRequest, res: Nex res.end(); }); - let { chatId, appId, shareId, password = '', stream = false, messages = [] } = req.body as Props; - let step = 0; + let { + historyId, + appId, + shareId, + stream = false, + messages = [], + variables = {} + } = req.body as Props; try { if (!messages) { @@ -68,8 +72,7 @@ export default withNextCors(async function handler(req: NextApiRequest, res: Nex authType } = await (shareId ? authShareChat({ - shareId, - password + shareId }) : authUser({ req })); @@ -78,257 +81,96 @@ export default withNextCors(async function handler(req: NextApiRequest, res: Nex throw new Error('appId is empty'); } - // auth app permission - const { app, showModelDetail } = await authApp({ - userId, - appId, - authOwner: false, - reserveDetail: true - }); + // auth app, get history + const [{ app }, { history }] = await Promise.all([ + authApp({ + appId, + userId + }), + getChatHistory({ historyId, userId }) + ]); - const showAppDetail = !shareId && showModelDetail; - - /* get api key */ - const { systemAuthKey: apiKey, userOpenAiKey } = await getApiKey({ - model: app.chat.chatModel, - userId, - mustPay: authType !== 'token' - }); - - // get history - const { history } = await getChatHistory({ chatId, userId }); const prompts = history.concat(gptMessage2ChatType(messages)); - // adapt fastgpt web if (prompts[prompts.length - 1].obj === 'AI') { prompts.pop(); } // user question - const prompt = prompts[prompts.length - 1]; + const prompt = prompts.pop(); - const { - rawSearch = [], - userSystemPrompt = [], - userLimitPrompt = [], - quotePrompt = [] - } = await (async () => { - // 使用了知识库搜索 - if (app.chat.relatedKbs?.length > 0) { - const { rawSearch, quotePrompt, userSystemPrompt, userLimitPrompt } = await appKbSearch({ - model: app, - userId, - fixedQuote: history[history.length - 1]?.quote, - prompt, - similarity: app.chat.searchSimilarity, - limit: app.chat.searchLimit - }); - - return { - rawSearch, - userSystemPrompt, - userLimitPrompt, - quotePrompt: [quotePrompt] - }; - } - return { - userSystemPrompt: app.chat.systemPrompt - ? [ - { - obj: ChatRoleEnum.System, - value: app.chat.systemPrompt - } - ] - : [], - userLimitPrompt: app.chat.limitPrompt - ? [ - { - obj: ChatRoleEnum.Human, - value: app.chat.limitPrompt - } - ] - : [] - }; - })(); - - // search result is empty - if (app.chat.relatedKbs?.length > 0 && !quotePrompt[0]?.value && app.chat.searchEmptyText) { - const response = app.chat.searchEmptyText; - if (stream) { - sseResponse({ - res, - event: sseResponseEventEnum.answer, - data: textAdaptGptResponse({ - text: response, - model: app.chat.chatModel, - finish_reason: 'stop' - }) - }); - return res.end(); - } else { - return res.json({ - id: chatId || '', - object: 'chat.completion', - created: 1688608930, - model: app.chat.chatModel, - usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, - choices: [ - { message: { role: 'assistant', content: response }, finish_reason: 'stop', index: 0 } - ] - }); - } + if (!prompt) { + throw new Error('Question is empty'); } - // api messages. [quote,context,systemPrompt,question] - const completePrompts = [ - ...quotePrompt, - ...userSystemPrompt, - ...prompts.slice(0, -1), - ...userLimitPrompt, - prompt - ]; - // chat temperature - const modelConstantsData = ChatModelMap[app.chat.chatModel]; - // FastGpt temperature range: 1~10 - const temperature = (modelConstantsData.maxTemperature * (app.chat.temperature / 10)).toFixed( - 2 - ); + const newHistoryId = historyId === '' ? new Types.ObjectId() : undefined; + if (stream && newHistoryId) { + res.setHeader('newHistoryId', String(newHistoryId)); + } - await sensitiveCheck({ - input: `${userSystemPrompt[0]?.value}\n${userLimitPrompt[0]?.value}\n${prompt.value}` + /* start process */ + const { responseData, answerText } = await dispatchModules({ + res, + modules: app.modules, + variables, + params: { + history: prompts, + userChatInput: prompt.value + }, + stream }); - // start app api. responseText and totalTokens: valid only if stream = false - const { streamResponse, responseMessages, responseText, totalTokens } = - await modelServiceToolMap.chatCompletion({ - model: app.chat.chatModel, - apiKey: userOpenAiKey || apiKey, - temperature: +temperature, - maxToken: app.chat.maxToken, - messages: completePrompts, - stream, - res - }); - - console.log('api response time:', `${(Date.now() - startTime) / 1000}s`); - - if (res.closed) return res.end(); - - // create a chatId - const newChatId = chatId === '' ? new Types.ObjectId() : undefined; - - // response answer - const { - textLen = 0, - answer = responseText, - tokens = totalTokens - } = await (async () => { - if (stream) { - // 创建响应流 - res.setHeader('Content-Type', 'text/event-stream;charset=utf-8'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Transfer-Encoding', 'chunked'); - res.setHeader('X-Accel-Buffering', 'no'); - res.setHeader('Cache-Control', 'no-cache, no-transform'); - step = 1; - - try { - // response newChatId and quota - sseResponse({ - res, - event: sseResponseEventEnum.chatResponse, - data: JSON.stringify({ - newChatId, - quoteLen: rawSearch.length - }) - }); - // response answer - const { finishMessages, totalTokens, responseContent } = await V2_StreamResponse({ - model: app.chat.chatModel, - res, - chatResponse: streamResponse, - prompts: responseMessages - }); - return { - answer: responseContent, - textLen: finishMessages.map((item) => item.value).join('').length, - tokens: totalTokens - }; - } catch (error) { - return Promise.reject(error); - } - } else { - return { - textLen: responseMessages.map((item) => item.value).join('').length - }; - } - })(); - - // save chat history - if (typeof chatId === 'string') { + // save chat + if (typeof historyId === 'string') { await saveChat({ - newChatId, - chatId, - modelId: appId, + historyId, + newHistoryId, + appId, prompts: [ prompt, { _id: messages[messages.length - 1]._id, obj: ChatRoleEnum.AI, - value: answer, - ...(showAppDetail - ? { - quote: rawSearch, - systemPrompt: `${userSystemPrompt[0]?.value}\n\n${userLimitPrompt[0]?.value}` - } - : {}) + value: answerText, + responseData } ], userId }); } - // close response if (stream) { + sseResponse({ + res, + event: sseResponseEventEnum.answer, + data: '[DONE]' + }); + sseResponse({ + res, + event: sseResponseEventEnum.appStreamResponse, + data: JSON.stringify(responseData) + }); res.end(); } else { res.json({ - ...(showAppDetail - ? { - rawSearch - } - : {}), - newChatId, - id: chatId || '', - object: 'chat.completion', - created: 1688608930, - model: app.chat.chatModel, - usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: tokens }, + data: { + newHistoryId, + ...responseData + }, + id: historyId || '', + model: '', + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, choices: [ - { message: { role: 'assistant', content: answer }, finish_reason: 'stop', index: 0 } + { + message: [{ role: 'assistant', content: answerText }], + finish_reason: 'stop', + index: 0 + } ] }); } - - pushChatBill({ - isPay: !userOpenAiKey, - chatModel: app.chat.chatModel, - userId, - textLen, - tokens, - type: authType === 'apikey' ? BillTypeEnum.openapiChat : BillTypeEnum.chat - }); - shareId && - updateShareChatBill({ - shareId, - tokens - }); } catch (err: any) { - res.status(500); - if (step === 1) { - sseResponse({ - res, - event: sseResponseEventEnum.error, - data: JSON.stringify(err) - }); + if (stream) { + res.status(500); + sseErrRes(res, err); res.end(); } else { jsonRes(res, { @@ -338,3 +180,232 @@ export default withNextCors(async function handler(req: NextApiRequest, res: Nex } } }); + +export async function dispatchModules({ + res, + modules, + params = {}, + variables = {}, + stream = false +}: { + res: NextApiResponse; + modules: AppModuleItemType[]; + params?: Record; + variables?: Record; + stream?: boolean; +}) { + const runningModules = loadModules(modules, variables); + let storeData: Record = {}; + let responseData: Record = {}; + let answerText = ''; + + function pushStore({ + isResponse = false, + answer, + data = {} + }: { + isResponse?: boolean; + answer?: string; + data?: Record; + }) { + if (isResponse) { + responseData = { + ...responseData, + ...data + }; + } + + if (answer) { + answerText += answer; + } + + storeData = { + ...storeData, + ...data + }; + } + function moduleInput( + module: RunningModuleItemType, + data: Record = {} + ): Promise { + const checkInputFinish = () => { + return !module.inputs.find((item: any) => item.value === undefined); + }; + const updateInputValue = (key: string, value: any) => { + const index = module.inputs.findIndex((item: any) => item.key === key); + if (index === -1) return; + module.inputs[index].value = value; + }; + + const set = new Set(); + + return Promise.all( + Object.entries(data).map(([key, val]: any) => { + updateInputValue(key, val); + + if (!set.has(module.moduleId) && checkInputFinish()) { + set.add(module.moduleId); + return moduleRun(module); + } + }) + ); + } + function moduleOutput( + module: RunningModuleItemType, + result: Record = {} + ): Promise { + return Promise.all( + module.outputs.map((outputItem) => { + if (result[outputItem.key] === undefined) return; + /* update output value */ + outputItem.value = result[outputItem.key]; + + pushStore({ + isResponse: outputItem.response, + answer: outputItem.answer ? outputItem.value : '', + data: { + [outputItem.key]: outputItem.value + } + }); + + /* update target */ + return Promise.all( + outputItem.targets.map((target: any) => { + // find module + const targetModule = runningModules.find((item) => item.moduleId === target.moduleId); + if (!targetModule) return; + return moduleInput(targetModule, { [target.key]: outputItem.value }); + }) + ); + }) + ); + } + async function moduleRun(module: RunningModuleItemType): Promise { + if (res.closed) return Promise.resolve(); + console.log('run=========', module.type, module.url); + + // direct answer + if (module.type === AppModuleItemTypeEnum.answer) { + const text = + module.inputs.find((item) => item.key === SpecificInputEnum.answerText)?.value || ''; + pushStore({ + answer: text + }); + return StreamAnswer({ + res, + stream, + text: text + }); + } + + if (module.type === AppModuleItemTypeEnum.switch) { + return moduleOutput(module, switchResponse(module)); + } + + if ( + (module.type === AppModuleItemTypeEnum.http || + module.type === AppModuleItemTypeEnum.initInput) && + module.url + ) { + // get fetch params + const params: Record = {}; + module.inputs.forEach((item: any) => { + params[item.key] = item.value; + }); + const data = { + stream, + ...params + }; + + // response data + const fetchRes = await moduleFetch({ + res, + url: module.url, + data + }); + + return moduleOutput(module, fetchRes); + } + } + + // start process width initInput + const initModules = runningModules.filter( + (item) => item.type === AppModuleItemTypeEnum.initInput + ); + + await Promise.all(initModules.map((module) => moduleInput(module, params))); + + return { + responseData, + answerText + }; +} + +function loadModules( + modules: AppModuleItemType[], + variables: Record +): RunningModuleItemType[] { + return modules.map((module) => { + return { + moduleId: module.moduleId, + type: module.type, + url: module.url, + inputs: module.inputs + .filter((item) => item.type !== FlowInputItemTypeEnum.target || item.connected) // filter unconnected target input + .map((item) => { + if (typeof item.value !== 'string') { + return { + key: item.key, + value: item.value + }; + } + + // variables replace + const replacedVal = item.value.replace( + /{{(.*?)}}/g, + (match, key) => variables[key.trim()] || match + ); + + return { + key: item.key, + value: replacedVal + }; + }), + outputs: module.outputs.map((item) => ({ + key: item.key, + answer: item.key === SpecificInputEnum.answerText, + response: item.response, + value: undefined, + targets: item.targets + })) + }; + }); +} +function StreamAnswer({ + res, + stream = false, + text = '' +}: { + res: NextApiResponse; + stream?: boolean; + text?: string; +}) { + if (stream && text) { + return sseResponse({ + res, + event: sseResponseEventEnum.answer, + data: textAdaptGptResponse({ + text: text.replace(/\\n/g, '\n') + }) + }); + } + return text; +} +function switchResponse(module: RunningModuleItemType) { + const val = module?.inputs?.[0]?.value; + + if (val) { + return { true: 1 }; + } + return { false: 1 }; +} diff --git a/client/src/pages/api/openapi/v1/chat/completions2.ts b/client/src/pages/api/openapi/v1/chat/completions2.ts deleted file mode 100644 index 231d20bca..000000000 --- a/client/src/pages/api/openapi/v1/chat/completions2.ts +++ /dev/null @@ -1,405 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; -import { connectToDatabase } from '@/service/mongo'; -import { authUser, authApp, getApiKey, authShareChat } from '@/service/utils/auth'; -import { sseErrRes, jsonRes } from '@/service/response'; -import { ChatRoleEnum, sseResponseEventEnum } from '@/constants/chat'; -import { withNextCors } from '@/service/utils/tools'; -import type { CreateChatCompletionRequest } from 'openai'; -import { gptMessage2ChatType, textAdaptGptResponse } from '@/utils/adapt'; -import { getChatHistory } from './getHistory'; -import { saveChat } from '@/pages/api/chat/saveChat'; -import { sseResponse } from '@/service/utils/tools'; -import { type ChatCompletionRequestMessage } from 'openai'; -import { SpecificInputEnum, AppModuleItemTypeEnum } from '@/constants/app'; -import { model, Types } from 'mongoose'; -import { moduleFetch } from '@/service/api/request'; -import { AppModuleItemType, RunningModuleItemType } from '@/types/app'; -import { FlowInputItemTypeEnum, FlowOutputItemTypeEnum } from '@/constants/flow'; -import { SystemInputEnum } from '@/constants/app'; - -export type MessageItemType = ChatCompletionRequestMessage & { _id?: string }; -type FastGptWebChatProps = { - chatId?: string; // undefined: nonuse history, '': new chat, 'xxxxx': use history - appId?: string; -}; -type FastGptShareChatProps = { - shareId?: string; -}; -export type Props = CreateChatCompletionRequest & - FastGptWebChatProps & - FastGptShareChatProps & { - messages: MessageItemType[]; - stream?: boolean; - variables: Record; - }; -export type ChatResponseType = { - newChatId: string; - quoteLen?: number; -}; - -export default withNextCors(async function handler(req: NextApiRequest, res: NextApiResponse) { - res.on('close', () => { - res.end(); - }); - res.on('error', () => { - console.log('error: ', 'request error'); - res.end(); - }); - - let { chatId, appId, shareId, stream = false, messages = [], variables = {} } = req.body as Props; - - try { - if (!messages) { - throw new Error('Prams Error'); - } - if (!Array.isArray(messages)) { - throw new Error('messages is not array'); - } - - await connectToDatabase(); - let startTime = Date.now(); - - /* user auth */ - const { - userId, - appId: authAppid, - authType - } = await (shareId - ? authShareChat({ - shareId - }) - : authUser({ req })); - - appId = appId ? appId : authAppid; - if (!appId) { - throw new Error('appId is empty'); - } - - // auth app, get history - const [{ app }, { history }] = await Promise.all([ - authApp({ - appId, - userId - }), - getChatHistory({ chatId, userId }) - ]); - - const prompts = history.concat(gptMessage2ChatType(messages)); - if (prompts[prompts.length - 1].obj === 'AI') { - prompts.pop(); - } - // user question - const prompt = prompts.pop(); - - if (!prompt) { - throw new Error('Question is empty'); - } - - const newChatId = chatId === '' ? new Types.ObjectId() : undefined; - if (stream && newChatId) { - res.setHeader('newChatId', String(newChatId)); - } - - /* start process */ - const { responseData, answerText } = await dispatchModules({ - res, - modules: app.modules, - variables, - params: { - history: prompts, - userChatInput: prompt.value - }, - stream - }); - - // save chat - if (typeof chatId === 'string') { - await saveChat({ - chatId, - newChatId, - modelId: appId, - prompts: [ - prompt, - { - _id: messages[messages.length - 1]._id, - obj: ChatRoleEnum.AI, - value: answerText, - responseData - } - ], - userId - }); - } - - if (stream) { - sseResponse({ - res, - event: sseResponseEventEnum.answer, - data: '[DONE]' - }); - sseResponse({ - res, - event: sseResponseEventEnum.appStreamResponse, - data: JSON.stringify(responseData) - }); - res.end(); - } else { - res.json({ - data: { - newChatId, - ...responseData - }, - id: chatId || '', - model: '', - usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, - choices: [ - { - message: [{ role: 'assistant', content: answerText }], - finish_reason: 'stop', - index: 0 - } - ] - }); - } - } catch (err: any) { - if (stream) { - res.status(500); - sseErrRes(res, err); - res.end(); - } else { - jsonRes(res, { - code: 500, - error: err - }); - } - } -}); - -export async function dispatchModules({ - res, - modules, - params = {}, - variables = {}, - stream = false -}: { - res: NextApiResponse; - modules: AppModuleItemType[]; - params?: Record; - variables?: Record; - stream?: boolean; -}) { - const runningModules = loadModules(modules, variables); - let storeData: Record = {}; - let responseData: Record = {}; - let answerText = ''; - - function pushStore({ - isResponse = false, - answer, - data = {} - }: { - isResponse?: boolean; - answer?: string; - data?: Record; - }) { - if (isResponse) { - responseData = { - ...responseData, - ...data - }; - } - - if (answer) { - answerText += answer; - } - - storeData = { - ...storeData, - ...data - }; - } - function moduleInput( - module: RunningModuleItemType, - data: Record = {} - ): Promise { - const checkInputFinish = () => { - return !module.inputs.find((item: any) => item.value === undefined); - }; - const updateInputValue = (key: string, value: any) => { - const index = module.inputs.findIndex((item: any) => item.key === key); - if (index === -1) return; - module.inputs[index].value = value; - }; - - const set = new Set(); - - return Promise.all( - Object.entries(data).map(([key, val]: any) => { - updateInputValue(key, val); - - if (!set.has(module.moduleId) && checkInputFinish()) { - set.add(module.moduleId); - return moduleRun(module); - } - }) - ); - } - function moduleOutput( - module: RunningModuleItemType, - result: Record = {} - ): Promise { - return Promise.all( - module.outputs.map((outputItem) => { - if (result[outputItem.key] === undefined) return; - /* update output value */ - outputItem.value = result[outputItem.key]; - - pushStore({ - isResponse: outputItem.response, - answer: outputItem.answer ? outputItem.value : '', - data: { - [outputItem.key]: outputItem.value - } - }); - - /* update target */ - return Promise.all( - outputItem.targets.map((target: any) => { - // find module - const targetModule = runningModules.find((item) => item.moduleId === target.moduleId); - if (!targetModule) return; - return moduleInput(targetModule, { [target.key]: outputItem.value }); - }) - ); - }) - ); - } - async function moduleRun(module: RunningModuleItemType): Promise { - if (res.closed) return Promise.resolve(); - console.log('run=========', module.type, module.url); - - // direct answer - if (module.type === AppModuleItemTypeEnum.answer) { - const text = - module.inputs.find((item) => item.key === SpecificInputEnum.answerText)?.value || ''; - pushStore({ - answer: text - }); - return StreamAnswer({ - res, - stream, - text: text - }); - } - - if (module.type === AppModuleItemTypeEnum.switch) { - return moduleOutput(module, switchResponse(module)); - } - - if ( - (module.type === AppModuleItemTypeEnum.http || - module.type === AppModuleItemTypeEnum.initInput) && - module.url - ) { - // get fetch params - const params: Record = {}; - module.inputs.forEach((item: any) => { - params[item.key] = item.value; - }); - const data = { - stream, - ...params - }; - - // response data - const fetchRes = await moduleFetch({ - res, - url: module.url, - data - }); - - return moduleOutput(module, fetchRes); - } - } - - // start process width initInput - const initModules = runningModules.filter( - (item) => item.type === AppModuleItemTypeEnum.initInput - ); - - await Promise.all(initModules.map((module) => moduleInput(module, params))); - - return { - responseData, - answerText - }; -} - -function loadModules( - modules: AppModuleItemType[], - variables: Record -): RunningModuleItemType[] { - return modules.map((module) => { - return { - moduleId: module.moduleId, - type: module.type, - url: module.url, - inputs: module.inputs - .filter((item) => item.type !== FlowInputItemTypeEnum.target || item.connected) // filter unconnected target input - .map((item) => { - if (typeof item.value !== 'string') { - return { - key: item.key, - value: item.value - }; - } - - // variables replace - const replacedVal = item.value.replace( - /{{(.*?)}}/g, - (match, key) => variables[key.trim()] || match - ); - - return { - key: item.key, - value: replacedVal - }; - }), - outputs: module.outputs.map((item) => ({ - key: item.key, - answer: item.key === SpecificInputEnum.answerText, - response: item.response, - value: undefined, - targets: item.targets - })) - }; - }); -} -function StreamAnswer({ - res, - stream = false, - text = '' -}: { - res: NextApiResponse; - stream?: boolean; - text?: string; -}) { - if (stream && text) { - return sseResponse({ - res, - event: sseResponseEventEnum.answer, - data: textAdaptGptResponse({ - text: text.replace(/\\n/g, '\n') - }) - }); - } - return text; -} -function switchResponse(module: RunningModuleItemType) { - const val = module?.inputs?.[0]?.value; - - if (val) { - return { true: 1 }; - } - return { false: 1 }; -} diff --git a/client/src/pages/api/openapi/v1/chat/getHistory.ts b/client/src/pages/api/openapi/v1/chat/getHistory.ts index 2531f4b8b..ceeaba3ef 100644 --- a/client/src/pages/api/openapi/v1/chat/getHistory.ts +++ b/client/src/pages/api/openapi/v1/chat/getHistory.ts @@ -7,7 +7,7 @@ import { Types } from 'mongoose'; import type { ChatItemType } from '@/types/chat'; export type Props = { - chatId?: string; + historyId?: string; limit?: number; }; export type Response = { history: ChatItemType[] }; @@ -16,11 +16,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) try { await connectToDatabase(); const { userId } = await authUser({ req }); - const { chatId, limit } = req.body as Props; + const { historyId, limit } = req.body as Props; jsonRes(res, { data: await getChatHistory({ - chatId, + historyId, userId, limit }) @@ -34,16 +34,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } export async function getChatHistory({ - chatId, + historyId, userId, limit = 50 }: Props & { userId: string }): Promise { - if (!chatId) { + if (!historyId) { return { history: [] }; } const history = await Chat.aggregate([ - { $match: { _id: new Types.ObjectId(chatId), userId: new Types.ObjectId(userId) } }, + { $match: { _id: new Types.ObjectId(historyId), userId: new Types.ObjectId(userId) } }, { $project: { content: { diff --git a/client/src/pages/app/detail/components/edit/components/ChatTest.tsx b/client/src/pages/app/detail/components/edit/components/ChatTest.tsx index 09e609f4a..c4197d756 100644 --- a/client/src/pages/app/detail/components/edit/components/ChatTest.tsx +++ b/client/src/pages/app/detail/components/edit/components/ChatTest.tsx @@ -58,7 +58,6 @@ const ChatTest = ( ?.find((item) => item.flowType === FlowModuleTypeEnum.historyNode) ?.inputs?.find((item) => item.key === 'maxContext')?.value || 0; const history = messages.slice(-historyMaxLen - 2, -2); - console.log(history, 'history===='); // 流请求,获取数据 const { responseText } = await streamFetch({ @@ -87,8 +86,6 @@ const ChatTest = ( useImperativeHandle(ref, () => ({ resetChatTest() { - console.log(ChatBoxRef.current, '==='); - ChatBoxRef.current?.resetHistory([]); ChatBoxRef.current?.resetVariables(); } @@ -147,6 +144,7 @@ const ChatTest = ( variableModules={variableModules} welcomeText={welcomeText} onStartChat={startChat} + onDelMessage={() => {}} /> diff --git a/client/src/pages/appStore/components/list.tsx b/client/src/pages/appStore/components/list.tsx index 4aaca49a8..6e8c92ea6 100644 --- a/client/src/pages/appStore/components/list.tsx +++ b/client/src/pages/appStore/components/list.tsx @@ -12,7 +12,7 @@ const ShareModelList = ({ onclickCollection }: { models: ShareAppItem[]; - onclickCollection: (modelId: string) => void; + onclickCollection: (appId: string) => void; }) => { const router = useRouter(); diff --git a/client/src/pages/appStore/index.tsx b/client/src/pages/appStore/index.tsx index 58ca35bd2..7db2d5537 100644 --- a/client/src/pages/appStore/index.tsx +++ b/client/src/pages/appStore/index.tsx @@ -12,8 +12,6 @@ const modelList = () => { const { Loading } = useLoading(); const lastSearch = useRef(''); const [searchText, setSearchText] = useState(''); - const { refreshModel } = useUserStore(); - /* 加载模型 */ const { data: models, @@ -30,16 +28,15 @@ const modelList = () => { }); const onclickCollection = useCallback( - async (modelId: string) => { + async (appId: string) => { try { - await triggerModelCollection(modelId); + await triggerModelCollection(appId); getData(pageNum); - refreshModel.removeModelDetail(modelId); } catch (error) { console.log(error); } }, - [getData, pageNum, refreshModel] + [getData, pageNum] ); return ( diff --git a/client/src/pages/chat/components/ChatHistorySlider.tsx b/client/src/pages/chat/components/ChatHistorySlider.tsx index 3ff4a7adc..23e2b4b62 100644 --- a/client/src/pages/chat/components/ChatHistorySlider.tsx +++ b/client/src/pages/chat/components/ChatHistorySlider.tsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { AddIcon } from '@chakra-ui/icons'; +import React, { useMemo } from 'react'; import { Box, Button, @@ -10,13 +9,16 @@ import { MenuList, MenuItem } from '@chakra-ui/react'; -import { useRouter } from 'next/router'; import MyIcon from '@/components/Icon'; -import type { ShareChatHistoryItemType, ExportChatType } from '@/types/chat'; -import { useChatStore } from '@/store/chat'; import { useGlobalStore } from '@/store/global'; import Avatar from '@/components/Avatar'; +type HistoryItemType = { + id: string; + title: string; + top?: boolean; +}; + const ChatHistorySlider = ({ appName, appAvatar, @@ -24,23 +26,26 @@ const ChatHistorySlider = ({ activeHistoryId, onChangeChat, onDelHistory, + onSetHistoryTop, onCloseSlider }: { appName: string; appAvatar: string; - history: { - id: string; - title: string; - }[]; + history: HistoryItemType[]; activeHistoryId: string; onChangeChat: (historyId?: string) => void; onDelHistory: (historyId: string) => void; + onSetHistoryTop?: (e: { historyId: string; top: boolean }) => void; onCloseSlider: () => void; }) => { - const router = useRouter(); const theme = useTheme(); const { isPc } = useGlobalStore(); + const concatHistory = useMemo( + () => (!activeHistoryId ? [{ id: activeHistoryId, title: '新对话' }].concat(history) : history), + [activeHistoryId, history] + ); + return ( {isPc && ( - + {appName} @@ -60,7 +65,7 @@ const ChatHistorySlider = ({ )} {/* 新对话 */} - + - {models.length > 1 && ( - - - - - - )} - - )} - {/* chat history */} - - {history.map((item) => ( - { - if (item._id === chatId) return; - if (isPc) { - router.replace(`/chat?appId=${item.modelId}&chatId=${item._id}`); - } else { - router.push(`/chat?appId=${item.modelId}&chatId=${item._id}`); - } - }} - onContextMenu={(e) => onclickContextMenu(e, item)} - > - - - - - {item.title} - - - {formatTimeToChatTime(item.updateTime)} - - - - {item.latestChat || '……'} - - - {/* phone quick delete */} - {!isPc && ( - { - e.stopPropagation(); - setIsLoading(true); - try { - await onclickDelHistory(item._id); - } catch (error) { - console.log(error); - } - setIsLoading(false); - }} - /> - )} - - ))} - {!isLoadingHistory && history.length === 0 && ( - - - - 还没有聊天记录 - - - )} - - {/* context menu */} - {contextMenuData && ( - - - - - { - try { - await putChatHistory({ - chatId: contextMenuData.history._id, - top: !contextMenuData.history.top - }); - loadHistory({ pageNum: 1, init: true }); - } catch (error) {} - }} - > - {contextMenuData.history.top ? '取消置顶' : '置顶'} - - { - setIsLoading(true); - try { - await onclickDelHistory(contextMenuData.history._id); - if (contextMenuData.history._id === chatId) { - router.replace(`/chat?appId=${modelId}`); - } - } catch (error) { - console.log(error); - } - setIsLoading(false); - }} - > - 删除记录 - - - onOpenModal({ - defaultVal: contextMenuData.history.title, - onSuccess: async (val: string) => { - await putChatHistory({ - chatId: contextMenuData.history._id, - customTitle: val, - top: contextMenuData.history.top - }); - toast({ - title: '自定义标题成功', - status: 'success' - }); - loadHistory({ pageNum: 1, init: true }); - }, - onError(err) { - toast({ - title: getErrText(err), - status: 'error' - }); - } - }) - } - > - 自定义标题 - - onclickExportChat('html')}>导出HTML格式 - onclickExportChat('pdf')}>导出PDF格式 - onclickExportChat('md')}>导出Markdown格式 - - - - )} - - - - ); -}; - -export default PcSliderBar; diff --git a/client/src/pages/chat/components/ModelList.tsx b/client/src/pages/chat/components/ModelList.tsx deleted file mode 100644 index 6ec39ed18..000000000 --- a/client/src/pages/chat/components/ModelList.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import { Box, Flex } from '@chakra-ui/react'; -import { useRouter } from 'next/router'; -import { AppListItemType } from '@/types/app'; -import Avatar from '@/components/Avatar'; - -const ModelList = ({ models, modelId }: { models: AppListItemType[]; modelId: string }) => { - const router = useRouter(); - - return ( - <> - {models.map((item) => ( - - { - router.replace(`/chat?appId=${item._id}`); - }} - > - - - - {item.name} - - - - - ))} - - ); -}; - -export default ModelList; diff --git a/client/src/pages/chat/components/PhoneSliderBar.tsx b/client/src/pages/chat/components/PhoneSliderBar.tsx deleted file mode 100644 index cdec7b912..000000000 --- a/client/src/pages/chat/components/PhoneSliderBar.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import { AddIcon, ChatIcon } from '@chakra-ui/icons'; -import { - Box, - Button, - Flex, - Divider, - useDisclosure, - useColorMode, - useColorModeValue -} from '@chakra-ui/react'; -import { useUserStore } from '@/store/user'; -import { useQuery } from '@tanstack/react-query'; -import { useRouter } from 'next/router'; -import MyIcon from '@/components/Icon'; -import WxConcat from '@/components/WxConcat'; -import { delChatHistoryById } from '@/api/chat'; -import { useChatStore } from '@/store/chat'; -import Avatar from '@/components/Avatar'; -import Tabs from '@/components/Tabs'; - -enum TabEnum { - app = 'app', - history = 'history' -} - -const PhoneSliderBar = ({ - chatId, - modelId, - onClose -}: { - chatId: string; - modelId: string; - onClose: () => void; -}) => { - const router = useRouter(); - const [currentTab, setCurrentTab] = useState(TabEnum.app); - const { myApps, myCollectionApps, loadMyModels } = useUserStore(); - const { isOpen: isOpenWx, onOpen: onOpenWx, onClose: onCloseWx } = useDisclosure(); - - const models = useMemo(() => [...myApps, ...myCollectionApps], [myCollectionApps, myApps]); - useQuery(['loadModels'], loadMyModels); - - const { history, loadHistory } = useChatStore(); - useQuery(['loadingHistory'], () => loadHistory({ pageNum: 1 })); - - const RenderButton = ({ - onClick, - children - }: { - onClick: () => void; - children: JSX.Element | string; - }) => ( - - - {children} - - - ); - - return ( - - - setCurrentTab(e)} - /> - {/* 新对话 */} - {currentTab === TabEnum.app && ( - - )} - - {/* 我的模型 & 历史记录 折叠框*/} - - {currentTab === TabEnum.app && ( - <> - {models.map((item) => ( - { - if (item._id === modelId) return; - router.replace(`/chat?appId=${item._id}`); - onClose(); - }} - > - - - {item.name} - - - ))} - - )} - {currentTab === TabEnum.history && ( - <> - {history.map((item) => ( - { - if (item._id === chatId) return; - router.replace(`/chat?appId=${item.modelId}&chatId=${item._id}`); - onClose(); - }} - > - - - {item.title} - - - { - e.stopPropagation(); - console.log(111); - await delChatHistoryById(item._id); - loadHistory({ pageNum: 1, init: true }); - if (item._id === chatId) { - router.replace(`/chat?appId=${modelId}`); - } - }} - /> - - - ))} - - )} - - - - - router.push('/model')}> - <> - - 退出聊天 - - - - <> - - 交流群 - - - - {/* wx 联系 */} - {isOpenWx && } - - ); -}; - -export default PhoneSliderBar; diff --git a/client/src/pages/chat/components/SliderApps.tsx b/client/src/pages/chat/components/SliderApps.tsx new file mode 100644 index 000000000..88dcd87ad --- /dev/null +++ b/client/src/pages/chat/components/SliderApps.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Flex, Box, IconButton } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import { useUserStore } from '@/store/user'; +import { useQuery } from '@tanstack/react-query'; +import MyIcon from '@/components/Icon'; +import Avatar from '@/components/Avatar'; + +const SliderApps = ({ appId }: { appId: string }) => { + const router = useRouter(); + const { myApps, loadMyModels } = useUserStore(); + + useQuery(['loadModels'], loadMyModels); + + return ( + <> + router.replace('/app/list')} + > + } + bg={'white'} + boxShadow={'1px 1px 9px rgba(0,0,0,0.15)'} + h={'28px'} + size={'sm'} + borderRadius={'50%'} + aria-label={''} + /> + 退出聊天 + + + {myApps.map((item) => ( + { + router.replace({ + query: { + appId: item._id + } + }); + } + })} + > + + + {item.name} + + + ))} + + + ); +}; + +export default SliderApps; diff --git a/client/src/pages/chat/index.tsx b/client/src/pages/chat/index.tsx index bc6982775..a8eddf48c 100644 --- a/client/src/pages/chat/index.tsx +++ b/client/src/pages/chat/index.tsx @@ -1,17 +1,15 @@ -import React, { useCallback, useState, useRef, useMemo, useEffect, MouseEvent } from 'react'; +import React, { useCallback, useState, useRef } from 'react'; import { useRouter } from 'next/router'; -import { getInitChatSiteInfo, delChatRecordByIndex, delChatHistoryById } from '@/api/chat'; -import type { ChatItemType, ChatSiteItemType, ExportChatType } from '@/types/chat'; import { - Textarea, + getInitChatSiteInfo, + delChatRecordByIndex, + delChatHistoryById, + putChatHistory +} from '@/api/chat'; +import { Box, Flex, useColorModeValue, - Menu, - MenuButton, - MenuList, - MenuItem, - Button, Modal, ModalOverlay, ModalContent, @@ -22,895 +20,329 @@ import { Drawer, DrawerOverlay, DrawerContent, - Card, - useOutsideClick, useTheme } from '@chakra-ui/react'; import { useToast } from '@/hooks/useToast'; import { useGlobalStore } from '@/store/global'; import { useQuery } from '@tanstack/react-query'; import dynamic from 'next/dynamic'; -import { useCopyData, voiceBroadcast, hasVoiceApi, delay } from '@/utils/tools'; import { streamFetch } from '@/api/fetch'; import MyIcon from '@/components/Icon'; -import { throttle } from 'lodash'; -import { Types } from 'mongoose'; -import { ChatModelMap } from '@/constants/model'; import { useChatStore } from '@/store/chat'; import { useLoading } from '@/hooks/useLoading'; -import { fileDownload } from '@/utils/file'; -import { htmlTemplate } from '@/constants/common'; -import { useUserStore } from '@/store/user'; -import Loading from '@/components/Loading'; + +import ChatBox, { type ComponentRef, type StartChatFnProps } from '@/components/ChatBox'; +import PageContainer from '@/components/PageContainer'; import SideBar from '@/components/SideBar'; -import Avatar from '@/components/Avatar'; -import Empty from './components/Empty'; -import QuoteModal from './components/QuoteModal'; -import { HUMAN_ICON } from '@/constants/chat'; -import MyTooltip from '@/components/MyTooltip'; - -const Markdown = dynamic(async () => await import('@/components/Markdown')); -const PhoneSliderBar = dynamic(() => import('./components/PhoneSliderBar'), { - ssr: false -}); -const History = dynamic(() => import('./components/History'), { - loading: () => , - ssr: false -}); - -import styles from './index.module.scss'; -import { adaptChatItem_openAI } from '@/utils/plugin/openai'; - -const textareaMinH = '22px'; +import ChatHistorySlider from './components/ChatHistorySlider'; +import SliderApps from './components/SliderApps'; +import Tag from '@/components/Tag'; +import { ChatHistoryItemType } from '@/types/chat'; const Chat = () => { const router = useRouter(); - const { appId = '', chatId = '' } = router.query as { appId: string; chatId: string }; + const { appId = '', historyId = '' } = router.query as { appId: string; historyId: string }; const theme = useTheme(); - const ChatBox = useRef(null); - const TextareaDom = useRef(null); - const ContextMenuRef = useRef(null); - const PhoneContextShow = useRef(false); - - // 中断请求 - const controller = useRef(new AbortController()); - const isLeavePage = useRef(false); + const ChatBoxRef = useRef(null); + const forbidRefresh = useRef(false); const [showHistoryQuote, setShowHistoryQuote] = useState(); const [showSystemPrompt, setShowSystemPrompt] = useState(''); - const [messageContextMenuData, setMessageContextMenuData] = useState<{ - left: number; - top: number; - message: ChatSiteItemType; - }>(); const { - lastChatModelId, - setLastChatModelId, + lastChatAppId, + setLastChatAppId, lastChatId, setLastChatId, + history, loadHistory, + updateHistory, chatData, - setChatData, - forbidLoadChatData, - setForbidLoadChatData + setChatData } = useChatStore(); - const isChatting = useMemo( - () => chatData.history[chatData.history.length - 1]?.status === 'loading', - [chatData.history] - ); - - const { toast } = useToast(); - const { copyData } = useCopyData(); const { isPc } = useGlobalStore(); const { Loading, setIsLoading } = useLoading(); - const { userInfo } = useUserStore(); const { isOpen: isOpenSlider, onClose: onCloseSlider, onOpen: onOpenSlider } = useDisclosure(); - // close contextMenu - useOutsideClick({ - ref: ContextMenuRef, - handler: () => { - // 移动端长按后会将其设置为true,松手时候也会触发一次,松手的时候需要忽略一次。 - if (PhoneContextShow.current) { - PhoneContextShow.current = false; + const startChat = useCallback( + async ({ messages, controller, generatingMessage, variables }: StartChatFnProps) => { + const prompts = messages.slice(-2); + const { responseText, newHistoryId } = await streamFetch({ + data: { + messages: prompts, + variables, + appId, + historyId + }, + onMessage: generatingMessage, + abortSignal: controller + }); + + const newTitle = prompts[0].content?.slice(0, 20) || '新对话'; + + // update history + if (newHistoryId) { + forbidRefresh.current = true; + router.replace({ + query: { + historyId: newHistoryId, + appId + } + }); + const newHistory: ChatHistoryItemType = { + _id: newHistoryId, + updateTime: new Date(), + title: newTitle, + appId, + top: false + }; + updateHistory(newHistory); } else { - messageContextMenuData && - setTimeout(() => { - setMessageContextMenuData(undefined); - window.getSelection?.()?.empty?.(); - window.getSelection?.()?.removeAllRanges?.(); - document?.getSelection()?.empty(); + const currentHistory = history.find((item) => item._id === historyId); + currentHistory && + updateHistory({ + ...currentHistory, + updateTime: new Date(), + title: newTitle }); } - } - }); - - // 滚动到底部 - const scrollToBottom = useCallback((behavior: 'smooth' | 'auto' = 'smooth') => { - if (!ChatBox.current) return; - ChatBox.current.scrollTo({ - top: ChatBox.current.scrollHeight, - behavior - }); - }, []); - - // 聊天信息生成中……获取当前滚动条位置,判断是否需要滚动到底部 - // eslint-disable-next-line react-hooks/exhaustive-deps - const generatingMessage = useCallback( - throttle(() => { - if (!ChatBox.current) return; - const isBottom = - ChatBox.current.scrollTop + ChatBox.current.clientHeight + 150 >= - ChatBox.current.scrollHeight; - - isBottom && scrollToBottom('auto'); - }, 100), - [] - ); - - // 重置输入内容 - 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); - }, []); - - // gpt 对话 - const gptChatPrompt = 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 }); - - // 流请求,获取数据 - const { newChatId, errMsg } = await streamFetch({ - data: { - messages, - chatId, - appId, - model: '' - }, - onMessage: (text: string) => { - setChatData((state) => ({ - ...state, - history: state.history.map((item, index) => { - if (index !== state.history.length - 1) return item; - return { - ...item, - value: item.value + text - }; - }) - })); - generatingMessage(); - }, - abortSignal - }); - - // 重置了页面,说明退出了当前聊天, 不缓存任何内容 - if (isLeavePage.current) { - return; - } - - // save chat - if (newChatId) { - setForbidLoadChatData(true); - router.replace(`/chat?appId=${appId}&chatId=${newChatId}`); - } - - abortSignal.signal.aborted && (await delay(500)); - - // 设置聊天内容为完成状态 + // update chat window setChatData((state) => ({ ...state, - chatId: newChatId || state.chatId, // 如果有 Id,说明是新创建的对话 - history: state.history.map((item, index) => { - if (index !== state.history.length - 1) return item; - return { - ...item, - status: 'finish', - quoteLen: 0, - systemPrompt: `${chatData.systemPrompt}${`${ - chatData.limitPrompt ? `\n\n${chatData.limitPrompt}` : '' - }`}` - }; - }) + title: newTitle, + history: ChatBoxRef.current?.getChatHistory() || state.history })); - // refresh data - setTimeout(() => { - generatingMessage(); - loadHistory({ pageNum: 1, init: true }); - }, 100); - - if (errMsg) { - toast({ - status: 'warning', - title: errMsg - }); - } + return { responseText }; }, - [ - chatId, - appId, - setChatData, - generatingMessage, - setForbidLoadChatData, - router, - chatData.systemPrompt, - chatData.limitPrompt, - loadHistory, - toast - ] + [appId, history, historyId, router, setChatData, updateHistory] ); - /** - * 发送一个内容 - */ - const sendPrompt = useCallback(async () => { - // get value - 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[] = [ - ...chatData.history, - { - _id: String(new Types.ObjectId()), - obj: 'Human', - value: val, - status: 'finish' - }, - { - _id: String(new Types.ObjectId()), - obj: 'AI', - value: '', - status: 'loading' - } - ]; - - // 插入内容 - setChatData((state) => ({ - ...state, - history: newChatList - })); - - // 清空输入内容 - resetInputVal(''); - setTimeout(() => { - scrollToBottom(); - }, 100); - - try { - await gptChatPrompt(newChatList.slice(newChatList.length - 2)); - } catch (err: any) { - toast({ - title: typeof err === 'string' ? err : err?.message || '聊天出错了~', - status: 'warning', - duration: 5000, - isClosable: true - }); - - resetInputVal(value); - - setChatData((state) => ({ - ...state, - history: newChatList.slice(0, newChatList.length - 2) - })); - } - }, [ - isChatting, - chatData.history, - setChatData, - resetInputVal, - toast, - scrollToBottom, - gptChatPrompt - ]); - // 删除一句话 - const delChatRecord = useCallback( - async (index: number, historyId?: string) => { - if (!messageContextMenuData || !historyId) return; - setIsLoading(true); + const delOneHistoryItem = useCallback( + async ({ contentId, index }: { contentId?: string; index: number }) => { + if (!historyId || !contentId) return; try { - // 删除数据库最后一句 - await delChatRecordByIndex(chatId, historyId); - setChatData((state) => ({ ...state, history: state.history.filter((_, i) => i !== index) })); + await delChatRecordByIndex({ historyId, contentId }); } catch (err) { console.log(err); } - setIsLoading(false); }, - [chatId, messageContextMenuData, setChatData, setIsLoading] + [historyId, setChatData] ); - - // 复制内容 - const onclickCopy = useCallback( - (value: string) => { - const val = value.replace(/\n+/g, '\n'); - copyData(val); - }, - [copyData] - ); - - // export chat data - const onclickExportChat = useCallback( - (type: ExportChatType) => { - const getHistoryHtml = () => { - const historyDom = document.getElementById('history'); - if (!historyDom) return; - const dom = Array.from(historyDom.children).map((child, i) => { - const avatar = ``; - - const chatContent = child.querySelector('.markdown'); - - if (!chatContent) { - return ''; - } - - const chatContentClone = chatContent.cloneNode(true) as HTMLDivElement; - - const codeHeader = chatContentClone.querySelectorAll('.code-header'); - codeHeader.forEach((childElement: any) => { - childElement.remove(); - }); - - return `
- ${avatar} - ${chatContentClone.outerHTML} -
`; - }); - - const html = htmlTemplate.replace('{{CHAT_CONTENT}}', dom.join('\n')); - return html; - }; - - const map: Record void> = { - md: () => { - fileDownload({ - text: chatData.history.map((item) => item.value).join('\n\n'), - type: 'text/markdown', - filename: 'chat.md' - }); - }, - html: () => { - const html = getHistoryHtml(); - html && - fileDownload({ - text: html, - type: 'text/html', - filename: '聊天记录.html' - }); - }, - pdf: () => { - const html = getHistoryHtml(); - - html && - // @ts-ignore - html2pdf(html, { - margin: 0, - filename: `聊天记录.pdf` - }); - } - }; - - map[type](); - }, - [chatData.history] - ); - - // delete history and reload history - const onclickDelHistory = useCallback( + // delete a history + const delHistoryById = useCallback( async (historyId: string) => { await delChatHistoryById(historyId); - loadHistory({ pageNum: 1, init: true }); + loadHistory({ appId }); }, - [loadHistory] + [appId, loadHistory] ); - // onclick chat message context - const onclickContextMenu = useCallback( - (e: MouseEvent, message: ChatSiteItemType) => { - e.preventDefault(); // 阻止默认右键菜单 - - // select all text - const range = document.createRange(); - range.selectNodeContents(e.currentTarget as HTMLDivElement); - window.getSelection()?.removeAllRanges(); - window.getSelection()?.addRange(range); - - navigator.vibrate?.(50); // 震动 50 毫秒 - - if (!isPc) { - PhoneContextShow.current = true; - } - - setMessageContextMenuData({ - left: e.clientX - 20, - top: e.clientY, - message - }); - - return false; - }, - [isPc] - ); - - // 获取对话信息 + // get chat app info const loadChatInfo = useCallback( async ({ appId, - chatId, + historyId, loading = false }: { appId: string; - chatId: string; + historyId: string; loading?: boolean; }) => { try { loading && setIsLoading(true); - const res = await getInitChatSiteInfo(appId, chatId); + const res = await getInitChatSiteInfo({ appId, historyId }); + const history = res.history.map((item) => ({ + ...item, + status: 'finish' as any + })); setChatData({ ...res, - history: res.history.map((item) => ({ - ...item, - status: 'finish' - })) + history }); // have records. + ChatBoxRef.current?.resetHistory(history); + ChatBoxRef.current?.resetVariables(res.variables); if (res.history.length > 0) { setTimeout(() => { - scrollToBottom('auto'); - }, 300); + ChatBoxRef.current?.scrollToBottom('auto'); + }, 200); } - // 空 modelId 请求, 重定向到新的 model 聊天 - if (res.modelId !== appId) { - setForbidLoadChatData(true); - router.replace(`/chat?appId=${res.modelId}`); + // empty appId request, return first app + if (res.appId !== appId) { + forbidRefresh.current = true; + router.replace({ + query: { + appId: res.appId + } + }); } } catch (e: any) { // reset all chat tore - setLastChatModelId(''); + setLastChatAppId(''); setLastChatId(''); - setChatData(); - loadHistory({ pageNum: 1, init: true }); router.replace('/chat'); } setIsLoading(false); return null; }, - [ - setIsLoading, - setChatData, - scrollToBottom, - setForbidLoadChatData, - router, - setLastChatModelId, - setLastChatId, - loadHistory - ] + [setIsLoading, setChatData, router, setLastChatAppId, setLastChatId] ); // 初始化聊天框 - useQuery(['init', appId, chatId], () => { + useQuery(['init', appId, historyId], () => { // pc: redirect to latest model chat - if (!appId && lastChatModelId) { - router.replace(`/chat?appId=${lastChatModelId}&chatId=${lastChatId}`); + if (!appId && lastChatAppId) { + router.replace({ + query: { + appId: lastChatAppId, + historyId: lastChatId + } + }); return null; } // store id - appId && setLastChatModelId(appId); - setLastChatId(chatId); + appId && setLastChatAppId(appId); + setLastChatId(historyId); - if (forbidLoadChatData) { - setForbidLoadChatData(false); + if (forbidRefresh.current) { + forbidRefresh.current = false; return null; } return loadChatInfo({ appId, - chatId, - loading: true + historyId, + loading: appId !== chatData.appId }); }); - // abort stream - useEffect(() => { - return () => { - window.speechSynthesis?.cancel(); - isLeavePage.current = true; - controller.current?.abort(); - }; - }, [appId, chatId]); - - // context menu component - const RenderContextMenu = useCallback( - ({ - history, - index, - AiDetail = false - }: { - history: ChatSiteItemType; - index: number; - AiDetail?: boolean; - }) => ( - - onclickCopy(history.value)}>复制 - {AiDetail && chatData.model.canUse && history.obj === 'AI' && ( - router.push(`/model?modelId=${chatData.modelId}`)} - > - 应用详情 - - )} - {hasVoiceApi && ( - voiceBroadcast({ text: history.value })} - > - 语音播报 - - )} - - delChatRecord(index, history._id)}>删除 - - ), - [ - chatData.model.canUse, - chatData.modelId, - delChatRecord, - onclickCopy, - router, - theme.borders.base - ] - ); + useQuery(['loadHistory', appId], () => (appId ? loadHistory({ appId }) : null)); return ( - - {/* pc always show history. */} - {(isPc || !appId) && ( - - - + + {/* pc show myself apps */} + {isPc && ( + + + )} - {/* 聊天内容 */} - {appId && ( - - {/* chat header */} - - {!isPc && ( - - )} - router.push(`/model?modelId=${chatData.modelId}`)} - > - {chatData.model.name} {ChatModelMap[chatData.chatModel]?.name} - {chatData.history.length > 0 ? ` (${chatData.history.length})` : ''} - - {chatId ? ( - - - - - - router.replace(`/chat?appId=${appId}`)}>新对话 - { - try { - setIsLoading(true); - await onclickDelHistory(chatData.chatId); - router.replace(`/chat?appId=${appId}`); - } catch (err) { - console.log(err); - } - setIsLoading(false); - }} - > - 删除记录 - - onclickExportChat('html')}>导出HTML格式 - onclickExportChat('pdf')}>导出PDF格式 - onclickExportChat('md')}>导出Markdown格式 - - + + + {/* pc always show history. */} + {((children: React.ReactNode) => { + return isPc || !appId ? ( + {children} ) : ( - - )} - - {/* chat content box */} - - - {chatData.history.map((item, index) => ( - - {item.obj === 'Human' && } - {/* avatar */} - - - - isPc && - chatData.model.canUse && - router.push(`/model?modelId=${chatData.modelId}`) - } - : { - order: 3, - ml: ['6px', 2] - })} - > - - - - {!isPc && } - - {/* message */} - - {item.obj === 'AI' ? ( - - onclickContextMenu(e, item)} - > - - - {!!item.systemPrompt && ( - - )} - {!!item.quoteLen && ( - - )} - - - - ) : ( - - onclickContextMenu(e, item)} - > - {item.value} - - - )} - - - ))} - {chatData.history.length === 0 && ( - - )} - - - {/* 发送区 */} - {chatData.model.canUse ? ( - - - {/* 输入框 */} -