feat: new ui

This commit is contained in:
archer
2023-05-04 23:30:59 +08:00
parent 4d043e0e46
commit 014fb504a4
133 changed files with 2426 additions and 1696 deletions

View File

@@ -4,7 +4,7 @@ import { useRouter } from 'next/router';
const NonePage = () => {
const router = useRouter();
useEffect(() => {
router.push('/model/list');
router.push('/model');
}, [router]);
return <div></div>;

View File

@@ -57,6 +57,7 @@ export default function App({ Component, pageProps }: AppProps) {
<QueryClientProvider client={queryClient}>
<ChakraProvider theme={theme}>
<ColorModeScript initialColorMode={theme.config.initialColorMode} />
{/* @ts-ignore */}
<Layout>
<Component {...pageProps} />
</Layout>

View File

@@ -6,11 +6,7 @@ import { authToken } from '@/service/utils/auth';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { chatId, contentId } = req.query as { chatId: string; contentId: string };
const { authorization } = req.headers;
if (!authorization) {
throw new Error('无权操作');
}
if (!chatId || !contentId) {
throw new Error('缺少参数');
}
@@ -18,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await connectToDatabase();
// 凭证校验
const userId = await authToken(authorization);
const userId = await authToken(req);
const chatRecord = await Chat.findById(chatId);

View File

@@ -6,7 +6,7 @@ import { authToken } from '@/service/utils/auth';
/* 获取历史记录 */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const userId = await authToken(req.headers.authorization);
const userId = await authToken(req);
await connectToDatabase();
@@ -14,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
{
userId
},
'_id title modelId'
'_id title modelId updateTime latestChat'
)
.sort({ updateTime: -1 })
.limit(20);

View File

@@ -1,33 +1,57 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase, Chat } from '@/service/mongo';
import { connectToDatabase, Chat, Model } from '@/service/mongo';
import type { InitChatResponse } from '@/api/response/chat';
import { authToken } from '@/service/utils/auth';
import { ChatItemType } from '@/types/chat';
import { authModel } from '@/service/utils/auth';
import mongoose from 'mongoose';
import { ModelStatusEnum } from '@/constants/model';
import type { ModelSchema } from '@/types/mongoSchema';
/* 初始化我的聊天框,需要身份验证 */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { authorization } = req.headers;
const userId = await authToken(authorization);
const userId = await authToken(req);
const { modelId, chatId } = req.query as { modelId: string; chatId: '' | string };
if (!modelId) {
throw new Error('缺少参数');
}
let { modelId, chatId } = req.query as { modelId: '' | string; chatId: '' | string };
await connectToDatabase();
// 获取 model 数据
const { model } = await authModel({ modelId, userId, authUser: false, authOwner: false });
let model: ModelSchema;
// 没有 modelId 时直接获取用户的第一个id
if (!modelId) {
const myModel = await Model.findOne({ userId });
if (!myModel) {
const { _id } = await Model.create({
name: 'AI助手1',
userId,
status: ModelStatusEnum.running
});
model = (await Model.findById(_id)) as ModelSchema;
} else {
model = myModel;
}
modelId = model._id;
} else {
// 校验使用权限
const authRes = await authModel({ modelId, userId, authUser: false, authOwner: false });
model = authRes.model;
}
// 历史记录
let history: ChatItemType[] = [];
if (chatId) {
// auth chatId
const chat = await Chat.countDocuments({
_id: chatId,
userId
});
if (chat === 0) {
throw new Error('聊天框不存在');
}
// 获取 chat.content 数据
history = await Chat.aggregate([
{
@@ -59,9 +83,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
data: {
chatId: chatId || '',
modelId: modelId,
name: model.name,
avatar: model.avatar,
intro: model.share.intro,
model: {
name: model.name,
avatar: model.avatar,
intro: model.share.intro,
canUse: model.share.isShare || String(model.userId) === userId
},
chatModel: model.chat.chatModel,
history
}

View File

@@ -1,6 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { ChatItemType } from '@/types/chat';
import { connectToDatabase, Chat } from '@/service/mongo';
import { authToken } from '@/service/utils/auth';
@@ -8,7 +7,7 @@ import { authToken } from '@/service/utils/auth';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { id } = req.query;
const userId = await authToken(req.headers.authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -20,7 +20,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
throw new Error('缺少参数');
}
const userId = await authToken(req.headers.authorization);
const userId = await authToken(req);
await connectToDatabase();
@@ -40,7 +40,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
userId,
modelId,
content,
title: content[0].value.slice(0, 20)
title: content[0].value.slice(0, 20),
latestChat: content[content.length - 1].value
});
return jsonRes(res, {
data: _id
@@ -53,7 +54,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
$each: content
}
},
updateTime: new Date()
updateTime: new Date(),
latestChat: content[content.length - 1].value
});
}
jsonRes(res);

View File

@@ -11,18 +11,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
const { name } = req.body as {
name: string;
};
const { authorization } = req.headers;
if (!authorization) {
throw new Error('无权操作');
}
if (!name) {
throw new Error('缺少参数');
}
// 凭证校验
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -8,18 +8,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
let { dataId } = req.query as {
dataId: string;
};
const { authorization } = req.headers;
if (!authorization) {
throw new Error('无权操作');
}
if (!dataId) {
throw new Error('缺少参数');
}
// 凭证校验
const userId = await authToken(authorization);
const userId = await authToken(req);
await PgClient.delete('modelData', {
where: [['user_id', userId], 'AND', ['id', dataId]]

View File

@@ -10,18 +10,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
modelId: string;
};
const { authorization } = req.headers;
if (!authorization) {
throw new Error('无权操作');
}
if (!modelId) {
throw new Error('缺少参数');
}
// 凭证校验
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -16,9 +16,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
await connectToDatabase();
const { authorization } = req.headers;
await authToken(authorization);
await authToken(req);
const data = await axios
.get(url, {

View File

@@ -19,21 +19,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
pageSize: string;
searchText: string;
};
const { authorization } = req.headers;
pageNum = +pageNum;
pageSize = +pageSize;
if (!authorization) {
throw new Error('无权操作');
}
if (!modelId) {
throw new Error('缺少参数');
}
// 凭证校验
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -12,9 +12,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
await connectToDatabase();
const { authorization } = req.headers;
const userId = await authToken(authorization);
const userId = await authToken(req);
// 找到长度大于0的数据
const data = await SplitData.find({

View File

@@ -13,18 +13,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
modelId: string;
data: string[][];
};
const { authorization } = req.headers;
if (!authorization) {
throw new Error('无权操作');
}
if (!modelId || !Array.isArray(data)) {
throw new Error('缺少参数');
}
// 凭证校验
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();
@@ -36,9 +31,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
// 去重
const searchRes = await Promise.allSettled(
data.map(async ([q, a]) => {
if (!q || !a) {
return Promise.reject('q/a为空');
data.map(async ([q, a = '']) => {
if (!q) {
return Promise.reject('q为空');
}
try {
q = q.replace(/\\n/g, '\n');

View File

@@ -13,18 +13,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
modelId: string;
data: { a: ModelDataSchema['a']; q: ModelDataSchema['q'] }[];
};
const { authorization } = req.headers;
if (!authorization) {
throw new Error('无权操作');
}
if (!modelId || !Array.isArray(data)) {
throw new Error('缺少参数');
}
// 凭证校验
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -8,18 +8,13 @@ import { PgClient } from '@/service/pg';
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
const { dataId, a, q } = req.body as { dataId: string; a: string; q?: string };
const { authorization } = req.headers;
if (!authorization) {
throw new Error('无权操作');
}
if (!dataId) {
throw new Error('缺少参数');
}
// 凭证校验
const userId = await authToken(authorization);
const userId = await authToken(req);
// 更新 pg 内容
await PgClient.update('modelData', {

View File

@@ -20,9 +20,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
await connectToDatabase();
const { authorization } = req.headers;
const userId = await authToken(authorization);
const userId = await authToken(req);
// 验证是否是该用户的 model
const model = await Model.findOne({

View File

@@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { Chat, Model, connectToDatabase } from '@/service/mongo';
import { Chat, Model, connectToDatabase, Collection } from '@/service/mongo';
import { authToken } from '@/service/utils/auth';
import { PgClient } from '@/service/pg';
import { authModel } from '@/service/utils/auth';
@@ -9,18 +9,13 @@ import { authModel } from '@/service/utils/auth';
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
const { modelId } = req.query as { modelId: string };
const { authorization } = req.headers;
if (!authorization) {
throw new Error('无权操作');
}
if (!modelId) {
throw new Error('参数错误');
}
// 凭证校验
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();
@@ -40,6 +35,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
modelId
});
// 删除收藏列表
await Collection.deleteMany({
modelId
});
// 删除模型
await Model.deleteOne({
_id: modelId,

View File

@@ -7,12 +7,6 @@ import { authModel } from '@/service/utils/auth';
/* 获取我的模型 */
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
const { authorization } = req.headers;
if (!authorization) {
throw new Error('无权操作');
}
const { modelId } = req.query as { modelId: string };
if (!modelId) {
@@ -20,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
}
// 凭证校验
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -1,32 +1,49 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase } from '@/service/mongo';
import { connectToDatabase, Collection, Model } from '@/service/mongo';
import { authToken } from '@/service/utils/auth';
import { Model } from '@/service/models/model';
import type { ModelListResponse } from '@/api/response/model';
/* 获取模型列表 */
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
const { authorization } = req.headers;
if (!authorization) {
throw new Error('无权操作');
}
// 凭证校验
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();
// 根据 userId 获取模型信息
const models = await Model.find({
userId
}).sort({
_id: -1
});
const [myModels, myCollections] = await Promise.all([
Model.find(
{
userId
},
'_id avatar name chat.systemPrompt'
).sort({
_id: -1
}),
Collection.find({
userId
}).populate('modelId', '_id avatar name chat.systemPrompt')
]);
jsonRes(res, {
data: models
jsonRes<ModelListResponse>(res, {
data: {
myModels: myModels.map((item) => ({
_id: item._id,
name: item.name,
avatar: item.avatar,
systemPrompt: item.chat.systemPrompt
})),
myCollectionModels: myCollections
.map((item: any) => ({
_id: item.modelId?._id,
name: item.modelId?.name,
avatar: item.modelId?.avatar,
systemPrompt: item.modelId?.chat.systemPrompt
}))
.filter((item) => !myModels.find((model) => String(model._id) === String(item._id))) // 去重
}
});
} catch (err) {
jsonRes(res, {

View File

@@ -12,7 +12,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
throw new Error('缺少参数');
}
// 凭证校验
const userId = await authToken(req.headers.authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -8,7 +8,7 @@ import type { ShareModelItem } from '@/types/model';
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
// 凭证校验
const userId = await authToken(req.headers.authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -1,7 +1,6 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase, Collection, Model } from '@/service/mongo';
import { authToken } from '@/service/utils/auth';
import type { PagingData } from '@/types';
import type { ShareModelItem } from '@/types/model';

View File

@@ -11,18 +11,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
try {
const { name, avatar, chat, share, security } = req.body as ModelUpdateParams;
const { modelId } = req.query as { modelId: string };
const { authorization } = req.headers;
if (!authorization) {
throw new Error('无权操作');
}
if (!name || !chat || !security || !modelId) {
throw new Error('参数错误');
}
// 凭证校验
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -46,7 +46,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
throw new Error('prompts is not array');
}
if (prompts.length > 30 || prompts.length === 0) {
throw new Error('prompts length range 1-30');
throw new Error('Prompts arr length range 1-30');
}
await connectToDatabase();

View File

@@ -7,17 +7,12 @@ import { authToken } from '@/service/utils/auth';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { id } = req.query as { id: string };
const { authorization } = req.headers;
if (!authorization) {
throw new Error('缺少登录凭证');
}
if (!id) {
throw new Error('缺少参数');
}
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -7,13 +7,7 @@ import { UserOpenApiKey } from '@/types/openapi';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { authorization } = req.headers;
if (!authorization) {
throw new Error('缺少登录凭证');
}
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -8,13 +8,7 @@ const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890');
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { authorization } = req.headers;
if (!authorization) {
throw new Error('缺少登录凭证');
}
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -11,10 +11,9 @@ import { PRICE_SCALE } from '@/constants/common';
/* 校验支付结果 */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { authorization } = req.headers;
let { payId } = req.query as { payId: string };
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -7,17 +7,12 @@ import type { BillSchema } from '@/types/mongoSchema';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { authorization } = req.headers;
let { pageNum = 1, pageSize = 10 } = req.query as { pageNum: string; pageSize: string };
pageNum = +pageNum;
pageSize = +pageSize;
if (!authorization) {
throw new Error('缺少登录凭证');
}
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -11,14 +11,10 @@ const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 20);
/* 获取支付二维码 */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { authorization } = req.headers;
let { amount = 0 } = req.query as { amount: string };
amount = +amount;
if (!authorization) {
throw new Error('缺少登录凭证');
}
const userId = await authToken(authorization);
const userId = await authToken(req);
const id = nanoid();
await connectToDatabase();

View File

@@ -5,12 +5,7 @@ import { connectToDatabase, Pay } from '@/service/mongo';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { authorization } = req.headers;
if (!authorization) {
throw new Error('缺少登录凭证');
}
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -32,9 +32,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
throw new Error('密码错误');
}
res.setHeader('Set-Cookie', `token=${generateToken(user._id)}; Path=/; HttpOnly`);
jsonRes(res, {
data: {
token: generateToken(user._id),
user
}
});

View File

@@ -7,13 +7,7 @@ import mongoose from 'mongoose';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { authorization } = req.headers;
if (!authorization) {
throw new Error('缺少登录凭证');
}
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -6,15 +6,11 @@ import { authToken } from '@/service/utils/auth';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { authorization } = req.headers;
let { pageNum = 1, pageSize = 10 } = req.query as { pageNum: string; pageSize: string };
pageNum = +pageNum;
pageSize = +pageSize;
if (!authorization) {
throw new Error('缺少登录凭证');
}
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -56,9 +56,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
username
});
res.setHeader('Set-Cookie', `token=${generateToken(user._id)}; Path=/; HttpOnly`);
jsonRes(res, {
data: {
token: generateToken(user._id),
user
}
});

View File

@@ -7,13 +7,7 @@ import { authToken } from '@/service/utils/auth';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { authorization } = req.headers;
if (!authorization) {
throw new Error('缺少登录凭证');
}
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();

View File

@@ -9,14 +9,9 @@ import { UserUpdateParams } from '@/types/user';
/* 更新一些基本信息 */
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
const { openaiKey } = req.body as UserUpdateParams;
const { authorization } = req.headers;
const { openaiKey, avatar } = req.body as UserUpdateParams;
if (!authorization) {
throw new Error('无权操作');
}
const userId = await authToken(authorization);
const userId = await authToken(req);
await connectToDatabase();
@@ -26,7 +21,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
_id: userId
},
{
openaiKey
...(avatar && { avatar }),
...(openaiKey && { openaiKey })
}
);

View File

@@ -48,9 +48,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
throw new Error('获取用户信息异常');
}
res.setHeader('Set-Cookie', `token=${generateToken(user._id)}; Path=/; HttpOnly`);
jsonRes(res, {
data: {
token: generateToken(user._id),
user
}
});

View File

@@ -1,9 +1,18 @@
import React from 'react';
import { Card, Box } from '@chakra-ui/react';
import { Card, Box, Image, Flex } from '@chakra-ui/react';
import { useMarkdown } from '@/hooks/useMarkdown';
import Markdown from '@/components/Markdown';
import { LOGO_ICON } from '@/constants/chat';
const Empty = ({ modelName, intro }: { modelName: string; intro: string }) => {
const Empty = ({
model: { name, intro, avatar }
}: {
model: {
name: string;
intro: string;
avatar: string;
};
}) => {
const { data: chatProblem } = useMarkdown({ url: '/chatProblem.md' });
const { data: versionIntro } = useMarkdown({ url: '/versionIntro.md' });
@@ -17,14 +26,15 @@ const Empty = ({ modelName, intro }: { modelName: string; intro: string }) => {
alignItems={'center'}
justifyContent={'center'}
>
{!!intro && (
<Card p={4} mb={10}>
<Box fontSize={'xl'} fontWeight={'600'} textAlign={'center'} pb={2}>
{modelName}
<Card p={4} mb={10}>
<Flex mb={2} alignItems={'center'} justifyContent={'center'}>
<Image src={avatar || LOGO_ICON} w={'32px'} h={'32px'} alt={''} />
<Box ml={3} fontSize={'3xl'} fontWeight={'bold'}>
{name}
</Box>
<Box whiteSpace={'pre-line'}>{intro}</Box>
</Card>
)}
</Flex>
<Box whiteSpace={'pre-line'}>{intro}</Box>
</Card>
{/* version intro */}
<Card p={4} mb={10}>
<Markdown source={versionIntro} />

View File

@@ -0,0 +1,242 @@
import React, { useCallback, useRef, useState, useMemo } 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 { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import { useLoading } from '@/hooks/useLoading';
import { useUserStore } from '@/store/user';
import { formatTimeToChatTime } from '@/utils/tools';
import MyIcon from '@/components/Icon';
import type { HistoryItemType, ExportChatType } from '@/types/chat';
import { useChatStore } from '@/store/chat';
import { useScreen } from '@/hooks/useScreen';
import ModelList from './ModelList';
import styles from '../index.module.scss';
const PcSliderBar = ({
isPcDevice,
onclickDelHistory,
onclickExportChat
}: {
isPcDevice: boolean;
onclickDelHistory: (historyId: string) => Promise<void>;
onclickExportChat: (type: ExportChatType) => void;
}) => {
const router = useRouter();
const { modelId = '', chatId = '' } = router.query as { modelId: string; chatId: string };
const theme = useTheme();
const { isPc } = useScreen({ defaultIsPc: isPcDevice });
const ContextMenuRef = useRef(null);
const { Loading, setIsLoading } = useLoading();
const [contextMenuData, setContextMenuData] = useState<{
left: number;
top: number;
history: HistoryItemType;
}>();
const { history, loadHistory } = useChatStore();
const { myModels, myCollectionModels, loadMyModels } = useUserStore();
const models = useMemo(
() => [...myModels, ...myCollectionModels],
[myCollectionModels, myModels]
);
useQuery(['loadModels'], () => loadMyModels(false));
// close contextMenu
useOutsideClick({
ref: ContextMenuRef,
handler: () =>
setTimeout(() => {
setContextMenuData(undefined);
})
});
const { isLoading: isLoadingHistory } = useQuery(['loadingHistory'], () =>
loadHistory({ pageNum: 1 })
);
const onclickContextMenu = useCallback(
(e: MouseEvent<HTMLDivElement>, history: HistoryItemType) => {
e.preventDefault(); // 阻止默认右键菜单
if (!isPc) return;
setContextMenuData({
left: e.clientX + 15,
top: e.clientY + 10,
history
});
},
[isPc]
);
return (
<Flex
position={'relative'}
flexDirection={'column'}
w={'100%'}
h={'100%'}
bg={'white'}
borderRight={['', theme.borders.base]}
>
{/* 新对话 */}
{isPc && (
<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={() => router.replace(`/chat?modelId=${modelId}`)}
>
</Button>
{models.length > 1 && (
<Box
className={styles.modelList}
position={'absolute'}
transition={'0.15s ease-out'}
w={'110%'}
left={0}
top={'45px'}
>
<ModelList models={models} modelId={modelId} />
</Box>
)}
</Box>
)}
{/* chat history */}
<Box flex={'1 0 0'} h={0} overflow={'overlay'}>
{history.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 === chatId
? {
backgroundColor: '#eff0f1',
borderLeftColor: 'myBlue.600 !important'
}
: {})}
onClick={() => {
if (item._id === chatId) return;
if (isPc) {
router.replace(`/chat?modelId=${item.modelId}&chatId=${item._id}`);
} else {
router.push(`/chat?modelId=${item.modelId}&chatId=${item._id}`);
}
}}
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={async (e) => {
e.stopPropagation();
setIsLoading(true);
try {
await onclickDelHistory(item._id);
} catch (error) {
console.log(error);
}
setIsLoading(false);
}}
/>
)}
</Flex>
))}
{!isLoadingHistory && history.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={async () => {
setIsLoading(true);
try {
await onclickDelHistory(contextMenuData.history._id);
if (contextMenuData.history._id === chatId) {
router.replace(`/chat?modelId=${modelId}`);
}
} catch (error) {
console.log(error);
}
setIsLoading(false);
}}
>
</MenuItem>
<MenuItem onClick={() => onclickExportChat('html')}>HTML格式</MenuItem>
<MenuItem onClick={() => onclickExportChat('pdf')}>PDF格式</MenuItem>
<MenuItem onClick={() => onclickExportChat('md')}>Markdown格式</MenuItem>
</MenuList>
</Menu>
</Box>
)}
<Loading loading={isLoadingHistory} fixed={false} />
</Flex>
);
};
export default PcSliderBar;

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { Box, Flex, Image } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import { ModelListItemType } from '@/types/model';
const ModelList = ({ models, modelId }: { models: ModelListItemType[]; modelId: string }) => {
const router = useRouter();
return (
<Box w={'100%'} h={'100%'} bg={'white'} overflow={'overlay'}>
{models.map((item) => (
<Box key={item._id}>
<Flex
key={item._id}
position={'relative'}
alignItems={['flex-start', 'center']}
p={3}
cursor={'pointer'}
transition={'background-color .2s ease-in'}
borderLeft={['', '5px solid transparent']}
_hover={{
backgroundColor: ['', '#dee0e3']
}}
{...(modelId === item._id
? {
backgroundColor: '#eff0f1',
borderLeftColor: 'myBlue.600'
}
: {})}
onClick={() => {
if (item._id === modelId) return;
router.replace(`/chat?modelId=${item._id}`);
}}
>
<Image
src={item.avatar || '/icon/logo.png'}
alt=""
w={'34px'}
maxH={'50px'}
objectFit={'contain'}
/>
<Box flex={'1 0 0'} w={0} ml={3}>
<Box className="textEllipsis" color={'myGray.1000'}>
{item.name}
</Box>
<Box className="textEllipsis" color={'myGray.400'} fontSize={'sm'}>
{item.systemPrompt || '这个模型没有提示词~'}
</Box>
</Box>
</Flex>
</Box>
))}
</Box>
);
};
export default ModelList;

View File

@@ -0,0 +1,194 @@
import React, { useMemo } from 'react';
import { AddIcon, ChatIcon } from '@chakra-ui/icons';
import {
Box,
Button,
Flex,
Divider,
useDisclosure,
useColorMode,
useColorModeValue,
Image
} from '@chakra-ui/react';
import { useUserStore } from '@/store/user';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import MyIcon from '@/components/Icon';
import WxConcat from '@/components/WxConcat';
import { delChatHistoryById } from '@/api/chat';
import { useChatStore } from '@/store/chat';
const PhoneSliderBar = ({
chatId,
modelId,
onClose
}: {
chatId: string;
modelId: string;
onClose: () => void;
}) => {
const router = useRouter();
const { colorMode, toggleColorMode } = useColorMode();
const { myModels, myCollectionModels, loadMyModels } = useUserStore();
const { isOpen: isOpenWx, onOpen: onOpenWx, onClose: onCloseWx } = useDisclosure();
const models = useMemo(
() => [...myModels, ...myCollectionModels],
[myCollectionModels, myModels]
);
useQuery(['loadModels'], () => loadMyModels(false));
const { history, loadHistory } = useChatStore();
useQuery(['loadingHistory'], () => loadHistory({ pageNum: 1 }));
const RenderButton = ({
onClick,
children
}: {
onClick: () => void;
children: JSX.Element | string;
}) => (
<Box px={3} mb={2}>
<Flex
alignItems={'center'}
p={2}
cursor={'pointer'}
borderRadius={'md'}
_hover={{
backgroundColor: 'rgba(255,255,255,0.2)'
}}
onClick={onClick}
>
{children}
</Flex>
</Box>
);
return (
<Flex
flexDirection={'column'}
w={'100%'}
h={'100%'}
py={3}
backgroundColor={useColorModeValue('blackAlpha.800', 'blackAlpha.500')}
color={'white'}
>
<Flex alignItems={'center'} justifyContent={'space-between'} px={3}>
<Box flex={'0 0 50px'}>AI助手</Box>
{/* 新对话 */}
<Button
w={'50%'}
variant={'outline'}
colorScheme={'white'}
mb={2}
leftIcon={<AddIcon />}
onClick={() => router.replace(`/chat?modelId=${modelId}`)}
>
</Button>
</Flex>
{/* 我的模型 & 历史记录 折叠框*/}
<Box flex={'1 0 0'} px={3} h={0} overflowY={'auto'}>
<Box>
{models.map((item) => (
<Flex
key={item._id}
alignItems={'center'}
p={3}
borderRadius={'md'}
mb={2}
cursor={'pointer'}
_hover={{
backgroundColor: 'rgba(255,255,255,0.1)'
}}
fontSize={'xs'}
border={'1px solid transparent'}
{...(item._id === modelId
? {
borderColor: 'rgba(255,255,255,0.5)',
backgroundColor: 'rgba(255,255,255,0.1)'
}
: {})}
onClick={async () => {
if (item._id === modelId) return;
router.replace(`/chat?modelId=${item._id}`);
onClose();
}}
>
<Image src={item.avatar} mr={2} alt={''} w={'16px'} h={'16px'} />
<Box className={'textEllipsis'} flex={'1 0 0'} w={0}>
{item.name}
</Box>
</Flex>
))}
</Box>
<>
<Box py={1}></Box>
{history.map((item) => (
<Flex
key={item._id}
alignItems={'center'}
p={3}
borderRadius={'md'}
mb={2}
fontSize={'xs'}
border={'1px solid transparent'}
{...(item._id === chatId
? {
borderColor: 'rgba(255,255,255,0.5)',
backgroundColor: 'rgba(255,255,255,0.1)'
}
: {})}
onClick={() => {
if (item._id === chatId) return;
router.replace(`/chat?modelId=${item.modelId}&chatId=${item._id}`);
onClose();
}}
>
<ChatIcon mr={2} />
<Box flex={'1 0 0'} w={0} className="textEllipsis">
{item.title}
</Box>
<Box>
<MyIcon
name={'delete'}
w={'14px'}
onClick={async (e) => {
e.stopPropagation();
console.log(111);
await delChatHistoryById(item._id);
loadHistory({ pageNum: 1, init: true });
if (item._id === chatId) {
router.replace(`/chat?modelId=${modelId}`);
}
}}
/>
</Box>
</Flex>
))}
</>
</Box>
<Divider my={3} colorScheme={useColorModeValue('gray', 'white')} />
<RenderButton onClick={() => router.push('/')}>
<>
<MyIcon name="out" fill={'white'} w={'18px'} h={'18px'} mr={4} />
退
</>
</RenderButton>
<RenderButton onClick={onOpenWx}>
<>
<MyIcon name="wx" fill={'white'} w={'18px'} h={'18px'} mr={4} />
</>
</RenderButton>
{/* wx 联系 */}
{isOpenWx && <WxConcat onClose={onCloseWx} />}
</Flex>
);
};
export default PhoneSliderBar;

View File

@@ -1,386 +0,0 @@
import React, { useRef, useEffect, useMemo, useCallback } from 'react';
import { AddIcon, ChatIcon, DeleteIcon, MoonIcon, SunIcon } from '@chakra-ui/icons';
import {
Box,
Button,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
Flex,
Divider,
IconButton,
useDisclosure,
useColorMode,
useColorModeValue,
Menu,
MenuButton,
MenuList,
MenuItem
} from '@chakra-ui/react';
import { useUserStore } from '@/store/user';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import { getToken } from '@/utils/user';
import MyIcon from '@/components/Icon';
import WxConcat from '@/components/WxConcat';
import { getChatHistory, delChatHistoryById } from '@/api/chat';
import { getCollectionModels } from '@/api/model';
import type { ChatSiteItemType } from '../index';
import { fileDownload } from '@/utils/file';
import { htmlTemplate } from '@/constants/common';
const SlideBar = ({
chatId,
modelId,
history,
resetChat,
onClose
}: {
chatId: string;
modelId: string;
history: ChatSiteItemType[];
resetChat: (modelId?: string, chatId?: string) => void;
onClose: () => void;
}) => {
const router = useRouter();
const { colorMode, toggleColorMode } = useColorMode();
const { myModels, getMyModels } = useUserStore();
const { isOpen: isOpenWx, onOpen: onOpenWx, onClose: onCloseWx } = useDisclosure();
const preChatId = useRef('chatId'); // 用于校验上一次chatId的情况,判断是否需要刷新历史记录
const { isSuccess, refetch: fetchMyModels } = useQuery(['getMyModels'], getMyModels, {
cacheTime: 5 * 60 * 1000,
enabled: false
});
const { data: collectionModels = [], refetch: fetchCollectionModels } = useQuery(
[getCollectionModels],
getCollectionModels,
{
cacheTime: 5 * 60 * 1000,
enabled: false
}
);
const models = useMemo(() => {
const myModelList = myModels.map((item) => ({
id: item._id,
name: item.name,
icon: 'model' as any
}));
const collectionList = collectionModels
.map((item) => ({
id: item._id,
name: item.name,
icon: 'collectionSolid' as any
}))
.filter((model) => !myModelList.find((item) => item.id === model.id));
return myModelList.concat(collectionList);
}, [collectionModels, myModels]);
const { data: chatHistory = [], mutate: loadChatHistory } = useMutation({
mutationFn: getChatHistory
});
// update history
useEffect(() => {
if (chatId && preChatId.current === '') {
loadChatHistory();
}
preChatId.current = chatId;
}, [chatId, loadChatHistory]);
// init history
useEffect(() => {
setTimeout(() => {
fetchMyModels();
fetchCollectionModels();
loadChatHistory();
}, 1000);
}, [fetchCollectionModels, fetchMyModels, loadChatHistory]);
/**
* export md
*/
const onclickExportMd = useCallback(() => {
fileDownload({
text: history.map((item) => item.value).join('\n'),
type: 'text/markdown',
filename: 'chat.md'
});
}, [history]);
const getHistoryHtml = useCallback(() => {
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 onclickExportHtml = useCallback(() => {
const html = getHistoryHtml();
html &&
fileDownload({
text: html,
type: 'text/html',
filename: '聊天记录.html'
});
}, [getHistoryHtml]);
const onclickExportPdf = useCallback(() => {
const html = getHistoryHtml();
html &&
// @ts-ignore
html2pdf(html, {
margin: 0,
filename: `聊天记录.pdf`
});
}, [getHistoryHtml]);
const RenderHistory = () => (
<>
{chatHistory.map((item) => (
<Flex
key={item._id}
alignItems={'center'}
p={3}
borderRadius={'md'}
mb={2}
cursor={'pointer'}
_hover={{
backgroundColor: 'rgba(255,255,255,0.1)'
}}
fontSize={'xs'}
border={'1px solid transparent'}
{...(item._id === chatId
? {
borderColor: 'rgba(255,255,255,0.5)',
backgroundColor: 'rgba(255,255,255,0.1)'
}
: {})}
onClick={() => {
if (item._id === chatId) return;
preChatId.current = 'chatId';
resetChat(item.modelId, item._id);
onClose();
}}
>
<ChatIcon mr={2} />
<Box flex={'1 0 0'} w={0} className="textEllipsis">
{item.title}
</Box>
<Box>
<IconButton
icon={<DeleteIcon />}
variant={'unstyled'}
aria-label={'edit'}
size={'xs'}
onClick={async (e) => {
e.stopPropagation();
await delChatHistoryById(item._id);
loadChatHistory();
if (item._id === chatId) {
resetChat();
}
}}
/>
</Box>
</Flex>
))}
</>
);
const RenderButton = ({
onClick,
children
}: {
onClick: () => void;
children: JSX.Element | string;
}) => (
<Box px={3} mb={2}>
<Flex
alignItems={'center'}
p={2}
cursor={'pointer'}
borderRadius={'md'}
_hover={{
backgroundColor: 'rgba(255,255,255,0.2)'
}}
onClick={onClick}
>
{children}
</Flex>
</Box>
);
return (
<Flex
flexDirection={'column'}
w={'100%'}
h={'100%'}
py={3}
backgroundColor={useColorModeValue('blackAlpha.800', 'blackAlpha.500')}
color={'white'}
>
{/* 新对话 */}
{getToken() && (
<Button
w={'90%'}
variant={'white'}
h={'40px'}
mb={2}
mx={'auto'}
leftIcon={<AddIcon />}
onClick={() => resetChat()}
>
</Button>
)}
{/* 我的模型 & 历史记录 折叠框*/}
<Box flex={'1 0 0'} px={3} h={0} overflowY={'auto'}>
{isSuccess && (
<>
<Box>
{models.map((item) => (
<Flex
key={item.id}
alignItems={'center'}
p={3}
borderRadius={'md'}
mb={2}
cursor={'pointer'}
_hover={{
backgroundColor: 'rgba(255,255,255,0.1)'
}}
fontSize={'xs'}
border={'1px solid transparent'}
{...(item.id === modelId
? {
borderColor: 'rgba(255,255,255,0.5)',
backgroundColor: 'rgba(255,255,255,0.1)'
}
: {})}
onClick={async () => {
if (item.id === modelId) return;
resetChat(item.id);
onClose();
}}
>
<MyIcon name={item.icon} mr={2} color={'white'} w={'16px'} h={'16px'} />
<Box className={'textEllipsis'} flex={'1 0 0'} w={0}>
{item.name}
</Box>
</Flex>
))}
</Box>
</>
)}
<Accordion allowToggle>
<AccordionItem borderTop={0} borderBottom={0}>
<AccordionButton borderRadius={'md'} pl={1}>
<Box as="span" flex="1" textAlign="left">
</Box>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={0} px={0}>
<RenderHistory />
</AccordionPanel>
</AccordionItem>
</Accordion>
</Box>
<Divider my={3} colorScheme={useColorModeValue('gray', 'white')} />
{history.length > 0 && (
<Menu autoSelect={false}>
<MenuButton
mx={3}
mb={2}
p={2}
display={'flex'}
alignItems={'center'}
cursor={'pointer'}
borderRadius={'md'}
textAlign={'left'}
_hover={{
backgroundColor: 'rgba(255,255,255,0.2)'
}}
>
<MyIcon name="export" fill={'white'} w={'18px'} h={'18px'} mr={4} />
</MenuButton>
<MenuList fontSize={'sm'} color={'blackAlpha.800'}>
<MenuItem onClick={onclickExportHtml}>HTML格式</MenuItem>
<MenuItem onClick={onclickExportPdf}>PDF格式</MenuItem>
<MenuItem onClick={onclickExportMd}>Markdown格式</MenuItem>
</MenuList>
</Menu>
)}
<RenderButton onClick={() => router.push('/')}>
<>
<MyIcon name="home" fill={'white'} w={'18px'} h={'18px'} mr={4} />
</>
</RenderButton>
<RenderButton onClick={() => router.push('/number/setting')}>
<>
<MyIcon name="pay" fill={'white'} w={'16px'} h={'16px'} mr={4} />
</>
</RenderButton>
<Flex alignItems={'center'} mr={4}>
<Box flex={1}>
<RenderButton onClick={onOpenWx}></RenderButton>
</Box>
<IconButton
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
aria-label={''}
variant={'outline'}
w={'16px'}
colorScheme={'white'}
_hover={{
backgroundColor: 'rgba(255,255,255,0.2)'
}}
onClick={toggleColorMode}
/>
</Flex>
{/* wx 联系 */}
{isOpenWx && <WxConcat onClose={onCloseWx} />}
</Flex>
);
};
export default SlideBar;

View File

@@ -9,3 +9,18 @@
transform: scale(1.2);
}
}
.newChat {
.modelList {
height: 0;
border-radius: 6px;
overflow: hidden;
}
&:hover {
.modelList {
height: 50vh;
box-shadow: 0 0 5px rgba($color: #000000, $alpha: 0.05);
border: 1px solid #dee0e2;
}
}
}

View File

@@ -1,16 +1,16 @@
import React, { useCallback, useState, useRef, useMemo, useEffect } from 'react';
import { useRouter } from 'next/router';
import { getInitChatSiteInfo, delChatRecordByIndex, postSaveChat } from '@/api/chat';
import type { InitChatResponse } from '@/api/response/chat';
import type { ChatItemType } from '@/types/chat';
import {
getInitChatSiteInfo,
delChatRecordByIndex,
postSaveChat,
delChatHistoryById
} from '@/api/chat';
import type { ChatSiteItemType, ExportChatType } from '@/types/chat';
import {
Textarea,
Box,
Flex,
useDisclosure,
Drawer,
DrawerOverlay,
DrawerContent,
useColorModeValue,
Menu,
MenuButton,
@@ -22,38 +22,46 @@ import {
ModalOverlay,
ModalContent,
ModalBody,
ModalCloseButton
ModalCloseButton,
useDisclosure,
Drawer,
DrawerOverlay,
DrawerContent
} from '@chakra-ui/react';
import { useToast } from '@/hooks/useToast';
import { useScreen } from '@/hooks/useScreen';
import { useQuery } from '@tanstack/react-query';
import { OpenAiChatEnum } from '@/constants/model';
import dynamic from 'next/dynamic';
import { useGlobalStore } from '@/store/global';
import { useCopyData } 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 { HUMAN_ICON, LOGO_ICON } from '@/constants/chat';
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';
const SlideBar = dynamic(() => import('./components/SlideBar'));
const PhoneSliderBar = dynamic(() => import('./components/PhoneSliderBar'));
const History = dynamic(() => import('./components/History'));
const Empty = dynamic(() => import('./components/Empty'));
import styles from './index.module.scss';
const textareaMinH = '22px';
export type ChatSiteItemType = {
status: 'loading' | 'finish';
} & ChatItemType;
interface ChatType extends InitChatResponse {
history: ChatSiteItemType[];
}
const Chat = ({ modelId, chatId }: { modelId: string; chatId: string }) => {
const Chat = ({
modelId,
chatId,
isPcDevice
}: {
modelId: string;
chatId: string;
isPcDevice: boolean;
}) => {
const router = useRouter();
const ChatBox = useRef<HTMLDivElement>(null);
@@ -61,31 +69,34 @@ const Chat = ({ modelId, chatId }: { modelId: string; chatId: string }) => {
// 中断请求
const controller = useRef(new AbortController());
const isResetPage = useRef(false);
const [chatData, setChatData] = useState<ChatType>({
chatId,
modelId,
name: '',
avatar: '/icon/logo.png',
intro: '',
chatModel: OpenAiChatEnum.GPT35,
history: []
}); // 聊天框整体数据
const isLeavePage = useRef(false);
const [inputVal, setInputVal] = useState(''); // user input prompt
const [showSystemPrompt, setShowSystemPrompt] = useState('');
const {
lastChatModelId,
setLastChatModelId,
lastChatId,
setLastChatId,
loadHistory,
chatData,
setChatData,
forbidLoadChatData,
setForbidLoadChatData
} = useChatStore();
const isChatting = useMemo(
() => chatData.history[chatData.history.length - 1]?.status === 'loading',
[chatData.history]
);
const { isOpen: isOpenSlider, onClose: onCloseSlider, onOpen: onOpenSlider } = useDisclosure();
const { toast } = useToast();
const { copyData } = useCopyData();
const { isPc, media } = useScreen();
const { setLoading } = useGlobalStore();
const { isPc } = useScreen({ defaultIsPc: isPcDevice });
const { Loading, setIsLoading } = useLoading();
const { userInfo } = useUserStore();
const { isOpen: isOpenSlider, onClose: onCloseSlider, onOpen: onOpenSlider } = useDisclosure();
// 滚动到底部
const scrollToBottom = useCallback((behavior: 'smooth' | 'auto' = 'smooth') => {
@@ -122,83 +133,13 @@ const Chat = ({ modelId, chatId }: { modelId: string; chatId: string }) => {
}, 100);
}, []);
// 获取对话信息
const loadChatInfo = useCallback(
async ({
modelId,
chatId,
isLoading = false,
isScroll = false
}: {
modelId: string;
chatId: string;
isLoading?: boolean;
isScroll?: boolean;
}) => {
isLoading && setLoading(true);
try {
const res = await getInitChatSiteInfo(modelId, chatId);
setChatData({
...res,
history: res.history.map((item) => ({
...item,
status: 'finish'
}))
});
if (isScroll && res.history.length > 0) {
setTimeout(() => {
scrollToBottom('auto');
}, 1000);
}
} catch (e: any) {
toast({
title: e?.message || '获取对话信息异常,请检查地址',
status: 'error',
isClosable: true,
duration: 5000
});
router.back();
}
setLoading(false);
return null;
},
[router, scrollToBottom, setLoading, toast]
);
// 重载新的对话
const resetChat = useCallback(
async (modelId = chatData.modelId, chatId = '') => {
// 强制中断流
isResetPage.current = true;
controller.current?.abort();
try {
router.replace(`/chat?modelId=${modelId}&chatId=${chatId}`);
loadChatInfo({
modelId,
chatId,
isLoading: true,
isScroll: true
});
} catch (error: any) {
toast({
title: error?.message || '生成新对话失败',
status: 'warning'
});
}
onCloseSlider();
},
[chatData.modelId, loadChatInfo, onCloseSlider, router, toast]
);
// gpt 对话
const gptChatPrompt = useCallback(
async (prompts: ChatSiteItemType[]) => {
// create abort obj
const abortSignal = new AbortController();
controller.current = abortSignal;
isResetPage.current = false;
isLeavePage.current = false;
const prompt = {
obj: prompts[0].obj,
@@ -230,7 +171,7 @@ const Chat = ({ modelId, chatId }: { modelId: string; chatId: string }) => {
});
// 重置了页面,说明退出了当前聊天, 不缓存任何内容
if (isResetPage.current) {
if (isLeavePage.current) {
return;
}
@@ -255,7 +196,9 @@ const Chat = ({ modelId, chatId }: { modelId: string; chatId: string }) => {
]
});
if (newChatId) {
setForbidLoadChatData(true);
router.replace(`/chat?modelId=${modelId}&chatId=${newChatId}`);
loadHistory({ pageNum: 1, init: true });
}
} catch (err) {
toast({
@@ -280,7 +223,16 @@ const Chat = ({ modelId, chatId }: { modelId: string; chatId: string }) => {
})
}));
},
[chatId, generatingMessage, modelId, router, toast]
[
chatId,
setForbidLoadChatData,
generatingMessage,
loadHistory,
modelId,
router,
setChatData,
toast
]
);
/**
@@ -351,12 +303,21 @@ const Chat = ({ modelId, chatId }: { modelId: string; chatId: string }) => {
history: newChatList.slice(0, newChatList.length - 2)
}));
}
}, [isChatting, inputVal, chatData.history, resetInputVal, toast, scrollToBottom, gptChatPrompt]);
}, [
isChatting,
inputVal,
chatData.history,
setChatData,
resetInputVal,
toast,
scrollToBottom,
gptChatPrompt
]);
// 删除一句话
const delChatRecord = useCallback(
async (index: number, id: string) => {
setLoading(true);
setIsLoading(true);
try {
// 删除数据库最后一句
await delChatRecordByIndex(chatId, id);
@@ -368,9 +329,9 @@ const Chat = ({ modelId, chatId }: { modelId: string; chatId: string }) => {
} catch (err) {
console.log(err);
}
setLoading(false);
setIsLoading(false);
},
[chatId, setLoading]
[chatId, setChatData, setIsLoading]
);
// 复制内容
@@ -382,20 +343,172 @@ const Chat = ({ modelId, chatId }: { modelId: string; chatId: string }) => {
[copyData]
);
// 初始化聊天框
useQuery(['init'], () =>
loadChatInfo({
modelId,
chatId,
isLoading: true,
isScroll: true
})
// 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: chatData.history.map((item) => item.value).join('\n\n'),
type: 'text/markdown',
filename: 'chat.md'
});
},
html: () => {
const html = getHistoryHtml();
html &&
fileDownload({
text: html,
type: 'text/html',
filename: '聊天记录.html'
});
},
pdf: () => {
const html = getHistoryHtml();
html &&
// @ts-ignore
html2pdf(html, {
margin: 0,
filename: `聊天记录.pdf`
});
}
};
map[type]();
},
[chatData.history]
);
// 更新流中断对象
// delete history and reload history
const onclickDelHistory = useCallback(
async (historyId: string) => {
await delChatHistoryById(historyId);
loadHistory({ pageNum: 1, init: true });
},
[loadHistory]
);
// 获取对话信息
const loadChatInfo = useCallback(
async ({
modelId,
chatId,
isLoading = false
}: {
modelId: string;
chatId: string;
isLoading?: boolean;
}) => {
isLoading && setIsLoading(true);
try {
const res = await getInitChatSiteInfo(modelId, chatId);
setChatData({
...res,
history: res.history.map((item) => ({
...item,
status: 'finish'
}))
});
if (res.history.length > 0) {
setTimeout(() => {
scrollToBottom('auto');
}, 300);
}
// 空 modelId 请求, 重定向到新的 model 聊天
if (res.modelId !== modelId) {
setForbidLoadChatData(true);
router.replace(`/chat?modelId=${res.modelId}`);
}
} catch (e: any) {
// reset all chat tore
setLastChatModelId('');
setLastChatId('');
setChatData();
loadHistory({ pageNum: 1, init: true });
router.replace('/chat');
}
setIsLoading(false);
return null;
},
[
router,
loadHistory,
setForbidLoadChatData,
scrollToBottom,
setChatData,
setIsLoading,
setLastChatId,
setLastChatModelId
]
);
// 初始化聊天框
const { isLoading } = useQuery(['init', modelId, chatId], () => {
// pc: redirect to latest model chat
if (!modelId && lastChatModelId) {
router.replace(`/chat?modelId=${lastChatModelId}&chatId=${lastChatId}`);
return null;
}
// store id
modelId && setLastChatModelId(modelId);
setLastChatId(chatId);
// focus scroll bottom
chatId && scrollToBottom('auto');
/* get mode and chat into ↓ */
// phone: history page
if (!isPc && Object.keys(router.query).length === 0) return null;
if (forbidLoadChatData) {
setForbidLoadChatData(false);
return null;
}
return loadChatInfo({
modelId,
chatId
});
});
useEffect(() => {
return () => {
isResetPage.current = true;
isLeavePage.current = true;
controller.current?.abort();
};
}, []);
@@ -403,239 +516,286 @@ const Chat = ({ modelId, chatId }: { modelId: string; chatId: string }) => {
return (
<Flex
h={'100%'}
flexDirection={media('row', 'column')}
flexDirection={['column', 'row']}
backgroundColor={useColorModeValue('white', '')}
>
{isPc ? (
<Box flex={'0 0 250px'} w={0} h={'100%'}>
<SlideBar
resetChat={resetChat}
chatId={chatId}
modelId={modelId}
history={chatData.history}
onClose={onCloseSlider}
{/* pc always show history. phone is only show when modelId is present */}
{isPc || !modelId ? (
<Box flex={[1, '0 0 250px']} w={['100%', 0]} h={'100%'}>
<History
onclickDelHistory={onclickDelHistory}
onclickExportChat={onclickExportChat}
isPcDevice={isPcDevice}
/>
</Box>
) : (
<Box h={'60px'} borderBottom={'1px solid rgba(0,0,0,0.1)'}>
<Box
h={'44px'}
borderBottom={'1px solid '}
borderBottomColor={useColorModeValue('gray.200', 'gray.700')}
>
<Flex
alignItems={'center'}
h={'100%'}
justifyContent={'space-between'}
backgroundColor={useColorModeValue('white', 'gray.700')}
color={useColorModeValue('blackAlpha.700', 'white')}
position={'relative'}
px={7}
px={5}
>
<Box onClick={onOpenSlider}>
<MyIcon
name={'menu'}
w={'20px'}
h={'20px'}
color={useColorModeValue('blackAlpha.700', 'white')}
/>
</Box>
<Box>{chatData?.name}</Box>
<MyIcon
name={'tabbarMore'}
w={'14px'}
h={'14px'}
color={useColorModeValue('blackAlpha.700', 'white')}
onClick={onOpenSlider}
/>
<Box>{chatData.model.name}</Box>
<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?modelId=${modelId}`)}>
</MenuItem>
<MenuItem
onClick={async () => {
try {
setIsLoading(true);
await onclickDelHistory(chatData.chatId);
router.replace(`/chat`);
} catch (err) {
console.log(err);
}
setIsLoading(false);
}}
>
</MenuItem>
<MenuItem onClick={() => onclickExportChat('html')}>HTML格式</MenuItem>
<MenuItem onClick={() => onclickExportChat('pdf')}>PDF格式</MenuItem>
<MenuItem onClick={() => onclickExportChat('md')}>Markdown格式</MenuItem>
</MenuList>
</Menu>
</Flex>
<Drawer isOpen={isOpenSlider} placement="left" size={'xs'} onClose={onCloseSlider}>
<DrawerOverlay backgroundColor={'rgba(255,255,255,0.5)'} />
<DrawerContent maxWidth={'250px'}>
<SlideBar
resetChat={resetChat}
chatId={chatId}
modelId={modelId}
history={chatData.history}
onClose={onCloseSlider}
/>
<PhoneSliderBar chatId={chatId} modelId={modelId} onClose={onCloseSlider} />
</DrawerContent>
</Drawer>
</Box>
)}
<Flex
{...media({ h: '100%', w: 0 }, { h: 0, w: '100%' })}
flex={'1 0 0'}
flexDirection={'column'}
>
{/* 聊天内容 */}
<Box
id={'history'}
ref={ChatBox}
pb={[4, 0]}
{/* 聊天内容 */}
{modelId && (
<Flex
position={'relative'}
h={[0, '100%']}
w={['100%', 0]}
flex={'1 0 0'}
h={0}
w={'100%'}
overflowY={'auto'}
flexDirection={'column'}
>
{chatData.history.map((item, index) => (
<Box
key={item._id}
py={media(9, 6)}
px={media(4, 2)}
backgroundColor={
index % 2 !== 0 ? useColorModeValue('blackAlpha.50', 'gray.700') : ''
}
color={useColorModeValue('blackAlpha.700', 'white')}
borderBottom={'1px solid rgba(0,0,0,0.1)'}
>
<Flex maxW={'750px'} m={'auto'} alignItems={'flex-start'}>
<Menu autoSelect={false}>
<MenuButton as={Box} mr={media(4, 1)} cursor={'pointer'}>
<Image
className="avatar"
src={item.obj === 'Human' ? HUMAN_ICON : chatData.avatar || LOGO_ICON}
alt="avatar"
w={['20px', '30px']}
maxH={'50px'}
objectFit={'contain'}
/>
</MenuButton>
<MenuList fontSize={'sm'}>
<MenuItem onClick={() => onclickCopy(item.value)}></MenuItem>
<MenuItem onClick={() => delChatRecord(index, item._id)}></MenuItem>
</MenuList>
</Menu>
<Box flex={'1 0 0'} w={0} overflow={'hidden'}>
{item.obj === 'AI' ? (
<>
<Markdown
source={item.value}
isChatting={isChatting && index === chatData.history.length - 1}
<Box
id={'history'}
ref={ChatBox}
pb={[4, 0]}
flex={'1 0 0'}
h={0}
w={'100%'}
overflow={'overlay'}
>
{chatData.history.map((item, index) => (
<Box
key={item._id}
py={[6, 9]}
px={[2, 4]}
backgroundColor={
index % 2 !== 0 ? useColorModeValue('blackAlpha.50', 'gray.700') : ''
}
color={useColorModeValue('blackAlpha.700', 'white')}
borderBottom={'1px solid rgba(0,0,0,0.1)'}
>
<Flex maxW={'750px'} m={'auto'} alignItems={'flex-start'}>
<Menu autoSelect={false}>
<MenuButton as={Box} mr={[1, 4]} cursor={'pointer'}>
<Image
className="avatar"
src={
item.obj === 'Human'
? userInfo?.avatar
: chatData.model.avatar || LOGO_ICON
}
alt="avatar"
w={['20px', '30px']}
maxH={'50px'}
objectFit={'contain'}
/>
{item.systemPrompt && (
<Button
size={'xs'}
mt={2}
fontWeight={'normal'}
colorScheme={'gray'}
variant={'outline'}
onClick={() => setShowSystemPrompt(item.systemPrompt || '')}
>
</Button>
</MenuButton>
<MenuList fontSize={'sm'}>
{chatData.model.canUse && (
<MenuItem onClick={() => router.push(`/model?modelId=${chatData.modelId}`)}>
</MenuItem>
)}
</>
) : (
<Box className="markdown" whiteSpace={'pre-wrap'}>
<Box as={'p'}>{item.value}</Box>
</Box>
)}
</Box>
{isPc && (
<Flex h={'100%'} flexDirection={'column'} ml={2} w={'14px'} height={'100%'}>
<Box minH={'40px'} flex={1}>
<MenuItem onClick={() => onclickCopy(item.value)}></MenuItem>
<MenuItem onClick={() => delChatRecord(index, item._id)}></MenuItem>
</MenuList>
</Menu>
<Box flex={'1 0 0'} w={0} overflow={'hidden'}>
{item.obj === 'AI' ? (
<>
<Markdown
source={item.value}
isChatting={isChatting && index === chatData.history.length - 1}
/>
{item.systemPrompt && (
<Button
size={'xs'}
mt={2}
fontWeight={'normal'}
colorScheme={'gray'}
variant={'outline'}
onClick={() => setShowSystemPrompt(item.systemPrompt || '')}
>
</Button>
)}
</>
) : (
<Box className="markdown" whiteSpace={'pre-wrap'}>
<Box as={'p'}>{item.value}</Box>
</Box>
)}
</Box>
{isPc && (
<Flex h={'100%'} flexDirection={'column'} ml={2} w={'14px'} height={'100%'}>
<Box minH={'40px'} flex={1}>
<MyIcon
name="copy"
w={'14px'}
cursor={'pointer'}
color={'blackAlpha.700'}
onClick={() => onclickCopy(item.value)}
/>
</Box>
<MyIcon
name="copy"
name="delete"
w={'14px'}
cursor={'pointer'}
color={'alphaBlack.400'}
onClick={() => onclickCopy(item.value)}
color={'blackAlpha.700'}
_hover={{
color: 'red.600'
}}
onClick={() => delChatRecord(index, item._id)}
/>
</Box>
<MyIcon
name="delete"
w={'14px'}
cursor={'pointer'}
color={'alphaBlack.400'}
_hover={{
color: 'red.600'
}}
onClick={() => delChatRecord(index, item._id)}
/>
</Flex>
)}
</Flex>
</Box>
))}
{chatData.history.length === 0 && (
<Empty modelName={chatData.name} intro={chatData.intro} />
)}
</Box>
{/* 发送区 */}
<Box m={media('20px auto', '0 auto')} w={'100%'} maxW={media('min(750px, 100%)', 'auto')}>
<Box
py={'18px'}
position={'relative'}
boxShadow={`0 0 15px rgba(0,0,0,0.1)`}
border={media('1px solid', '0')}
borderColor={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'}
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 (isPc && 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();
</Flex>
)}
</Flex>
</Box>
))}
{chatData.history.length === 0 && <Empty model={chatData.model} />}
</Box>
{/* 发送区 */}
{chatData.model.canUse ? (
<Box m={['0 auto', '20px auto']} w={'100%'} maxW={['auto', 'min(750px, 100%)']}>
<Box
py={'18px'}
position={'relative'}
boxShadow={`0 0 15px 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 (isPc && e.keyCode === 13 && !e.shiftKey) {
sendPrompt();
e.preventDefault();
}
// 全选内容
// @ts-ignore
e.key === 'a' && e.ctrlKey && e.target?.select();
}}
/>
) : (
<MyIcon
name={'chatSend'}
width={['18px', '20px']}
height={['18px', '20px']}
cursor={'pointer'}
color={useColorModeValue('gray.500', 'white')}
onClick={sendPrompt}
/>
)}
</Flex>
</Box>
</Box>
</Flex>
{/* 发送和等待按键 */}
<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>
) : (
<Box m={['0 auto', '20px auto']} w={'100%'} textAlign={'center'} color={'myGray.500'}>
</Box>
)}
<Loading loading={isLoading} fixed={false} />
</Flex>
)}
{/* system prompt show modal */}
{
@@ -655,11 +815,10 @@ const Chat = ({ modelId, chatId }: { modelId: string; chatId: string }) => {
export default Chat;
export async function getServerSideProps(context: any) {
const modelId = context?.query?.modelId || '';
const chatId = context?.query?.chatId || '';
Chat.getInitialProps = ({ query, req }: any) => {
return {
props: { modelId, chatId }
modelId: query?.modelId || '',
chatId: query?.chatId || '',
isPcDevice: !/Mobile/.test(req ? req.headers['user-agent'] : navigator.userAgent)
};
}
};

View File

@@ -15,7 +15,7 @@ const Home = () => {
}, [inviterId]);
return (
<>
<Box p={[5, 10]}>
<Card p={5} lineHeight={2}>
<Markdown source={data} isChatting={false} />
</Card>
@@ -28,7 +28,7 @@ const Home = () => {
</Box>
<Box>Made by FastGpt Team.</Box>
</Card>
</>
</Box>
);
};

View File

@@ -159,7 +159,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
float={'right'}
fontSize="sm"
mt={2}
color={'blue.600'}
color={'myBlue.600'}
cursor={'pointer'}
_hover={{ textDecoration: 'underline' }}
onClick={() => setPageType('login')}

View File

@@ -97,7 +97,7 @@ const LoginForm = ({ setPageType, loginSuccess }: Props) => {
{!!errors.password && errors.password.message}
</FormErrorMessage>
</FormControl>
<Flex align={'center'} justifyContent={'space-between'} mt={6} color={'blue.600'}>
<Flex align={'center'} justifyContent={'space-between'} mt={6} color={'myBlue.600'}>
<Box
cursor={'pointer'}
_hover={{ textDecoration: 'underline' }}

View File

@@ -167,7 +167,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
float={'right'}
fontSize="sm"
mt={2}
color={'blue.600'}
color={'myBlue.600'}
cursor={'pointer'}
_hover={{ textDecoration: 'underline' }}
onClick={() => setPageType('login')}

View File

@@ -11,18 +11,18 @@ import dynamic from 'next/dynamic';
const RegisterForm = dynamic(() => import('./components/RegisterForm'));
const ForgetPasswordForm = dynamic(() => import('./components/ForgetPasswordForm'));
const Login = () => {
const Login = ({ isPcDevice }: { isPcDevice: boolean }) => {
const router = useRouter();
const { lastRoute = '' } = router.query as { lastRoute: string };
const { isPc } = useScreen();
const { isPc } = useScreen({ defaultIsPc: isPcDevice });
const [pageType, setPageType] = useState<`${PageTypeEnum}`>(PageTypeEnum.login);
const { setUserInfo } = useUserStore();
const loginSuccess = useCallback(
(res: ResLogin) => {
setUserInfo(res.user, res.token);
setUserInfo(res.user);
setTimeout(() => {
router.push(lastRoute ? decodeURIComponent(lastRoute) : '/model/list');
router.push(lastRoute ? decodeURIComponent(lastRoute) : '/model');
}, 100);
},
[lastRoute, router, setUserInfo]
@@ -41,7 +41,7 @@ const Login = () => {
}
useEffect(() => {
router.prefetch('/model/list');
router.prefetch('/model');
}, [router]);
return (
@@ -60,7 +60,8 @@ const Login = () => {
backgroundColor={'#fff'}
alignItems={'center'}
justifyContent={'center'}
p={10}
py={[5, 10]}
px={'5vw'}
borderRadius={isPc ? 'md' : 'none'}
gap={5}
>
@@ -95,3 +96,9 @@ const Login = () => {
};
export default Login;
Login.getInitialProps = ({ query, req }: any) => {
return {
isPcDevice: !/Mobile/.test(req ? req.headers['user-agent'] : navigator.userAgent)
};
};

View File

@@ -0,0 +1,171 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Box, Flex, useTheme, Input, IconButton, Tooltip, Image } from '@chakra-ui/react';
import { AddIcon } from '@chakra-ui/icons';
import { useRouter } from 'next/router';
import MyIcon from '@/components/Icon';
import { postCreateModel } from '@/api/model';
import { useLoading } from '@/hooks/useLoading';
import { useToast } from '@/hooks/useToast';
import { useQuery } from '@tanstack/react-query';
import { useUserStore } from '@/store/user';
const ModelList = ({ modelId }: { modelId: string }) => {
const theme = useTheme();
const router = useRouter();
const { toast } = useToast();
const { Loading, setIsLoading } = useLoading();
const { myModels, myCollectionModels, loadMyModels, refreshModel } = useUserStore();
const [searchText, setSearchText] = useState('');
/* 加载模型 */
const { isLoading } = useQuery(['loadModels'], () => loadMyModels(false));
const onclickCreateModel = useCallback(async () => {
setIsLoading(true);
try {
const id = await postCreateModel({ name: `AI助手${myModels.length + 1}` });
toast({
title: '创建成功',
status: 'success'
});
refreshModel.freshMyModels();
router.push(`/model?modelId=${id}`);
} catch (err: any) {
toast({
title: typeof err === 'string' ? err : err.message || '出现了意外',
status: 'error'
});
}
setIsLoading(false);
}, [myModels.length, refreshModel, router, setIsLoading, toast]);
const models = useMemo(
() =>
[
{
label: '我的',
list: myModels.filter((item) =>
new RegExp(searchText, 'ig').test(item.name + item.systemPrompt)
)
},
{
label: '收藏',
list: myCollectionModels.filter((item) =>
new RegExp(searchText, 'ig').test(item.name + item.systemPrompt)
)
}
].filter((item) => item.list.length > 0),
[myCollectionModels, myModels, searchText]
);
const totalModels = useMemo(
() => models.reduce((sum, item) => sum + item.list.length, 0),
[models]
);
return (
<Flex
position={'relative'}
flexDirection={'column'}
w={'100%'}
h={'100%'}
bg={'white'}
borderRight={['', theme.borders.base]}
>
<Flex w={'90%'} my={5} mx={'auto'}>
<Flex flex={1} mr={2} position={'relative'} alignItems={'center'}>
<Input
h={'32px'}
placeholder="搜索 AI 助手"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
{searchText && (
<MyIcon
zIndex={10}
position={'absolute'}
right={3}
name={'closeSolid'}
w={'16px'}
h={'16px'}
color={'myGray.500'}
cursor={'pointer'}
onClick={() => setSearchText('')}
/>
)}
</Flex>
<Tooltip label={'新建一个AI助手'}>
<IconButton
h={'32px'}
icon={<AddIcon />}
aria-label={''}
variant={'outline'}
onClick={onclickCreateModel}
/>
</Tooltip>
</Flex>
<Box flex={'1 0 0'} h={0} overflow={'overlay'}>
{models.map((item) => (
<Box key={item.label} _notFirst={{ mt: 5 }}>
<Box fontWeight={'bold'} pl={5}>
{item.label}
</Box>
{item.list.map((item) => (
<Flex
key={item._id}
position={'relative'}
alignItems={['flex-start', 'center']}
p={3}
mb={[2, 0]}
cursor={'pointer'}
transition={'background-color .2s ease-in'}
borderLeft={['', '5px solid transparent']}
_hover={{
backgroundColor: ['', '#dee0e3']
}}
{...(modelId === item._id
? {
backgroundColor: '#eff0f1',
borderLeftColor: 'myBlue.600'
}
: {})}
onClick={() => {
if (item._id === modelId) return;
router.push(`/model?modelId=${item._id}`);
}}
>
<Image
src={item.avatar || '/icon/logo.png'}
alt=""
w={'34px'}
maxH={'50px'}
objectFit={'contain'}
/>
<Box flex={'1 0 0'} w={0} ml={3}>
<Box className="textEllipsis" color={'myGray.1000'}>
{item.name}
</Box>
<Box className="textEllipsis" color={'myGray.400'} fontSize={'sm'}>
{item.systemPrompt || '这个模型没有提示词~'}
</Box>
</Box>
</Flex>
))}
</Box>
))}
{!isLoading && totalModels === 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'}>
AI ~
</Box>
</Flex>
)}
</Box>
<Loading loading={isLoading} fixed={false} />
</Flex>
);
};
export default ModelList;

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState, useRef } from 'react';
import React, { useCallback, useState, useRef, useEffect } from 'react';
import {
Box,
TableContainer,
@@ -53,6 +53,7 @@ const ModelDataCard = ({ modelId, isOwner }: { modelId: string; isOwner: boolean
whiteSpace: 'pre-wrap',
overflowY: 'auto'
});
const {
data: modelDataList,
isLoading,
@@ -66,9 +67,14 @@ const ModelDataCard = ({ modelId, isOwner }: { modelId: string; isOwner: boolean
params: {
modelId,
searchText
}
},
defaultRequest: false
});
useEffect(() => {
getData(1);
}, [modelId, getData]);
const [editInputData, setEditInputData] = useState<InputDataType>();
const {

View File

@@ -54,9 +54,10 @@ const ModelEditForm = ({
if (!file) return;
try {
const base64 = await compressImg({
file
file,
maxW: 40,
maxH: 60
});
setValue('avatar', base64);
setRefresh((state) => !state);
} catch (err: any) {
@@ -184,7 +185,7 @@ const ModelEditForm = ({
<SliderMark
value={getValues('chat.temperature')}
textAlign="center"
bg="blue.500"
bg="myBlue.600"
color="white"
w={'18px'}
h={'18px'}
@@ -195,7 +196,7 @@ const ModelEditForm = ({
{getValues('chat.temperature')}
</SliderMark>
<SliderTrack>
<SliderFilledTrack />
<SliderFilledTrack bg={'myBlue.700'} />
</SliderTrack>
<SliderThumb />
</Slider>

View File

@@ -97,7 +97,7 @@ const SelectJsonModal = ({
my={3}
cursor={'pointer'}
textDecoration={'underline'}
color={'blue.600'}
color={'myBlue.600'}
onClick={() =>
fileDownload({
text: csvTemplate,

View File

@@ -1,72 +1,70 @@
import React, { useCallback, useState, useMemo, useEffect } from 'react';
import { useRouter } from 'next/router';
import { getModelById, delModelById, putModelById } from '@/api/model';
import { delModelById, putModelById } from '@/api/model';
import type { ModelSchema } from '@/types/mongoSchema';
import { Card, Box, Flex, Button, Tag, Grid } from '@chakra-ui/react';
import { useToast } from '@/hooks/useToast';
import { useForm } from 'react-hook-form';
import { formatModelStatus, defaultModel } from '@/constants/model';
import { useGlobalStore } from '@/store/global';
import { useScreen } from '@/hooks/useScreen';
import { formatModelStatus } from '@/constants/model';
import { useQuery } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import { useUserStore } from '@/store/user';
import { useLoading } from '@/hooks/useLoading';
import ModelEditForm from './components/ModelEditForm';
import ModelDataCard from './components/ModelDataCard';
const ModelEditForm = dynamic(() => import('./components/ModelEditForm'));
const ModelDataCard = dynamic(() => import('./components/ModelDataCard'));
const ModelDetail = ({ modelId }: { modelId: string }) => {
const ModelDetail = ({ modelId, isPc }: { modelId: string; isPc: boolean }) => {
const { toast } = useToast();
const router = useRouter();
const { isPc } = useScreen();
const { userInfo } = useUserStore();
const { setLoading } = useGlobalStore();
const { userInfo, modelDetail, loadModelDetail, refreshModel, setLastModelId } = useUserStore();
const { Loading, setIsLoading } = useLoading();
const [btnLoading, setBtnLoading] = useState(false);
const [model, setModel] = useState<ModelSchema>(defaultModel);
const formHooks = useForm<ModelSchema>({
defaultValues: model
defaultValues: modelDetail
});
const isOwner = useMemo(() => model.userId === userInfo?._id, [model.userId, userInfo?._id]);
/* 加载模型数据 */
const loadModel = useCallback(async () => {
setLoading(true);
try {
const res = await getModelById(modelId);
setModel(res);
formHooks.reset(res);
} catch (err: any) {
// load model data
const { isLoading } = useQuery([modelId], () => loadModelDetail(modelId), {
onSuccess(res) {
res && formHooks.reset(res);
modelId && setLastModelId(modelId);
},
onError(err: any) {
toast({
title: err?.message || '获取模型异常',
status: 'error'
});
setLastModelId('');
refreshModel.freshMyModels();
router.replace('/model');
}
setLoading(false);
return null;
}, [formHooks, modelId, setLoading, toast]);
});
useQuery([modelId], loadModel);
const isOwner = useMemo(
() => modelDetail.userId === userInfo?._id,
[modelDetail.userId, userInfo?._id]
);
/* 点击删除 */
const handleDelModel = useCallback(async () => {
if (!model) return;
setLoading(true);
if (!modelDetail) return;
setIsLoading(true);
try {
await delModelById(model._id);
await delModelById(modelDetail._id);
toast({
title: '删除成功',
status: 'success'
});
router.replace('/model/list');
refreshModel.removeModelDetail(modelDetail._id);
router.replace('/model');
} catch (err: any) {
toast({
title: err?.message || '删除失败',
status: 'error'
});
}
setLoading(false);
}, [setLoading, model, router, toast]);
setIsLoading(false);
}, [modelDetail, setIsLoading, toast, refreshModel, router]);
/* 点前往聊天预览页 */
const handlePreviewChat = useCallback(async () => {
@@ -76,7 +74,7 @@ const ModelDetail = ({ modelId }: { modelId: string }) => {
// 提交保存模型修改
const saveSubmitSuccess = useCallback(
async (data: ModelSchema) => {
setLoading(true);
setBtnLoading(true);
try {
await putModelById(data._id, {
name: data.name,
@@ -89,15 +87,16 @@ const ModelDetail = ({ modelId }: { modelId: string }) => {
title: '更新成功',
status: 'success'
});
refreshModel.updateModelDetail(data);
} catch (err: any) {
toast({
title: err?.message || '更新失败',
status: 'error'
});
}
setLoading(false);
setBtnLoading(false);
},
[setLoading, toast]
[refreshModel, toast]
);
// 提交保存表单失败
const saveSubmitError = useCallback(() => {
@@ -131,23 +130,31 @@ const ModelDetail = ({ modelId }: { modelId: string }) => {
}, [router]);
return (
<>
<Box h={'100%'} p={5} overflow={'overlay'} position={'relative'}>
{/* 头部 */}
<Card px={6} py={3}>
{isPc ? (
<Flex alignItems={'center'}>
<Box fontSize={'xl'} fontWeight={'bold'}>
{model.name}
{modelDetail.name}
</Box>
<Tag ml={2} variant="solid" colorScheme={formatModelStatus[model.status].colorTheme}>
{formatModelStatus[model.status].text}
<Tag
ml={2}
variant="solid"
colorScheme={formatModelStatus[modelDetail.status].colorTheme}
>
{formatModelStatus[modelDetail.status].text}
</Tag>
<Box flex={1} />
<Button variant={'outline'} onClick={handlePreviewChat}>
</Button>
{isOwner && (
<Button ml={4} onClick={formHooks.handleSubmit(saveSubmitSuccess, saveSubmitError)}>
<Button
isLoading={btnLoading}
ml={4}
onClick={formHooks.handleSubmit(saveSubmitSuccess, saveSubmitError)}
>
</Button>
)}
@@ -156,20 +163,21 @@ const ModelDetail = ({ modelId }: { modelId: string }) => {
<>
<Flex alignItems={'center'}>
<Box as={'h3'} fontSize={'xl'} fontWeight={'bold'} flex={1}>
{model?.name}
{modelDetail.name}
</Box>
<Tag ml={2} colorScheme={formatModelStatus[model.status].colorTheme}>
{formatModelStatus[model.status].text}
<Tag ml={2} colorScheme={formatModelStatus[modelDetail.status].colorTheme}>
{formatModelStatus[modelDetail.status].text}
</Tag>
</Flex>
<Box mt={4} textAlign={'right'}>
<Button variant={'outline'} size={'sm'} onClick={handlePreviewChat}>
</Button>
{isOwner && (
<Button
ml={4}
size={'sm'}
isLoading={btnLoading}
onClick={formHooks.handleSubmit(saveSubmitSuccess, saveSubmitError)}
>
@@ -182,22 +190,13 @@ const ModelDetail = ({ modelId }: { modelId: string }) => {
<Grid mt={5} gridTemplateColumns={['1fr', '1fr 1fr']} gridGap={5}>
<ModelEditForm formHooks={formHooks} handleDelModel={handleDelModel} isOwner={isOwner} />
{modelId && (
<Card p={4} gridColumnStart={[1, 1]} gridColumnEnd={[2, 3]}>
<ModelDataCard modelId={modelId} isOwner={isOwner} />
</Card>
)}
<Card p={4} gridColumnStart={[1, 1]} gridColumnEnd={[2, 3]}>
<ModelDataCard modelId={modelId} isOwner={isOwner} />
</Card>
</Grid>
</>
<Loading loading={isLoading} fixed={false} />
</Box>
);
};
export default ModelDetail;
export async function getServerSideProps(context: any) {
const modelId = context.query?.modelId || '';
return {
props: { modelId }
};
}

47
src/pages/model/index.tsx Normal file
View File

@@ -0,0 +1,47 @@
import React, { useEffect } from 'react';
import { Box, Flex } from '@chakra-ui/react';
import { useScreen } from '@/hooks/useScreen';
import { useRouter } from 'next/router';
import ModelList from './components/ModelList';
import dynamic from 'next/dynamic';
import { useUserStore } from '@/store/user';
const ModelDetail = dynamic(() => import('./components/detail/index'));
const Model = ({ modelId, isPcDevice }: { modelId: string; isPcDevice: boolean }) => {
const router = useRouter();
const { isPc } = useScreen({
defaultIsPc: isPcDevice
});
const { lastModelId } = useUserStore();
// redirect modelId
useEffect(() => {
if (isPc && !modelId && lastModelId) {
router.replace(`/model?modelId=${lastModelId}`);
}
}, [isPc, lastModelId, modelId, router]);
return (
<Flex h={'100%'} position={'relative'}>
{/* 模型列表 */}
{(isPc || !modelId) && (
<Box w={['100%', '250px']}>
<ModelList modelId={modelId} />
</Box>
)}
<Box flex={1} h={'100%'}>
{modelId && <ModelDetail modelId={modelId} isPc={isPc} />}
</Box>
</Flex>
);
};
export default Model;
Model.getInitialProps = ({ query, req }: any) => {
return {
modelId: query?.modelId || '',
isPcDevice: !/Mobile/.test(req ? req.headers['user-agent'] : navigator.userAgent)
};
};

View File

@@ -1,76 +0,0 @@
import React, { useEffect } from 'react';
import { Box, Button, Flex, Tag } from '@chakra-ui/react';
import type { ModelSchema } from '@/types/mongoSchema';
import { formatModelStatus } from '@/constants/model';
import { useRouter } from 'next/router';
import { ChatModelMap } from '@/constants/model';
const ModelPhoneList = ({
models,
handlePreviewChat
}: {
models: ModelSchema[];
handlePreviewChat: (_: string) => void;
}) => {
const router = useRouter();
useEffect(() => {
router.prefetch('/chat');
}, [router]);
return (
<Box borderRadius={'md'} overflow={'hidden'} mb={5}>
{models.map((model) => (
<Box
key={model._id}
_notFirst={{ borderTop: '1px solid rgba(0,0,0,0.1)' }}
px={6}
py={3}
backgroundColor={'white'}
>
<Flex alignItems={'flex-start'}>
<Box flex={'1 0 0'} w={0} fontSize={'lg'} fontWeight={'bold'}>
{model.name}
</Box>
<Tag
colorScheme={formatModelStatus[model.status].colorTheme}
variant="solid"
px={3}
size={'md'}
>
{formatModelStatus[model.status].text}
</Tag>
</Flex>
<Flex mt={5}>
<Box flex={'0 0 100px'}>: </Box>
<Box color={'blackAlpha.500'}>{ChatModelMap[model.chat.chatModel].name}</Box>
</Flex>
<Flex mt={5}>
<Box flex={'0 0 100px'}>: </Box>
<Box color={'blackAlpha.500'}>{model.chat.temperature}</Box>
</Flex>
<Flex mt={5} justifyContent={'flex-end'}>
<Button
mr={3}
variant={'outline'}
w={'100px'}
size={'sm'}
onClick={() => handlePreviewChat(model._id)}
>
</Button>
<Button
size={'sm'}
w={'100px'}
onClick={() => router.push(`/model/detail?modelId=${model._id}`)}
>
</Button>
</Flex>
</Box>
))}
</Box>
);
};
export default ModelPhoneList;

View File

@@ -1,120 +0,0 @@
import { useEffect } from 'react';
import {
Button,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Tag,
Card,
Box
} from '@chakra-ui/react';
import { formatModelStatus } from '@/constants/model';
import type { ModelSchema } from '@/types/mongoSchema';
import { useRouter } from 'next/router';
import { ChatModelMap } from '@/constants/model';
const ModelTable = ({
models = [],
handlePreviewChat
}: {
models: ModelSchema[];
handlePreviewChat: (_: string) => void;
}) => {
const router = useRouter();
const columns = [
{
title: '模型名',
key: 'name',
dataIndex: 'name'
},
{
title: '对话模型',
key: 'service',
render: (model: ModelSchema) => (
<Box fontWeight={'bold'} whiteSpace={'pre-wrap'} maxW={'200px'}>
{ChatModelMap[model.chat.chatModel].name}
</Box>
)
},
{
title: '温度',
key: 'temperature',
render: (model: ModelSchema) => <>{model.chat.temperature}</>
},
{
title: '状态',
key: 'status',
dataIndex: 'status',
render: (item: ModelSchema) => (
<Tag
colorScheme={formatModelStatus[item.status]?.colorTheme}
variant="solid"
px={3}
size={'md'}
>
{formatModelStatus[item.status]?.text}
</Tag>
)
},
{
title: '操作',
key: 'control',
render: (item: ModelSchema) => (
<>
<Button mr={3} onClick={() => handlePreviewChat(item._id)}>
</Button>
<Button
variant={'outline'}
onClick={() => router.push(`/model/detail?modelId=${item._id}`)}
>
</Button>
</>
)
}
];
useEffect(() => {
router.prefetch('/chat');
}, [router]);
return (
<Card py={3}>
<TableContainer>
<Table variant={'simple'}>
<Thead>
<Tr>
{columns.map((item) => (
<Th key={item.key}>{item.title}</Th>
))}
</Tr>
</Thead>
<Tbody>
{models.map((item) => (
<Tr key={item._id}>
{columns.map((col) => (
<Td key={col.key}>
{col.render
? col.render(item)
: !!col.dataIndex
? // @ts-ignore nextline
item[col.dataIndex]
: ''}
</Td>
))}
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Card>
);
};
export default ModelTable;

View File

@@ -1,90 +0,0 @@
import React, { useCallback } from 'react';
import { Box, Button, Flex, Card } from '@chakra-ui/react';
import type { ModelSchema } from '@/types/mongoSchema';
import { useRouter } from 'next/router';
import ModelTable from './components/ModelTable';
import ModelPhoneList from './components/ModelPhoneList';
import { useScreen } from '@/hooks/useScreen';
import { useQuery } from '@tanstack/react-query';
import { useLoading } from '@/hooks/useLoading';
import { useToast } from '@/hooks/useToast';
import { useUserStore } from '@/store/user';
import { postCreateModel } from '@/api/model';
const modelList = () => {
const { toast } = useToast();
const { isPc } = useScreen();
const router = useRouter();
const { myModels, getMyModels } = useUserStore();
const { Loading, setIsLoading } = useLoading();
/* 加载模型 */
const { isLoading } = useQuery(['loadModels'], getMyModels);
const handleCreateModel = useCallback(async () => {
setIsLoading(true);
try {
const id = await postCreateModel({ name: `模型${myModels.length}` });
toast({
title: '创建成功',
status: 'success'
});
router.push(`/model/detail?modelId=${id}`);
} catch (err: any) {
toast({
title: typeof err === 'string' ? err : err.message || '出现了意外',
status: 'error'
});
}
setIsLoading(false);
}, [myModels.length, router, setIsLoading, toast]);
/* 点前往聊天预览页 */
const handlePreviewChat = useCallback(
async (modelId: string) => {
setIsLoading(true);
try {
router.push(`/chat?modelId=${modelId}`, undefined, {
shallow: true
});
} catch (err: any) {
console.log('error->', err);
toast({
title: err.message || '出现一些异常',
status: 'error'
});
}
setIsLoading(false);
},
[router, setIsLoading, toast]
);
return (
<Box position={'relative'}>
{/* 头部 */}
<Card px={6} py={3}>
<Flex alignItems={'center'} justifyContent={'space-between'}>
<Box fontWeight={'bold'} fontSize={'xl'}>
</Box>
<Button flex={'0 0 145px'} variant={'outline'} onClick={handleCreateModel}>
</Button>
</Flex>
</Card>
{/* 表单 */}
<Box mt={5} position={'relative'}>
{isPc ? (
<ModelTable models={myModels} handlePreviewChat={handlePreviewChat} />
) : (
<ModelPhoneList models={myModels} handlePreviewChat={handlePreviewChat} />
)}
</Box>
<Loading loading={isLoading} />
</Box>
);
};
export default modelList;

View File

@@ -44,7 +44,7 @@ const ShareModelList = ({
<Flex
alignItems={'center'}
cursor={'pointer'}
color={model.isCollection ? 'blue.600' : 'alphaBlack.700'}
color={model.isCollection ? 'myBlue.700' : 'blackAlpha.700'}
onClick={() => onclickCollection(model._id)}
>
<MyIcon
@@ -58,7 +58,7 @@ const ShareModelList = ({
<Button
size={'sm'}
variant={'outline'}
w={'80px'}
w={['60px', '80px']}
onClick={() => router.push(`/chat?modelId=${model._id}`)}
>
@@ -67,8 +67,8 @@ const ShareModelList = ({
<Button
ml={4}
size={'sm'}
w={'80px'}
onClick={() => router.push(`/model/detail?modelId=${model._id}`)}
w={['60px', '80px']}
onClick={() => router.push(`/model?modelId=${model._id}`)}
>
</Button>

View File

@@ -4,7 +4,7 @@ import { useLoading } from '@/hooks/useLoading';
import { getShareModelList, triggerModelCollection, getCollectionModels } from '@/api/model';
import { usePagination } from '@/hooks/usePagination';
import type { ShareModelItem } from '@/types/model';
import { useUserStore } from '@/store/user';
import ShareModelList from './components/list';
import { useQuery } from '@tanstack/react-query';
@@ -12,6 +12,7 @@ const modelList = () => {
const { Loading } = useLoading();
const lastSearch = useRef('');
const [searchText, setSearchText] = useState('');
const { refreshModel } = useUserStore();
/* 加载模型 */
const { data, isLoading, Pagination, getData, pageNum } = usePagination<ShareModelItem>({
@@ -41,15 +42,16 @@ const modelList = () => {
await triggerModelCollection(modelId);
getData(pageNum);
refetchCollection();
refreshModel.removeModelDetail(modelId);
} catch (error) {
console.log(error);
}
},
[getData, pageNum, refetchCollection]
[getData, pageNum, refetchCollection, refreshModel]
);
return (
<>
<Box py={[5, 10]} px={'5vw'}>
<Card px={6} py={3}>
<Flex alignItems={'center'} justifyContent={'space-between'}>
<Box fontWeight={'bold'} fontSize={'xl'}>
@@ -105,7 +107,7 @@ const modelList = () => {
</Card>
<Loading loading={isLoading} />
</>
</Box>
);
};

View File

@@ -119,7 +119,7 @@ const PayModal = ({ onClose }: { onClose: () => void }) => {
<ModalFooter>
{!payId && (
<>
<Button colorScheme={'gray'} onClick={onClose}>
<Button variant={'outline'} onClick={onClose}>
</Button>
<Button

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react';
import { Card, Box, Flex, Button, Input } from '@chakra-ui/react';
import { Card, Box, Flex, Button, Input, Image } from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import { UserUpdateParams } from '@/types/user';
import { putUserInfo } from '@/api/user';
@@ -11,6 +11,8 @@ import { clearToken } from '@/utils/user';
import { useRouter } from 'next/router';
import { useQuery } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import { useSelectFile } from '@/hooks/useSelectFile';
import { compressImg } from '@/utils/file';
const PayRecordTable = dynamic(() => import('./components/PayRecordTable'));
const BilTable = dynamic(() => import('./components/BillTable'));
@@ -26,6 +28,11 @@ const NumberSetting = () => {
const [showPay, setShowPay] = useState(false);
const { toast } = useToast();
const { File, onOpen: onOpenSelectFile } = useSelectFile({
fileType: '.jpg,.png',
multiple: false
});
const onclickSave = useCallback(
async (data: UserUpdateParams) => {
if (data.openaiKey === userInfo?.openaiKey) return;
@@ -43,7 +50,28 @@ const NumberSetting = () => {
[setLoading, toast, updateUserInfo, userInfo?.openaiKey]
);
useQuery(['init'], initUserInfo);
const onSelectFile = useCallback(
async (e: File[]) => {
const file = e[0];
if (!file) return;
try {
const base64 = await compressImg({
file,
maxW: 40,
maxH: 60
});
onclickSave({
avatar: base64
});
} catch (err: any) {
toast({
title: typeof err === 'string' ? err : '头像选择异常',
status: 'warning'
});
}
},
[onclickSave, toast]
);
const onclickLogOut = useCallback(() => {
clearToken();
@@ -51,8 +79,10 @@ const NumberSetting = () => {
router.replace('/login');
}, [router, setUserInfo]);
useQuery(['init'], initUserInfo);
return (
<>
<Box py={[5, 10]} px={'5vw'}>
{/* 核心信息 */}
<Card px={6} py={4}>
<Flex justifyContent={'space-between'}>
@@ -64,16 +94,34 @@ const NumberSetting = () => {
</Button>
</Flex>
<Flex mt={6} alignItems={'center'}>
<Box flex={'0 0 60px'}>:</Box>
<Box flex={'0 0 50px'}>:</Box>
<Image
src={userInfo?.avatar}
alt={'avatar'}
w={['28px', '36px']}
h={['28px', '36px']}
objectFit={'cover'}
cursor={'pointer'}
title={'点击切换头像'}
onClick={onOpenSelectFile}
/>
</Flex>
<Flex mt={6} alignItems={'center'}>
<Box flex={'0 0 50px'}>:</Box>
<Box>{userInfo?.username}</Box>
</Flex>
<Box mt={6}>
<Flex alignItems={'center'}>
<Box flex={'0 0 60px'}>:</Box>
<Box flex={'0 0 50px'}>:</Box>
<Box>
<strong>{userInfo?.balance}</strong>
</Box>
<Button size={'sm'} w={'80px'} ml={5} onClick={() => setShowPay(true)}>
<Button
size={['xs', 'sm']}
w={['70px', '80px']}
ml={5}
onClick={() => setShowPay(true)}
>
</Button>
</Flex>
@@ -98,12 +146,13 @@ const NumberSetting = () => {
</Flex>
</Card>
<File onSelect={onSelectFile} />
{/* 充值记录 */}
<PayRecordTable />
{/* 账单表 */}
<BilTable />
{showPay && <PayModal onClose={() => setShowPay(false)} />}
</>
</Box>
);
};

View File

@@ -51,7 +51,7 @@ const OpenApi = () => {
});
return (
<>
<Box py={[5, 10]} px={'5vw'}>
<Card px={6} py={4} position={'relative'}>
<Box fontSize={'xl'} fontWeight={'bold'}>
FastGpt Api
@@ -65,7 +65,7 @@ const OpenApi = () => {
my={1}
as="a"
href="https://kjqvjse66l.feishu.cn/docx/DmLedTWtUoNGX8xui9ocdUEjnNh"
color={'blue.800'}
color={'myBlue.800'}
textDecoration={'underline'}
target={'_blank'}
>
@@ -131,7 +131,7 @@ const OpenApi = () => {
</ModalBody>
</ModalContent>
</Modal>
</>
</Box>
);
};

View File

@@ -61,7 +61,7 @@ const OpenApi = () => {
});
return (
<>
<Box py={[5, 10]} px={'5vw'}>
<Card px={6} py={4} position={'relative'}>
<Box fontSize={'xl'} fontWeight={'bold'}>
@@ -172,7 +172,7 @@ const OpenApi = () => {
</ModalFooter>
</ModalContent>
</Modal>
</>
</Box>
);
};

51
src/pages/tools/index.tsx Normal file
View File

@@ -0,0 +1,51 @@
import React from 'react';
import { Box, Flex } from '@chakra-ui/react';
import { ChevronRightIcon } from '@chakra-ui/icons';
import MyIcon from '@/components/Icon';
import { useRouter } from 'next/router';
const list = [
{
icon: 'shareMarket',
label: '模型共享市场',
link: '/model/share'
},
{
icon: 'promotion',
label: '邀请好友',
link: '/promotion'
},
{
icon: 'develop',
label: '开发',
link: '/openapi'
}
];
const Tools = () => {
const router = useRouter();
return (
<Box px={'5vw'}>
{list.map((item) => (
<Flex
key={item.link}
alignItems={'center'}
px={5}
py={4}
bg={'white'}
mt={5}
borderRadius={'md'}
onClick={() => router.push(item.link)}
>
<MyIcon name={item.icon as any} w={'22px'} />
<Box ml={4} flex={1}>
{item.label}
</Box>
<ChevronRightIcon fontSize={'20px'} color={'myGray.600'} />
</Flex>
))}
</Box>
);
};
export default Tools;