mirror of
https://github.com/labring/FastGPT.git
synced 2025-07-23 05:12:39 +00:00
feat: share chat page
This commit is contained in:
@@ -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)}`);
|
||||
|
10
src/api/response/chat.d.ts
vendored
10
src/api/response/chat.d.ts
vendored
@@ -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']; // 对话模型名
|
||||
}
|
||||
|
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -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}
|
||||
|
@@ -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
|
||||
};
|
||||
|
@@ -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;
|
||||
|
130
src/pages/api/chat/shareChat/chat.ts
Normal file
130
src/pages/api/chat/shareChat/chat.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
39
src/pages/api/chat/shareChat/create.ts
Normal file
39
src/pages/api/chat/shareChat/create.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
29
src/pages/api/chat/shareChat/delete.ts
Normal file
29
src/pages/api/chat/shareChat/delete.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
59
src/pages/api/chat/shareChat/init.ts
Normal file
59
src/pages/api/chat/shareChat/init.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
43
src/pages/api/chat/shareChat/list.ts
Normal file
43
src/pages/api/chat/shareChat/list.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
@@ -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} />
|
||||
|
203
src/pages/chat/components/ShareHistory.tsx
Normal file
203
src/pages/chat/components/ShareHistory.tsx
Normal 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
930
src/pages/chat/share.tsx
Normal 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;
|
@@ -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 />
|
||||
</>
|
||||
|
@@ -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,
|
||||
|
38
src/service/models/shareChat.ts
Normal file
38
src/service/models/shareChat.ts
Normal 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);
|
@@ -51,3 +51,4 @@ export * from './models/splitData';
|
||||
export * from './models/openapi';
|
||||
export * from './models/promotionRecord';
|
||||
export * from './models/collection';
|
||||
export * from './models/shareChat';
|
||||
|
@@ -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) => {
|
||||
|
@@ -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
15
src/types/chat.d.ts
vendored
@@ -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[];
|
||||
};
|
||||
|
6
src/types/model.d.ts
vendored
6
src/types/model.d.ts
vendored
@@ -33,3 +33,9 @@ export interface ShareModelItem {
|
||||
share: ModelSchema['share'];
|
||||
isCollection: boolean;
|
||||
}
|
||||
|
||||
export type ShareChatEditType = {
|
||||
name: string;
|
||||
password: string;
|
||||
maxContext: number;
|
||||
};
|
||||
|
11
src/types/mongoSchema.d.ts
vendored
11
src/types/mongoSchema.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
Reference in New Issue
Block a user