feat: share chat page

This commit is contained in:
archer
2023-05-14 21:37:26 +08:00
parent d3e7923040
commit d31bdf0ee0
23 changed files with 2047 additions and 65 deletions

View File

@@ -1,7 +1,10 @@
import { GET, POST, DELETE } from './request';
import type { ChatItemType, HistoryItemType } from '@/types/chat';
import type { InitChatResponse } from './response/chat';
import type { InitChatResponse, InitShareChatResponse } from './response/chat';
import { RequestPaging } from '../types/index';
import type { ShareChatSchema } from '@/types/mongoSchema';
import type { ShareChatEditType } from '@/types/model';
import { Obj2Query } from '@/utils/tools';
/**
* 获取初始化聊天内容
@@ -35,3 +38,29 @@ export const postSaveChat = (data: {
*/
export const delChatRecordByIndex = (chatId: string, contentId: string) =>
DELETE(`/chat/delChatRecordByContentId?chatId=${chatId}&contentId=${contentId}`);
/**
* create a shareChat
*/
export const createShareChat = (
data: ShareChatEditType & {
modelId: string;
}
) => POST<string>(`/chat/shareChat/create`, data);
/**
* get shareChat
*/
export const getShareChatList = (modelId: string) =>
GET<ShareChatSchema[]>(`/chat/shareChat/list?modelId=${modelId}`);
/**
* delete a shareChat
*/
export const delShareChatById = (id: string) => DELETE(`/chat/shareChat/delete?id=${id}`);
/**
* 初始化分享聊天
*/
export const initShareChatInfo = (data: { shareId: string; password: string }) =>
GET<InitShareChatResponse>(`/chat/shareChat/init?${Obj2Query(data)}`);

View File

@@ -13,3 +13,13 @@ export interface InitChatResponse {
chatModel: ModelSchema['chat']['chatModel']; // 对话模型名
history: ChatItemType[];
}
export interface InitShareChatResponse {
maxContext: number;
model: {
name: string;
avatar: string;
intro: string;
};
chatModel: ModelSchema['chat']['chatModel']; // 对话模型名
}

View File

@@ -10,11 +10,13 @@ import NavbarPhone from './navbarPhone';
const pcUnShowLayoutRoute: Record<string, boolean> = {
'/': true,
'/login': true
'/login': true,
'/chat/share': true
};
const phoneUnShowLayoutRoute: Record<string, boolean> = {
'/': true,
'/login': true
'/login': true,
'/chat/share': true
};
const Layout = ({ children, isPcDevice }: { children: JSX.Element; isPcDevice: boolean }) => {
@@ -67,7 +69,7 @@ const Layout = ({ children, isPcDevice }: { children: JSX.Element; isPcDevice: b
</Flex>
)}
</Box>
{loading && <Loading />}
<Loading loading={loading} />
</>
);
};

View File

@@ -5,7 +5,7 @@ const Loading = ({ fixed = true }: { fixed?: boolean }) => {
return (
<Flex
position={fixed ? 'fixed' : 'absolute'}
zIndex={100}
zIndex={10000}
backgroundColor={'rgba(255,255,255,0.5)'}
top={0}
left={0}

View File

@@ -1,5 +1,6 @@
import { getSystemModelList } from '@/api/system';
import type { ModelSchema } from '@/types/mongoSchema';
import type { ShareChatEditType } from '@/types/model';
export const embeddingModel = 'text-embedding-ada-002';
export type EmbeddingModelType = 'text-embedding-ada-002';
@@ -161,3 +162,9 @@ export const defaultModel: ModelSchema = {
maxLoadAmount: 1
}
};
export const defaultShareChat: ShareChatEditType = {
name: '',
password: '',
maxContext: 5
};

View File

@@ -1,15 +1,10 @@
function Error({ statusCode }: { statusCode: number }) {
return (
<p>
{statusCode ? `An error ${statusCode} occurred on server` : 'An error occurred on client'}
</p>
);
function Error({ errStr }: { errStr: string }) {
return <p>{errStr}</p>;
}
Error.getInitialProps = ({ res, err }: { res: any; err: any }) => {
const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
console.log(err);
return { statusCode };
return { errStr: JSON.stringify(err) };
};
export default Error;

View File

@@ -0,0 +1,130 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { connectToDatabase } from '@/service/mongo';
import { authShareChat } from '@/service/utils/auth';
import { modelServiceToolMap } from '@/service/utils/chat';
import { ChatItemSimpleType } from '@/types/chat';
import { jsonRes } from '@/service/response';
import { PassThrough } from 'stream';
import { ChatModelMap, ModelVectorSearchModeMap } from '@/constants/model';
import { pushChatBill, updateShareChatBill } from '@/service/events/pushBill';
import { resStreamResponse } from '@/service/utils/chat';
import { searchKb } from '@/service/plugins/searchKb';
import { ChatRoleEnum } from '@/constants/chat';
/* 发送提示词 */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
let step = 0; // step=1 时,表示开始了流响应
const stream = new PassThrough();
stream.on('error', () => {
console.log('error: ', 'stream error');
stream.destroy();
});
res.on('close', () => {
stream.destroy();
});
res.on('error', () => {
console.log('error: ', 'request error');
stream.destroy();
});
try {
const { shareId, password, historyId, prompts } = req.body as {
prompts: ChatItemSimpleType[];
password: string;
shareId: string;
historyId: string;
};
if (!historyId || !prompts) {
throw new Error('分享链接无效');
}
await connectToDatabase();
let startTime = Date.now();
const { model, showModelDetail, userOpenAiKey, systemAuthKey, userId } = await authShareChat({
shareId,
password
});
const modelConstantsData = ChatModelMap[model.chat.chatModel];
// 使用了知识库搜索
if (model.chat.useKb) {
const { code, searchPrompts } = await searchKb({
userOpenAiKey,
prompts,
similarity: ModelVectorSearchModeMap[model.chat.searchMode]?.similarity,
model,
userId
});
// search result is empty
if (code === 201) {
return res.send(searchPrompts[0]?.value);
}
prompts.splice(prompts.length - 3, 0, ...searchPrompts);
} else {
// 没有用知识库搜索,仅用系统提示词
model.chat.systemPrompt &&
prompts.splice(prompts.length - 3, 0, {
obj: ChatRoleEnum.System,
value: model.chat.systemPrompt
});
}
// 计算温度
const temperature = (modelConstantsData.maxTemperature * (model.chat.temperature / 10)).toFixed(
2
);
// 发出请求
const { streamResponse } = await modelServiceToolMap[model.chat.chatModel].chatCompletion({
apiKey: userOpenAiKey || systemAuthKey,
temperature: +temperature,
messages: prompts,
stream: true,
res,
chatId: historyId
});
console.log('api response time:', `${(Date.now() - startTime) / 1000}s`);
step = 1;
const { totalTokens, finishMessages } = await resStreamResponse({
model: model.chat.chatModel,
res,
stream,
chatResponse: streamResponse,
prompts,
systemPrompt: ''
});
/* bill */
pushChatBill({
isPay: !userOpenAiKey,
chatModel: model.chat.chatModel,
userId,
textLen: finishMessages.map((item) => item.value).join('').length,
tokens: totalTokens
});
updateShareChatBill({
shareId,
tokens: totalTokens
});
} catch (err: any) {
if (step === 1) {
// 直接结束流
console.log('error结束');
stream.destroy();
} else {
res.status(500);
jsonRes(res, {
code: 500,
error: err
});
}
}
}

View File

@@ -0,0 +1,39 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase, ShareChat } from '@/service/mongo';
import { authModel, authToken } from '@/service/utils/auth';
import type { ShareChatEditType } from '@/types/model';
/* create a shareChat */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { modelId, name, maxContext, password } = req.body as ShareChatEditType & {
modelId: string;
};
await connectToDatabase();
const userId = await authToken(req);
await authModel({
modelId,
userId
});
const { _id } = await ShareChat.create({
userId,
modelId,
name,
password,
maxContext
});
jsonRes(res, {
data: _id
});
} catch (err) {
jsonRes(res, {
code: 500,
error: err
});
}
}

View File

@@ -0,0 +1,29 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase, ShareChat } from '@/service/mongo';
import { authToken } from '@/service/utils/auth';
/* delete a shareChat by shareChatId */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { id } = req.query as {
id: string;
};
await connectToDatabase();
const userId = await authToken(req);
await ShareChat.findOneAndRemove({
_id: id,
userId
});
jsonRes(res);
} catch (err) {
jsonRes(res, {
code: 500,
error: err
});
}
}

View File

@@ -0,0 +1,59 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase, ShareChat } from '@/service/mongo';
import type { InitShareChatResponse } from '@/api/response/chat';
import { authModel } from '@/service/utils/auth';
import { hashPassword } from '@/service/utils/tools';
/* 初始化我的聊天框,需要身份验证 */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
let { shareId, password = '' } = req.query as {
shareId: string;
password: string;
};
if (!shareId) {
throw new Error('params is error');
}
await connectToDatabase();
// get shareChat
const shareChat = await ShareChat.findById(shareId);
if (!shareChat) {
throw new Error('分享链接已失效');
}
if (shareChat.password !== hashPassword(password)) {
return jsonRes(res, {
code: 501,
message: '密码不正确'
});
}
// 校验使用权限
const { model } = await authModel({
modelId: shareChat.modelId,
userId: String(shareChat.userId)
});
jsonRes<InitShareChatResponse>(res, {
data: {
maxContext: shareChat.maxContext,
model: {
name: model.name,
avatar: model.avatar,
intro: model.share.intro
},
chatModel: model.chat.chatModel
}
});
} catch (err) {
jsonRes(res, {
code: 500,
error: err
});
}
}

View File

@@ -0,0 +1,43 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase, ShareChat } from '@/service/mongo';
import { authToken } from '@/service/utils/auth';
import { hashPassword } from '@/service/utils/tools';
/* get shareChat list by modelId */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { modelId } = req.query as {
modelId: string;
};
await connectToDatabase();
const userId = await authToken(req);
const data = await ShareChat.find({
modelId,
userId
}).sort({
_id: -1
});
const blankPassword = hashPassword('');
jsonRes(res, {
data: data.map((item) => ({
_id: item._id,
name: item.name,
password: item.password === blankPassword ? '' : '1',
tokens: item.tokens,
maxContext: item.maxContext,
lastTime: item.lastTime
}))
});
} catch (err) {
jsonRes(res, {
code: 500,
error: err
});
}
}

View File

@@ -26,21 +26,23 @@ const Empty = ({
alignItems={'center'}
justifyContent={'center'}
>
<Card p={4} mb={10}>
<Flex mb={2} alignItems={'center'} justifyContent={'center'}>
<Image
src={avatar || LOGO_ICON}
w={'32px'}
maxH={'40px'}
objectFit={'contain'}
alt={''}
/>
<Box ml={3} fontSize={'3xl'} fontWeight={'bold'}>
{name}
</Box>
</Flex>
<Box whiteSpace={'pre-line'}>{intro}</Box>
</Card>
{name && (
<Card p={4} mb={10}>
<Flex mb={2} alignItems={'center'} justifyContent={'center'}>
<Image
src={avatar || LOGO_ICON}
w={'32px'}
maxH={'40px'}
objectFit={'contain'}
alt={''}
/>
<Box ml={3} fontSize={'3xl'} fontWeight={'bold'}>
{name}
</Box>
</Flex>
<Box whiteSpace={'pre-line'}>{intro}</Box>
</Card>
)}
{/* version intro */}
<Card p={4} mb={10}>
<Markdown source={versionIntro} />

View File

@@ -0,0 +1,203 @@
import React, { useCallback, useRef, useState } from 'react';
import type { MouseEvent } from 'react';
import { AddIcon } from '@chakra-ui/icons';
import {
Box,
Button,
Flex,
useTheme,
Menu,
MenuList,
MenuItem,
useOutsideClick
} from '@chakra-ui/react';
import { ChatIcon } from '@chakra-ui/icons';
import { useRouter } from 'next/router';
import { formatTimeToChatTime } from '@/utils/tools';
import MyIcon from '@/components/Icon';
import type { ShareChatHistoryItemType, ExportChatType } from '@/types/chat';
import { useChatStore } from '@/store/chat';
import { useScreen } from '@/hooks/useScreen';
import styles from '../index.module.scss';
const PcSliderBar = ({
isPcDevice,
onclickDelHistory,
onclickExportChat,
onCloseSlider
}: {
isPcDevice: boolean;
onclickDelHistory: (historyId: string) => void;
onclickExportChat: (type: ExportChatType) => void;
onCloseSlider: () => void;
}) => {
const router = useRouter();
const { shareId = '', historyId = '' } = router.query as { shareId: string; historyId: string };
const theme = useTheme();
const { isPc } = useScreen({ defaultIsPc: isPcDevice });
const ContextMenuRef = useRef(null);
const [contextMenuData, setContextMenuData] = useState<{
left: number;
top: number;
history: ShareChatHistoryItemType;
}>();
const { shareChatHistory } = useChatStore();
// close contextMenu
useOutsideClick({
ref: ContextMenuRef,
handler: () =>
setTimeout(() => {
setContextMenuData(undefined);
})
});
const onclickContextMenu = useCallback(
(e: MouseEvent<HTMLDivElement>, history: ShareChatHistoryItemType) => {
e.preventDefault(); // 阻止默认右键菜单
if (!isPc) return;
setContextMenuData({
left: e.clientX + 15,
top: e.clientY + 10,
history
});
},
[isPc]
);
const replaceChatPage = useCallback(
({ hId = '', shareId }: { hId?: string; shareId: string }) => {
if (hId === historyId) return;
router.replace(`/chat/share?shareId=${shareId}&historyId=${hId}`);
!isPc && onCloseSlider();
},
[historyId, isPc, onCloseSlider, router]
);
return (
<Flex
position={'relative'}
flexDirection={'column'}
w={'100%'}
h={'100%'}
bg={'white'}
borderRight={['', theme.borders.base]}
>
{/* 新对话 */}
<Box
className={styles.newChat}
zIndex={1000}
w={'90%'}
h={'40px'}
my={5}
mx={'auto'}
position={'relative'}
>
<Button
variant={'base'}
w={'100%'}
h={'100%'}
leftIcon={<AddIcon />}
onClick={() => replaceChatPage({ shareId })}
>
</Button>
</Box>
{/* chat history */}
<Box flex={'1 0 0'} h={0} overflow={'overlay'}>
{shareChatHistory.map((item) => (
<Flex
position={'relative'}
key={item._id}
alignItems={'center'}
py={3}
pr={[0, 3]}
pl={[6, 3]}
cursor={'pointer'}
transition={'background-color .2s ease-in'}
borderLeft={['none', '5px solid transparent']}
userSelect={'none'}
_hover={{
backgroundColor: ['', '#dee0e3']
}}
{...(item._id === historyId
? {
backgroundColor: '#eff0f1',
borderLeftColor: 'myBlue.600 !important'
}
: {})}
onClick={() => replaceChatPage({ hId: item._id, shareId: item.shareId })}
onContextMenu={(e) => onclickContextMenu(e, item)}
>
<ChatIcon fontSize={'16px'} color={'myGray.500'} />
<Box flex={'1 0 0'} w={0} ml={3}>
<Flex alignItems={'center'}>
<Box flex={'1 0 0'} w={0} className="textEllipsis" color={'myGray.1000'}>
{item.title}
</Box>
<Box color={'myGray.400'} fontSize={'sm'}>
{formatTimeToChatTime(item.updateTime)}
</Box>
</Flex>
<Box className="textEllipsis" mt={1} fontSize={'sm'} color={'myGray.500'}>
{item.latestChat || '……'}
</Box>
</Box>
{/* phone quick delete */}
{!isPc && (
<MyIcon
px={3}
name={'delete'}
w={'16px'}
onClickCapture={(e) => {
e.stopPropagation();
onclickDelHistory(item._id);
item._id === historyId && replaceChatPage({ shareId: item.shareId });
}}
/>
)}
</Flex>
))}
{shareChatHistory.length === 0 && (
<Flex h={'100%'} flexDirection={'column'} alignItems={'center'} pt={'30vh'}>
<MyIcon name="empty" w={'48px'} h={'48px'} color={'transparent'} />
<Box mt={2} color={'myGray.500'}>
</Box>
</Flex>
)}
</Box>
{/* context menu */}
{contextMenuData && (
<Box zIndex={10} position={'fixed'} top={contextMenuData.top} left={contextMenuData.left}>
<Box ref={ContextMenuRef}></Box>
<Menu isOpen>
<MenuList>
<MenuItem
onClick={() => {
onclickDelHistory(contextMenuData.history._id);
contextMenuData.history._id === historyId && replaceChatPage({ shareId });
}}
>
</MenuItem>
<MenuItem onClick={() => onclickExportChat('html')}>HTML格式</MenuItem>
<MenuItem onClick={() => onclickExportChat('pdf')}>PDF格式</MenuItem>
<MenuItem onClick={() => onclickExportChat('md')}>Markdown格式</MenuItem>
</MenuList>
</Menu>
</Box>
)}
</Flex>
);
};
export default PcSliderBar;

930
src/pages/chat/share.tsx Normal file
View File

@@ -0,0 +1,930 @@
import React, { useCallback, useState, useRef, useMemo, useEffect, MouseEvent } from 'react';
import { useRouter } from 'next/router';
import { initShareChatInfo } from '@/api/chat';
import type { ChatSiteItemType, ExportChatType } from '@/types/chat';
import {
Textarea,
Box,
Flex,
useColorModeValue,
Menu,
MenuButton,
MenuList,
MenuItem,
Image,
Button,
Modal,
ModalOverlay,
ModalContent,
ModalBody,
ModalCloseButton,
useDisclosure,
Drawer,
DrawerOverlay,
DrawerContent,
Card,
Tooltip,
useOutsideClick,
useTheme,
Input,
ModalFooter,
ModalHeader
} from '@chakra-ui/react';
import { useToast } from '@/hooks/useToast';
import { useScreen } from '@/hooks/useScreen';
import { useQuery } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import { useCopyData, voiceBroadcast } from '@/utils/tools';
import { streamFetch } from '@/api/fetch';
import MyIcon from '@/components/Icon';
import { throttle } from 'lodash';
import { Types } from 'mongoose';
import Markdown from '@/components/Markdown';
import { LOGO_ICON } from '@/constants/chat';
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';
const ShareHistory = dynamic(() => import('./components/ShareHistory'), {
loading: () => <Loading fixed={false} />,
ssr: false
});
const Empty = dynamic(() => import('./components/Empty'), {
loading: () => <Loading fixed={false} />,
ssr: false
});
import styles from './index.module.scss';
const textareaMinH = '22px';
const Chat = ({
shareId,
historyId,
isPcDevice
}: {
shareId: string;
historyId: string;
isPcDevice: boolean;
}) => {
const hasVoiceApi = !!window.speechSynthesis;
const router = useRouter();
const theme = useTheme();
const ChatBox = useRef<HTMLDivElement>(null);
const TextareaDom = useRef<HTMLTextAreaElement>(null);
const ContextMenuRef = useRef(null);
const PhoneContextShow = useRef(false);
// 中断请求
const controller = useRef(new AbortController());
const isLeavePage = useRef(false);
const [inputVal, setInputVal] = useState(''); // user input prompt
const [showSystemPrompt, setShowSystemPrompt] = useState('');
const [messageContextMenuData, setMessageContextMenuData] = useState<{
// message messageContextMenuData
left: number;
top: number;
message: ChatSiteItemType;
}>();
const [foldSliderBar, setFoldSlideBar] = useState(false);
const {
password,
setPassword,
shareChatHistory,
delShareHistoryById,
setShareChatHistory,
shareChatData,
setShareChatData,
delShareChatHistoryItemById,
delShareChatHistory
} = useChatStore();
const isChatting = useMemo(
() => shareChatData.history[shareChatData.history.length - 1]?.status === 'loading',
[shareChatData.history]
);
const { toast } = useToast();
const { copyData } = useCopyData();
const { isPc } = useScreen({ defaultIsPc: isPcDevice });
const { Loading, setIsLoading } = useLoading();
const { userInfo } = useUserStore();
const { isOpen: isOpenSlider, onClose: onCloseSlider, onOpen: onOpenSlider } = useDisclosure();
const {
isOpen: isOpenPassword,
onClose: onClosePassword,
onOpen: onOpenPassword
} = useDisclosure();
// close contextMenu
useOutsideClick({
ref: ContextMenuRef,
handler: () => {
// 移动端长按后会将其设置为true松手时候也会触发一次松手的时候需要忽略一次。
if (PhoneContextShow.current) {
PhoneContextShow.current = false;
} else {
messageContextMenuData &&
setTimeout(() => {
setMessageContextMenuData(undefined);
window.getSelection?.()?.empty?.();
window.getSelection?.()?.removeAllRanges?.();
document?.getSelection()?.empty();
});
}
}
});
// 滚动到底部
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) => {
setInputVal(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 formatPrompts = prompts.map((item) => ({
obj: item.obj,
value: item.value
}));
// 流请求,获取数据
const { responseText, systemPrompt } = await streamFetch({
url: '/api/chat/shareChat/chat',
data: {
prompts: formatPrompts.slice(-shareChatData.maxContext - 1, -1),
password,
shareId,
historyId
},
onMessage: (text: string) => {
setShareChatData((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;
}
let responseHistory: ChatSiteItemType[] = [];
// 设置聊天内容为完成状态
setShareChatData((state) => {
responseHistory = state.history.map((item, index) => {
if (index !== state.history.length - 1) return item;
return {
...item,
status: 'finish',
systemPrompt
};
});
return {
...state,
history: responseHistory
};
});
setShareChatHistory({
historyId,
shareId,
title: formatPrompts[formatPrompts.length - 2].value,
latestChat: responseText,
chats: responseHistory
});
setTimeout(() => {
generatingMessage();
}, 100);
},
[
generatingMessage,
historyId,
password,
setShareChatData,
setShareChatHistory,
shareChatData.maxContext,
shareId
]
);
/**
* 发送一个内容
*/
const sendPrompt = useCallback(async () => {
if (isChatting) {
toast({
title: '正在聊天中...请等待结束',
status: 'warning'
});
return;
}
const storeInput = inputVal;
// 去除空行
const val = inputVal.trim().replace(/\n\s*/g, '\n');
if (!val) {
toast({
title: '内容为空',
status: 'warning'
});
return;
}
const newChatList: ChatSiteItemType[] = [
...shareChatData.history,
{
_id: String(new Types.ObjectId()),
obj: 'Human',
value: val,
status: 'finish'
},
{
_id: String(new Types.ObjectId()),
obj: 'AI',
value: '',
status: 'loading'
}
];
// 插入内容
setShareChatData((state) => ({
...state,
history: newChatList
}));
// 清空输入内容
resetInputVal('');
setTimeout(() => {
scrollToBottom();
}, 100);
try {
await gptChatPrompt(newChatList);
} catch (err: any) {
toast({
title: typeof err === 'string' ? err : err?.message || '聊天出错了~',
status: 'warning',
duration: 5000,
isClosable: true
});
resetInputVal(storeInput);
setShareChatData((state) => ({
...state,
history: newChatList.slice(0, newChatList.length - 2)
}));
}
}, [
isChatting,
inputVal,
shareChatData.history,
setShareChatData,
resetInputVal,
toast,
scrollToBottom,
gptChatPrompt
]);
// 复制内容
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 = `<img src="${
child.querySelector<HTMLImageElement>('.avatar')?.src
}" alt="" />`;
const chatContent = child.querySelector<HTMLDivElement>('.markdown');
if (!chatContent) {
return '';
}
const chatContentClone = chatContent.cloneNode(true) as HTMLDivElement;
const codeHeader = chatContentClone.querySelectorAll('.code-header');
codeHeader.forEach((childElement: any) => {
childElement.remove();
});
return `<div class="chat-item">
${avatar}
${chatContentClone.outerHTML}
</div>`;
});
const html = htmlTemplate.replace('{{CHAT_CONTENT}}', dom.join('\n'));
return html;
};
const map: Record<ExportChatType, () => void> = {
md: () => {
fileDownload({
text: shareChatData.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]();
},
[shareChatData.history]
);
// onclick chat message context
const onclickContextMenu = useCallback(
(e: MouseEvent<HTMLDivElement>, 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 (!isPcDevice) {
PhoneContextShow.current = true;
}
setMessageContextMenuData({
left: e.clientX - 20,
top: e.clientY,
message
});
return false;
},
[isPcDevice]
);
// 获取对话信息
const loadChatInfo = useCallback(async () => {
setIsLoading(true);
try {
const res = await initShareChatInfo({
shareId,
password
});
setShareChatData({
...res,
history: shareChatHistory.find((item) => item._id === historyId)?.chats || []
});
onClosePassword();
setTimeout(() => {
scrollToBottom();
}, 500);
} catch (e: any) {
toast({
status: 'error',
title: typeof e === 'string' ? e : e?.message || '初始化异常'
});
if (e?.code === 501) {
onOpenPassword();
} else {
delShareChatHistory(shareId);
router.replace(`/chat/share`);
}
}
setIsLoading(false);
return null;
}, [
setIsLoading,
shareId,
password,
setShareChatData,
shareChatHistory,
onClosePassword,
historyId,
scrollToBottom,
toast,
onOpenPassword,
delShareChatHistory,
router
]);
// 初始化聊天框
useQuery(['init', historyId], () => {
if (!shareId) {
return null;
}
if (!historyId) {
router.replace(`/chat/share?shareId=${shareId}&historyId=${new Types.ObjectId()}`);
return null;
}
return loadChatInfo();
});
// abort stream
useEffect(() => {
return () => {
window.speechSynthesis?.cancel();
isLeavePage.current = true;
controller.current?.abort();
};
}, [shareId, historyId]);
// context menu component
const RenderContextMenu = useCallback(
({ history, index }: { history: ChatSiteItemType; index: number }) => (
<MenuList fontSize={'sm'} minW={'100px !important'}>
<MenuItem onClick={() => onclickCopy(history.value)}></MenuItem>
{hasVoiceApi && (
<MenuItem
borderBottom={theme.borders.base}
onClick={() => voiceBroadcast({ text: history.value })}
>
</MenuItem>
)}
<MenuItem onClick={() => delShareChatHistoryItemById(historyId, index)}></MenuItem>
</MenuList>
),
[delShareChatHistoryItemById, hasVoiceApi, historyId, onclickCopy, theme.borders.base]
);
return (
<Flex
h={'100%'}
flexDirection={['column', 'row']}
backgroundColor={useColorModeValue('#fdfdfd', '')}
>
{/* pc always show history. */}
{isPc && (
<Box
position={'relative'}
flex={foldSliderBar ? '0 0 0' : [1, '0 0 250px', '0 0 280px', '0 0 310px', '0 0 340px']}
w={['100%', 0]}
h={'100%'}
zIndex={1}
transition={'0.2s'}
_hover={{
'& > div': { visibility: 'visible', opacity: 1 }
}}
>
<Flex
position={'absolute'}
right={0}
top={'50%'}
transform={'translate(50%,-50%)'}
alignItems={'center'}
justifyContent={'flex-end'}
pr={1}
w={'36px'}
h={'50px'}
borderRadius={'10px'}
bg={'rgba(0,0,0,0.5)'}
cursor={'pointer'}
transition={'0.2s'}
{...(foldSliderBar
? {
opacity: 0.6
}
: {
visibility: 'hidden',
opacity: 0
})}
onClick={() => setFoldSlideBar(!foldSliderBar)}
>
<MyIcon
name={'back'}
transform={foldSliderBar ? 'rotate(180deg)' : ''}
w={'14px'}
color={'white'}
/>
</Flex>
<Box position={'relative'} h={'100%'} bg={'white'} overflow={'hidden'}>
<ShareHistory
onclickDelHistory={delShareHistoryById}
onclickExportChat={onclickExportChat}
onCloseSlider={onCloseSlider}
isPcDevice={isPcDevice}
/>
</Box>
</Box>
)}
{/* 聊天内容 */}
<Flex
position={'relative'}
h={[0, '100%']}
w={['100%', 0]}
flex={'1 0 0'}
flexDirection={'column'}
>
{/* chat header */}
<Flex
alignItems={'center'}
justifyContent={'space-between'}
py={[3, 5]}
px={5}
borderBottom={'1px solid '}
borderBottomColor={useColorModeValue('gray.200', 'gray.700')}
color={useColorModeValue('myGray.900', 'white')}
>
{!isPc && (
<MyIcon
name={'tabbarMore'}
w={'14px'}
h={'14px'}
color={useColorModeValue('blackAlpha.700', 'white')}
onClick={onOpenSlider}
/>
)}
<Box
cursor={'pointer'}
lineHeight={1.2}
textAlign={'center'}
px={3}
fontSize={['sm', 'md']}
>
{shareChatData.model.name}
{shareChatData.history.length > 0 ? ` (${shareChatData.history.length})` : ''}
</Box>
{shareChatData.history.length > 0 ? (
<Menu autoSelect={false}>
<MenuButton lineHeight={1}>
<MyIcon
name={'more'}
w={'16px'}
h={'16px'}
color={useColorModeValue('blackAlpha.700', 'white')}
/>
</MenuButton>
<MenuList minW={`90px !important`}>
<MenuItem onClick={() => router.replace(`/chat/share?shareId=${shareId}`)}>
</MenuItem>
<MenuItem
onClick={() => {
delShareHistoryById(historyId);
router.replace(`/chat/share?shareId=${shareId}`);
}}
>
</MenuItem>
<MenuItem onClick={() => onclickExportChat('html')}>HTML格式</MenuItem>
<MenuItem onClick={() => onclickExportChat('pdf')}>PDF格式</MenuItem>
<MenuItem onClick={() => onclickExportChat('md')}>Markdown格式</MenuItem>
</MenuList>
</Menu>
) : (
<Box w={'16px'} h={'16px'} />
)}
</Flex>
{/* chat content box */}
<Box ref={ChatBox} pb={[4, 0]} flex={'1 0 0'} h={0} w={'100%'} overflow={'overlay'}>
<Box id={'history'}>
{shareChatData.history.map((item, index) => (
<Flex key={item._id} alignItems={'flex-start'} py={2} px={[2, 6, 8]}>
{item.obj === 'Human' && <Box flex={1} />}
{/* avatar */}
<Menu autoSelect={false} isLazy>
<MenuButton
as={Box}
{...(item.obj === 'AI'
? {
order: 1,
mr: ['6px', 2]
}
: {
order: 3,
ml: ['6px', 2]
})}
>
<Tooltip label={item.obj === 'AI' ? 'AI助手详情' : ''}>
<Image
className="avatar"
src={
item.obj === 'Human'
? userInfo?.avatar
: shareChatData.model.avatar || LOGO_ICON
}
alt="avatar"
w={['20px', '34px']}
h={['20px', '34px']}
borderRadius={'50%'}
objectFit={'contain'}
/>
</Tooltip>
</MenuButton>
{!isPc && <RenderContextMenu history={item} index={index} />}
</Menu>
{/* message */}
<Flex order={2} pt={2} maxW={['calc(100% - 50px)', '80%']}>
{item.obj === 'AI' ? (
<Box w={'100%'}>
<Card
bg={'white'}
px={4}
py={3}
borderRadius={'0 8px 8px 8px'}
onContextMenu={(e) => onclickContextMenu(e, item)}
>
<Markdown
source={item.value}
isChatting={isChatting && index === shareChatData.history.length - 1}
/>
{item.systemPrompt && (
<Button
size={'xs'}
mt={2}
fontWeight={'normal'}
colorScheme={'gray'}
variant={'outline'}
w={'90px'}
onClick={() => setShowSystemPrompt(item.systemPrompt || '')}
>
</Button>
)}
</Card>
</Box>
) : (
<Box>
<Card
className="markdown"
whiteSpace={'pre-wrap'}
px={4}
py={3}
borderRadius={'8px 0 8px 8px'}
bg={'myBlue.300'}
onContextMenu={(e) => onclickContextMenu(e, item)}
>
<Box as={'p'}>{item.value}</Box>
</Card>
</Box>
)}
</Flex>
</Flex>
))}
{shareChatData.history.length === 0 && <Empty model={shareChatData.model} />}
</Box>
</Box>
{/* 发送区 */}
<Box m={['0 auto', '20px auto']} w={'100%'} maxW={['auto', 'min(750px, 100%)']}>
<Box
py={'18px'}
position={'relative'}
boxShadow={`0 0 10px rgba(0,0,0,0.1)`}
borderTop={['1px solid', 0]}
borderTopColor={useColorModeValue('gray.200', 'gray.700')}
borderRadius={['none', 'md']}
backgroundColor={useColorModeValue('white', 'gray.700')}
>
{/* 输入框 */}
<Textarea
ref={TextareaDom}
py={0}
pr={['45px', '55px']}
border={'none'}
_focusVisible={{
border: 'none'
}}
placeholder="提问"
resize={'none'}
value={inputVal}
rows={1}
height={'22px'}
lineHeight={'22px'}
maxHeight={'150px'}
maxLength={-1}
overflowY={'auto'}
whiteSpace={'pre-wrap'}
wordBreak={'break-all'}
boxShadow={'none !important'}
color={useColorModeValue('blackAlpha.700', 'white')}
onChange={(e) => {
const textarea = e.target;
setInputVal(textarea.value);
textarea.style.height = textareaMinH;
textarea.style.height = `${textarea.scrollHeight}px`;
}}
onKeyDown={(e) => {
// 触发快捷发送
if (isPcDevice && e.keyCode === 13 && !e.shiftKey) {
sendPrompt();
e.preventDefault();
}
// 全选内容
// @ts-ignore
e.key === 'a' && e.ctrlKey && e.target?.select();
}}
/>
{/* 发送和等待按键 */}
<Flex
alignItems={'center'}
justifyContent={'center'}
h={'25px'}
w={'25px'}
position={'absolute'}
right={['12px', '20px']}
bottom={'15px'}
>
{isChatting ? (
<MyIcon
className={styles.stopIcon}
width={['22px', '25px']}
height={['22px', '25px']}
cursor={'pointer'}
name={'stop'}
color={useColorModeValue('gray.500', 'white')}
onClick={() => {
controller.current?.abort();
}}
/>
) : (
<MyIcon
name={'chatSend'}
width={['18px', '20px']}
height={['18px', '20px']}
cursor={'pointer'}
color={useColorModeValue('gray.500', 'white')}
onClick={sendPrompt}
/>
)}
</Flex>
</Box>
</Box>
<Loading fixed={false} />
</Flex>
{/* phone slider */}
{!isPc && (
<Drawer isOpen={isOpenSlider} placement="left" size={'xs'} onClose={onCloseSlider}>
<DrawerOverlay backgroundColor={'rgba(255,255,255,0.5)'} />
<DrawerContent maxWidth={'250px'}>
<ShareHistory
onclickDelHistory={delShareHistoryById}
onclickExportChat={onclickExportChat}
onCloseSlider={onCloseSlider}
isPcDevice={isPcDevice}
/>
</DrawerContent>
</Drawer>
)}
{/* system prompt show modal */}
{
<Modal isOpen={!!showSystemPrompt} onClose={() => setShowSystemPrompt('')}>
<ModalOverlay />
<ModalContent maxW={'min(90vw, 600px)'} pr={2} maxH={'80vh'} overflowY={'auto'}>
<ModalCloseButton />
<ModalBody pt={5} whiteSpace={'pre-wrap'} textAlign={'justify'}>
{showSystemPrompt}
</ModalBody>
</ModalContent>
</Modal>
}
{/* context menu */}
{messageContextMenuData && (
<Box
zIndex={10}
position={'fixed'}
top={messageContextMenuData.top}
left={messageContextMenuData.left}
>
<Box ref={ContextMenuRef}></Box>
<Menu isOpen>
<RenderContextMenu
history={messageContextMenuData.message}
index={shareChatData.history.findIndex(
(item) => item._id === messageContextMenuData.message._id
)}
/>
</Menu>
</Box>
)}
{/* password input */}
{
<Modal isOpen={isOpenPassword} onClose={onClosePassword}>
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalHeader></ModalHeader>
<ModalBody>
<Flex alignItems={'center'}>
<Box flex={'0 0 70px'}></Box>
<Input
type="password"
autoFocus
placeholder="使用密码,无密码直接点确认"
onBlur={(e) => setPassword(e.target.value)}
/>
</Flex>
</ModalBody>
<ModalFooter>
<Button variant={'outline'} mr={3} onClick={onClosePassword}>
</Button>
<Button onClick={loadChatInfo}></Button>
</ModalFooter>
</ModalContent>
</Modal>
}
</Flex>
);
};
Chat.getInitialProps = ({ query, req }: any) => {
return {
shareId: query?.shareId || '',
historyId: query?.historyId || '',
isPcDevice: !/Mobile/.test(req ? req.headers['user-agent'] : navigator.userAgent)
};
};
export default Chat;

View File

@@ -14,13 +14,30 @@ import {
Tooltip,
Button,
Select,
Grid,
Switch,
Image
Image,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
useDisclosure,
Table,
Thead,
Tbody,
Tfoot,
Tr,
Th,
Td,
TableContainer,
IconButton
} from '@chakra-ui/react';
import { DeleteIcon } from '@chakra-ui/icons';
import { QuestionOutlineIcon } from '@chakra-ui/icons';
import type { ModelSchema } from '@/types/mongoSchema';
import { UseFormReturn } from 'react-hook-form';
import { useForm, UseFormReturn } from 'react-hook-form';
import { ChatModelMap, ModelVectorSearchModeMap, getChatModelList } from '@/constants/model';
import { formatPrice } from '@/utils/user';
import { useConfirm } from '@/hooks/useConfirm';
@@ -28,6 +45,13 @@ import { useSelectFile } from '@/hooks/useSelectFile';
import { useToast } from '@/hooks/useToast';
import { compressImg } from '@/utils/file';
import { useQuery } from '@tanstack/react-query';
import { getShareChatList, createShareChat, delShareChatById } from '@/api/chat';
import { useRouter } from 'next/router';
import { defaultShareChat } from '@/constants/model';
import type { ShareChatEditType } from '@/types/model';
import { formatTimeToChatTime, useCopyData } from '@/utils/tools';
import MyIcon from '@/components/Icon';
import { useGlobalStore } from '@/store/global';
const ModelEditForm = ({
formHooks,
@@ -38,16 +62,34 @@ const ModelEditForm = ({
isOwner: boolean;
handleDelModel: () => void;
}) => {
const { toast } = useToast();
const { modelId } = useRouter().query as { modelId: string };
const { setLoading } = useGlobalStore();
const [refresh, setRefresh] = useState(false);
const { openConfirm, ConfirmChild } = useConfirm({
content: '确认删除该AI助手?'
});
const { copyData } = useCopyData();
const { register, setValue, getValues } = formHooks;
const [refresh, setRefresh] = useState(false);
const {
register: registerShareChat,
getValues: getShareChatValues,
setValue: setShareChatValues,
handleSubmit: submitShareChat,
reset: resetShareChat
} = useForm({
defaultValues: defaultShareChat
});
const {
isOpen: isOpenCreateShareChat,
onOpen: onOpenCreateShareChat,
onClose: onCloseCreateShareChat
} = useDisclosure();
const { File, onOpen: onOpenSelectFile } = useSelectFile({
fileType: '.jpg,.png',
multiple: false
});
const { toast } = useToast();
const onSelectFile = useCallback(
async (e: File[]) => {
@@ -71,10 +113,54 @@ const ModelEditForm = ({
[setValue, toast]
);
const { data: chatModelList = [] } = useQuery(['init'], getChatModelList);
const { data: chatModelList = [] } = useQuery(['initChatModelList'], getChatModelList);
const { data: shareChatList = [], refetch: refetchShareChatList } = useQuery(
['initShareChatList', modelId],
() => getShareChatList(modelId)
);
const onclickCreateShareChat = useCallback(
async (e: ShareChatEditType) => {
try {
setLoading(true);
const id = await createShareChat({
...e,
modelId
});
onCloseCreateShareChat();
refetchShareChatList();
const url = `你可以与 ${getValues('name')} 进行对话。
对话地址为:${location.origin}/chat/share?shareId=${id}
${e.password ? `密码为: ${e.password}` : ''}`;
copyData(url, '已复制分享地址');
resetShareChat(defaultShareChat);
} catch (error) {
console.log(error);
}
setLoading(false);
},
[
copyData,
getValues,
modelId,
onCloseCreateShareChat,
refetchShareChatList,
resetShareChat,
setLoading
]
);
const formatTokens = (tokens: number) => {
if (tokens < 10000) return tokens;
return `${(tokens / 10000).toFixed(2)}`;
};
return (
<>
{/* basic info */}
<Card p={4}>
<Box fontWeight={'bold'}></Box>
<Flex alignItems={'center'} mt={4}>
@@ -160,6 +246,7 @@ const ModelEditForm = ({
</Flex>
)}
</Card>
{/* model effect */}
<Card p={4}>
<Box fontWeight={'bold'}></Box>
<FormControl mt={4}>
@@ -246,13 +333,16 @@ const ModelEditForm = ({
</Box>
</Card>
{isOwner && (
<Card p={4} gridColumnStart={[1, 1]} gridColumnEnd={[2, 3]}>
<Box fontWeight={'bold'}></Box>
<Grid gridTemplateColumns={['1fr', '1fr 410px']} gridGap={5}>
<>
{/* model share setting */}
<Card p={4}>
<Box fontWeight={'bold'}></Box>
<Box>
<Flex mt={5} alignItems={'center'}>
<Box mr={3}>:</Box>
<Box mr={1}>:</Box>
<Tooltip label="开启模型分享后,你的模型将会出现在共享市场,可供 FastGpt 所有用户使用。用户使用时不会消耗你的 tokens而是消耗使用者的 tokens。">
<QuestionOutlineIcon mr={3} />
</Tooltip>
<Switch
isChecked={getValues('share.isShare')}
onChange={() => {
@@ -260,9 +350,12 @@ const ModelEditForm = ({
setRefresh(!refresh);
}}
/>
<Box ml={12} mr={3}>
<Box ml={12} mr={1}>
:
</Box>
<Tooltip label="开启分享详情后,其他用户可以查看该模型的特有数据:温度、提示词和数据集。">
<QuestionOutlineIcon mr={3} />
</Tooltip>
<Switch
isChecked={getValues('share.isShareDetail')}
onChange={() => {
@@ -282,25 +375,170 @@ const ModelEditForm = ({
/>
</Box>
</Box>
<Box
textAlign={'justify'}
fontSize={'sm'}
border={'1px solid #f4f4f4'}
borderRadius={'sm'}
p={3}
>
<Box fontWeight={'bold'}>Tips</Box>
<Box mt={1} as={'ul'} pl={4}>
<li>
FastGpt
使使 tokens使 tokens
</li>
<li></li>
</Card>
{/* shareChat */}
<Card p={4}>
<Flex justifyContent={'space-between'}>
<Box fontWeight={'bold'}>
<Tooltip label="可以直接分享该模型给其他用户去进行对话对方无需登录即可直接进行对话。注意这个功能会消耗你账号的tokens。请保管好链接和密码。">
<QuestionOutlineIcon ml={1} />
</Tooltip>
Beta
</Box>
</Box>
</Grid>
</Card>
<Button
size={'sm'}
variant={'outline'}
colorScheme={'myBlue'}
{...(shareChatList.length >= 10
? {
isDisabled: true,
title: '最多创建10组'
}
: {})}
onClick={onOpenCreateShareChat}
>
</Button>
</Flex>
<TableContainer mt={1} minH={'100px'}>
<Table variant={'simple'} w={'100%'}>
<Thead>
<Tr>
<Th></Th>
<Th></Th>
<Th></Th>
<Th>tokens消耗</Th>
<Th>使</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{shareChatList.map((item) => (
<Tr key={item._id}>
<Td>{item.name}</Td>
<Td>{item.password === '1' ? '已开启' : '未使用'}</Td>
<Td>{item.maxContext}</Td>
<Td>{formatTokens(item.tokens)}</Td>
<Td>{item.lastTime ? formatTimeToChatTime(item.lastTime) : '未使用'}</Td>
<Td>
<Flex>
<MyIcon
mr={3}
name="copy"
w={'14px'}
cursor={'pointer'}
_hover={{ color: 'myBlue.600' }}
onClick={() => {
const url = `${location.origin}/chat/share?shareId=${item._id}`;
copyData(url, '已复制分享地址');
}}
/>
<MyIcon
name="delete"
w={'14px'}
cursor={'pointer'}
_hover={{ color: 'red' }}
onClick={async () => {
setLoading(true);
try {
await delShareChatById(item._id);
refetchShareChatList();
} catch (error) {
console.log(error);
}
setLoading(false);
}}
/>
</Flex>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Card>
</>
)}
{/* create shareChat modal */}
<Modal isOpen={isOpenCreateShareChat} onClose={onCloseCreateShareChat}>
<ModalOverlay />
<ModalContent>
<ModalHeader></ModalHeader>
<ModalCloseButton />
<ModalBody>
<FormControl>
<Flex alignItems={'center'}>
<Box flex={'0 0 60px'} w={0}>
:
</Box>
<Input
placeholder="记录名字,仅用于展示"
maxLength={20}
{...registerShareChat('name', {
required: '记录名称不能为空'
})}
/>
</Flex>
</FormControl>
<FormControl mt={4}>
<Flex alignItems={'center'}>
<Box flex={'0 0 60px'} w={0}>
:
</Box>
<Input placeholder={'不设置密码,可直接访问'} {...registerShareChat('password')} />
</Flex>
<Box fontSize={'xs'} ml={'60px'}>
</Box>
</FormControl>
<FormControl mt={9}>
<Flex alignItems={'center'}>
<Box flex={'0 0 120px'} w={0}>
</Box>
<Slider
aria-label="slider-ex-1"
min={1}
max={20}
step={1}
value={getShareChatValues('maxContext')}
isDisabled={!isOwner}
onChange={(e) => {
setShareChatValues('maxContext', e);
setRefresh(!refresh);
}}
>
<SliderMark
value={getShareChatValues('maxContext')}
textAlign="center"
bg="myBlue.600"
color="white"
w={'18px'}
h={'18px'}
borderRadius={'100px'}
fontSize={'xs'}
transform={'translate(-50%, -200%)'}
>
{getShareChatValues('maxContext')}
</SliderMark>
<SliderTrack>
<SliderFilledTrack bg={'myBlue.700'} />
</SliderTrack>
<SliderThumb />
</Slider>
</Flex>
</FormControl>
</ModalBody>
<ModalFooter>
<Button variant={'outline'} mr={3} onClick={onCloseCreateShareChat}>
</Button>
<Button onClick={submitShareChat(onclickCreateShareChat)}></Button>
</ModalFooter>
</ModalContent>
</Modal>
<File onSelect={onSelectFile} />
<ConfirmChild />
</>

View File

@@ -1,4 +1,4 @@
import { connectToDatabase, Bill, User } from '../mongo';
import { connectToDatabase, Bill, User, ShareChat } from '../mongo';
import { ChatModelMap, OpenAiChatEnum, ChatModelType, embeddingModel } from '@/constants/model';
import { BillTypeEnum } from '@/constants/user';
@@ -55,6 +55,23 @@ export const pushChatBill = async ({
}
};
export const updateShareChatBill = async ({
shareId,
tokens
}: {
shareId: string;
tokens: number;
}) => {
try {
await ShareChat.findByIdAndUpdate(shareId, {
$inc: { tokens },
lastTime: new Date()
});
} catch (error) {
console.log('update shareChat error', error);
}
};
export const pushSplitDataBill = async ({
isPay,
userId,

View File

@@ -0,0 +1,38 @@
import { Schema, model, models, Model } from 'mongoose';
import { ShareChatSchema as ShareChatSchemaType } from '@/types/mongoSchema';
import { hashPassword } from '@/service/utils/tools';
const ShareChatSchema = new Schema({
userId: {
type: Schema.Types.ObjectId,
ref: 'user',
required: true
},
modelId: {
type: Schema.Types.ObjectId,
ref: 'model',
required: true
},
name: {
type: String,
required: true
},
password: {
type: String,
set: (val: string) => hashPassword(val)
},
tokens: {
type: Number,
default: 0
},
maxContext: {
type: Number,
default: 20
},
lastTime: {
type: Date
}
});
export const ShareChat: Model<ShareChatSchemaType> =
models['shareChat'] || model('shareChat', ShareChatSchema);

View File

@@ -51,3 +51,4 @@ export * from './models/splitData';
export * from './models/openapi';
export * from './models/promotionRecord';
export * from './models/collection';
export * from './models/shareChat';

View File

@@ -1,7 +1,7 @@
import type { NextApiRequest } from 'next';
import jwt from 'jsonwebtoken';
import cookie from 'cookie';
import { Chat, Model, OpenApi, User } from '../mongo';
import { Chat, Model, OpenApi, User, ShareChat } from '../mongo';
import type { ModelSchema } from '@/types/mongoSchema';
import type { ChatItemSimpleType } from '@/types/chat';
import mongoose from 'mongoose';
@@ -9,6 +9,7 @@ import { ClaudeEnum, defaultModel } from '@/constants/model';
import { formatPrice } from '@/utils/user';
import { ERROR_ENUM } from '../errorCode';
import { ChatModelType, OpenAiChatEnum } from '@/constants/model';
import { hashPassword } from '@/service/utils/tools';
/* 校验 token */
export const authToken = (req: NextApiRequest): Promise<string> => {
@@ -113,8 +114,8 @@ export const authModel = async ({
1. authOwner=true or authUser = true , just owner can use
2. authUser = false and share, anyone can use
*/
if ((authOwner || (authUser && !model.share.isShare)) && userId !== String(model.userId)) {
return Promise.reject(ERROR_ENUM.unAuthModel);
if (authOwner || (authUser && !model.share.isShare)) {
if (userId !== String(model.userId)) return Promise.reject(ERROR_ENUM.unAuthModel);
}
// do not share detail info
@@ -183,6 +184,50 @@ export const authChat = async ({
showModelDetail
};
};
export const authShareChat = async ({
shareId,
password
}: {
shareId: string;
password: string;
}) => {
// get shareChat
const shareChat = await ShareChat.findById(shareId);
if (!shareChat) {
return Promise.reject('分享链接已失效');
}
if (shareChat.password !== hashPassword(password)) {
return Promise.reject({
code: 501,
message: '密码不正确'
});
}
const modelId = String(shareChat.modelId);
const userId = String(shareChat.userId);
// 获取 model 数据
const { model, showModelDetail } = await authModel({
modelId,
userId
});
// 获取 user 的 apiKey
const { userOpenAiKey, systemAuthKey } = await getApiKey({
model: model.chat.chatModel,
userId
});
return {
userOpenAiKey,
systemAuthKey,
userId,
model,
showModelDetail
};
};
/* 校验 open api key */
export const authOpenApiKey = async (req: NextApiRequest) => {

View File

@@ -3,9 +3,23 @@ import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { OpenAiChatEnum } from '@/constants/model';
import { HistoryItemType, ChatType } from '@/types/chat';
import {
ChatSiteItemType,
HistoryItemType,
ShareChatHistoryItemType,
ChatType,
ShareChatType
} from '@/types/chat';
import { getChatHistory } from '@/api/chat';
type SetShareChatHistoryItem = {
historyId: string;
shareId: string;
title: string;
latestChat: string;
chats: ChatSiteItemType[];
};
type State = {
history: HistoryItemType[];
loadHistory: (data: { pageNum: number; init?: boolean }) => Promise<null>;
@@ -17,6 +31,16 @@ type State = {
setLastChatModelId: (id: string) => void;
lastChatId: string;
setLastChatId: (id: string) => void;
shareChatData: ShareChatType;
setShareChatData: (e?: ShareChatType | ((e: ShareChatType) => ShareChatType)) => void;
password: string;
setPassword: (val: string) => void;
shareChatHistory: ShareChatHistoryItemType[];
setShareChatHistory: (e: SetShareChatHistoryItem) => void;
delShareHistoryById: (historyId: string) => void;
delShareChatHistoryItemById: (historyId: string, index: number) => void;
delShareChatHistory: (shareId?: string) => void;
};
const defaultChatData = {
@@ -31,6 +55,16 @@ const defaultChatData = {
chatModel: OpenAiChatEnum.GPT35,
history: []
};
const defaultShareChatData: ShareChatType = {
maxContext: 5,
model: {
name: '',
avatar: '/icon/logo.png',
intro: ''
},
chatModel: 'gpt-3.5-turbo',
history: []
};
export const useChatStore = create<State>()(
devtools(
@@ -77,13 +111,114 @@ export const useChatStore = create<State>()(
state.chatData = e;
});
}
},
shareChatData: defaultShareChatData,
setShareChatData(
e: ShareChatType | ((e: ShareChatType) => ShareChatType) = defaultShareChatData
) {
if (typeof e === 'function') {
set((state) => {
state.shareChatData = e(state.shareChatData);
});
} else {
set((state) => {
state.shareChatData = e;
});
}
},
password: '',
setPassword(val: string) {
set((state) => {
state.password = val;
});
},
shareChatHistory: [],
setShareChatHistory({
historyId,
shareId,
title,
latestChat,
chats = []
}: SetShareChatHistoryItem) {
set((state) => {
const history = state.shareChatHistory.find((item) => item._id === historyId);
let historyList: ShareChatHistoryItemType[] = [];
if (history) {
historyList = state.shareChatHistory.map((item) =>
item._id === historyId
? {
...item,
title,
latestChat,
updateTime: new Date(),
chats
}
: item
);
} else {
historyList = [
...state.shareChatHistory,
{
_id: historyId,
shareId,
title,
latestChat,
updateTime: new Date(),
chats
}
];
}
// @ts-ignore
historyList.sort((a, b) => new Date(b.updateTime) - new Date(a.updateTime));
state.shareChatHistory = historyList.slice(0, 30);
});
},
delShareHistoryById(historyId: string) {
set((state) => {
state.shareChatHistory = state.shareChatHistory.filter(
(item) => item._id !== historyId
);
});
},
delShareChatHistoryItemById(historyId: string, index: number) {
set((state) => {
// update history store
const newHistoryList = state.shareChatHistory.map((item) =>
item._id === historyId
? {
...item,
chats: [...item.chats.slice(0, index), ...item.chats.slice(index + 1)]
}
: item
);
state.shareChatHistory = newHistoryList;
// update chatData
state.shareChatData.history =
newHistoryList.find((item) => item._id === historyId)?.chats || [];
});
},
delShareChatHistory(shareId?: string) {
set((state) => {
if (shareId) {
state.shareChatHistory = state.shareChatHistory.filter(
(item) => item.shareId !== shareId
);
} else {
state.shareChatHistory = [];
}
});
}
})),
{
name: 'globalStore',
name: 'chatStore',
partialize: (state) => ({
lastChatModelId: state.lastChatModelId,
lastChatId: state.lastChatId
lastChatId: state.lastChatId,
password: state.password,
shareChatHistory: state.shareChatHistory
})
}
)

15
src/types/chat.d.ts vendored
View File

@@ -1,5 +1,5 @@
import { ChatRoleEnum } from '@/constants/chat';
import type { InitChatResponse } from '@/api/response/chat';
import type { InitChatResponse, InitShareChatResponse } from '@/api/response/chat';
export type ExportChatType = 'md' | 'pdf' | 'html';
@@ -20,6 +20,10 @@ export interface ChatType extends InitChatResponse {
history: ChatSiteItemType[];
}
export interface ShareChatType extends InitShareChatResponse {
history: ChatSiteItemType[];
}
export type HistoryItemType = {
_id: string;
updateTime: Date;
@@ -27,3 +31,12 @@ export type HistoryItemType = {
title: string;
latestChat: string;
};
export type ShareChatHistoryItemType = {
_id: string;
shareId: string;
updateTime: Date;
title: string;
latestChat: string;
chats: ChatSiteItemType[];
};

View File

@@ -33,3 +33,9 @@ export interface ShareModelItem {
share: ModelSchema['share'];
isCollection: boolean;
}
export type ShareChatEditType = {
name: string;
password: string;
maxContext: number;
};

View File

@@ -137,3 +137,14 @@ export interface PromotionRecordSchema {
createTime: Date; // 记录时间
amount: number;
}
export interface ShareChatSchema {
_id: string;
userId: string;
modelId: string;
password: string;
name: string;
tokens: number;
maxContext: number;
lastTime: Date;
}