new framwork
This commit is contained in:
13
client/src/pages/404.tsx
Normal file
13
client/src/pages/404.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
const NonePage = () => {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.push('/model');
|
||||
}, [router]);
|
||||
|
||||
return <div></div>;
|
||||
};
|
||||
|
||||
export default NonePage;
|
||||
77
client/src/pages/_app.tsx
Normal file
77
client/src/pages/_app.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { AppProps } from 'next/app';
|
||||
import Script from 'next/script';
|
||||
import Head from 'next/head';
|
||||
import { ChakraProvider, ColorModeScript } from '@chakra-ui/react';
|
||||
import Layout from '@/components/Layout';
|
||||
import { theme } from '@/constants/theme';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import NProgress from 'nprogress'; //nprogress module
|
||||
import Router from 'next/router';
|
||||
import 'nprogress/nprogress.css';
|
||||
import '../styles/reset.scss';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
|
||||
//Binding events.
|
||||
Router.events.on('routeChangeStart', () => NProgress.start());
|
||||
Router.events.on('routeChangeComplete', () => NProgress.done());
|
||||
Router.events.on('routeChangeError', () => NProgress.done());
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
cacheTime: 0
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
const {
|
||||
loadInitData,
|
||||
initData: { googleVerKey }
|
||||
} = useGlobalStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadInitData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Fast GPT</title>
|
||||
<meta name="description" content="Generated by Fast GPT" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0,user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<Script src="/js/qrcode.min.js" strategy="lazyOnload"></Script>
|
||||
<Script src="/js/pdf.js" strategy="lazyOnload"></Script>
|
||||
<Script src="/js/html2pdf.bundle.min.js" strategy="lazyOnload"></Script>
|
||||
{googleVerKey && (
|
||||
<Script
|
||||
src={`https://www.recaptcha.net/recaptcha/api.js?render=${googleVerKey}`}
|
||||
strategy="lazyOnload"
|
||||
></Script>
|
||||
)}
|
||||
<Script src="/js/particles.js"></Script>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ChakraProvider theme={theme}>
|
||||
<ColorModeScript initialColorMode={theme.config.initialColorMode} />
|
||||
{/* @ts-ignore */}
|
||||
<Layout>
|
||||
<Component {...pageProps} />
|
||||
</Layout>
|
||||
</ChakraProvider>
|
||||
</QueryClientProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// export function reportWebVitals(metric: NextWebVitalsMetric) {
|
||||
// console.log(metric);
|
||||
// }
|
||||
13
client/src/pages/_document.tsx
Normal file
13
client/src/pages/_document.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Html, Head, Main, NextScript } from 'next/document';
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
12
client/src/pages/_error.tsx
Normal file
12
client/src/pages/_error.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
function Error({ errStr }: { errStr: string }) {
|
||||
return <p>{errStr}</p>;
|
||||
}
|
||||
|
||||
Error.getInitialProps = ({ res, err }: { res: any; err: any }) => {
|
||||
console.log(err);
|
||||
return {
|
||||
errStr: `部分系统不兼容,导致页面崩溃。如果可以,请联系作者,反馈下具体操作和页面。大部分是 苹果 的 safari 浏览器导致,可以尝试更换 chrome 浏览器。`
|
||||
};
|
||||
};
|
||||
|
||||
export default Error;
|
||||
37
client/src/pages/api/admin/countTraining.ts
Normal file
37
client/src/pages/api/admin/countTraining.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { connectToDatabase, TrainingData } from '@/service/mongo';
|
||||
import { TrainingModeEnum } from '@/constants/plugin';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
await authUser({ req, authRoot: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
// split queue data
|
||||
const result = await TrainingData.aggregate([
|
||||
{
|
||||
$group: {
|
||||
_id: '$mode',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
jsonRes(res, {
|
||||
data: {
|
||||
qaListLen: result.find((item) => item._id === TrainingModeEnum.qa)?.count || 0,
|
||||
vectorListLen: result.find((item) => item._id === TrainingModeEnum.index)?.count || 0
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error
|
||||
});
|
||||
}
|
||||
}
|
||||
195
client/src/pages/api/chat/chat.ts
Normal file
195
client/src/pages/api/chat/chat.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { connectToDatabase } from '@/service/mongo';
|
||||
import { authChat } from '@/service/utils/auth';
|
||||
import { modelServiceToolMap } from '@/service/utils/chat';
|
||||
import { ChatItemType } from '@/types/chat';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { ChatModelMap, ModelVectorSearchModeMap } from '@/constants/model';
|
||||
import { pushChatBill } from '@/service/events/pushBill';
|
||||
import { resStreamResponse } from '@/service/utils/chat';
|
||||
import { appKbSearch } from '../openapi/kb/appKbSearch';
|
||||
import { ChatRoleEnum, QUOTE_LEN_HEADER, GUIDE_PROMPT_HEADER } from '@/constants/chat';
|
||||
import { BillTypeEnum } from '@/constants/user';
|
||||
import { sensitiveCheck } from '@/service/api/text';
|
||||
import { NEW_CHATID_HEADER } from '@/constants/chat';
|
||||
import { saveChat } from './saveChat';
|
||||
import { Types } from 'mongoose';
|
||||
|
||||
/* 发送提示词 */
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
res.on('close', () => {
|
||||
res.end();
|
||||
});
|
||||
res.on('error', () => {
|
||||
console.log('error: ', 'request error');
|
||||
res.end();
|
||||
});
|
||||
|
||||
try {
|
||||
const { chatId, prompt, modelId } = req.body as {
|
||||
prompt: [ChatItemType, ChatItemType];
|
||||
modelId: string;
|
||||
chatId?: string;
|
||||
};
|
||||
|
||||
if (!modelId || !prompt) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
await connectToDatabase();
|
||||
let startTime = Date.now();
|
||||
|
||||
const { model, showModelDetail, content, userOpenAiKey, systemAuthKey, userId } =
|
||||
await authChat({
|
||||
modelId,
|
||||
chatId,
|
||||
req
|
||||
});
|
||||
|
||||
const modelConstantsData = ChatModelMap[model.chat.chatModel];
|
||||
|
||||
// 读取对话内容
|
||||
const prompts = [...content, prompt[0]];
|
||||
|
||||
const {
|
||||
code = 200,
|
||||
systemPrompts = [],
|
||||
quote = [],
|
||||
guidePrompt = ''
|
||||
} = await (async () => {
|
||||
// 使用了知识库搜索
|
||||
if (model.chat.relatedKbs.length > 0) {
|
||||
const { code, searchPrompts, rawSearch, guidePrompt } = await appKbSearch({
|
||||
model,
|
||||
userId,
|
||||
fixedQuote: content[content.length - 1]?.quote || [],
|
||||
prompt: prompt[0],
|
||||
similarity: ModelVectorSearchModeMap[model.chat.searchMode]?.similarity
|
||||
});
|
||||
|
||||
return {
|
||||
code,
|
||||
quote: rawSearch,
|
||||
systemPrompts: searchPrompts,
|
||||
guidePrompt
|
||||
};
|
||||
}
|
||||
if (model.chat.systemPrompt) {
|
||||
return {
|
||||
guidePrompt: model.chat.systemPrompt,
|
||||
systemPrompts: [
|
||||
{
|
||||
obj: ChatRoleEnum.System,
|
||||
value: model.chat.systemPrompt
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
return {};
|
||||
})();
|
||||
|
||||
// get conversationId. create a newId if it is null
|
||||
const conversationId = chatId || String(new Types.ObjectId());
|
||||
!chatId && res.setHeader(NEW_CHATID_HEADER, conversationId);
|
||||
if (showModelDetail) {
|
||||
guidePrompt && res.setHeader(GUIDE_PROMPT_HEADER, encodeURIComponent(guidePrompt));
|
||||
res.setHeader(QUOTE_LEN_HEADER, quote.length);
|
||||
}
|
||||
|
||||
// search result is empty
|
||||
if (code === 201) {
|
||||
const response = systemPrompts[0]?.value;
|
||||
await saveChat({
|
||||
chatId,
|
||||
newChatId: conversationId,
|
||||
modelId,
|
||||
prompts: [
|
||||
prompt[0],
|
||||
{
|
||||
...prompt[1],
|
||||
quote: [],
|
||||
value: response
|
||||
}
|
||||
],
|
||||
userId
|
||||
});
|
||||
return res.end(response);
|
||||
}
|
||||
|
||||
prompts.unshift(...systemPrompts);
|
||||
|
||||
// content check
|
||||
await sensitiveCheck({
|
||||
input: [...systemPrompts, prompt[0]].map((item) => item.value).join('')
|
||||
});
|
||||
|
||||
// 计算温度
|
||||
const temperature = (modelConstantsData.maxTemperature * (model.chat.temperature / 10)).toFixed(
|
||||
2
|
||||
);
|
||||
|
||||
// 发出 chat 请求
|
||||
const { streamResponse, responseMessages } = await modelServiceToolMap[
|
||||
model.chat.chatModel
|
||||
].chatCompletion({
|
||||
apiKey: userOpenAiKey || systemAuthKey,
|
||||
temperature: +temperature,
|
||||
messages: prompts,
|
||||
stream: true,
|
||||
res,
|
||||
chatId: conversationId
|
||||
});
|
||||
|
||||
console.log('api response time:', `${(Date.now() - startTime) / 1000}s`);
|
||||
|
||||
if (res.closed) return res.end();
|
||||
|
||||
try {
|
||||
const { totalTokens, finishMessages, responseContent } = await resStreamResponse({
|
||||
model: model.chat.chatModel,
|
||||
res,
|
||||
chatResponse: streamResponse,
|
||||
prompts: responseMessages
|
||||
});
|
||||
|
||||
// save chat
|
||||
await saveChat({
|
||||
chatId,
|
||||
newChatId: conversationId,
|
||||
modelId,
|
||||
prompts: [
|
||||
prompt[0],
|
||||
{
|
||||
...prompt[1],
|
||||
value: responseContent,
|
||||
quote: showModelDetail ? quote : [],
|
||||
systemPrompt: showModelDetail ? guidePrompt : ''
|
||||
}
|
||||
],
|
||||
userId
|
||||
});
|
||||
|
||||
res.end();
|
||||
|
||||
// 只有使用平台的 key 才计费
|
||||
pushChatBill({
|
||||
isPay: !userOpenAiKey,
|
||||
chatModel: model.chat.chatModel,
|
||||
userId,
|
||||
chatId: conversationId,
|
||||
textLen: finishMessages.map((item) => item.value).join('').length,
|
||||
tokens: totalTokens,
|
||||
type: BillTypeEnum.chat
|
||||
});
|
||||
} catch (error) {
|
||||
res.end();
|
||||
console.log('error,结束', error);
|
||||
}
|
||||
} catch (err: any) {
|
||||
res.status(500);
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
44
client/src/pages/api/chat/delChatRecordByContentId.ts
Normal file
44
client/src/pages/api/chat/delChatRecordByContentId.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, Chat } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { chatId, contentId } = req.query as {
|
||||
chatId: string;
|
||||
contentId: string;
|
||||
};
|
||||
|
||||
if (!chatId || !contentId) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
const chatRecord = await Chat.findById(chatId);
|
||||
|
||||
if (!chatRecord) {
|
||||
throw new Error('找不到对话');
|
||||
}
|
||||
|
||||
// 删除一条数据库记录
|
||||
await Chat.updateOne(
|
||||
{
|
||||
_id: chatId,
|
||||
userId
|
||||
},
|
||||
{ $pull: { content: { _id: contentId } } }
|
||||
);
|
||||
|
||||
jsonRes(res);
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
39
client/src/pages/api/chat/history/getHistory.ts
Normal file
39
client/src/pages/api/chat/history/getHistory.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, Chat } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import type { HistoryItemType } from '@/types/chat';
|
||||
|
||||
/* 获取历史记录 */
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const data = await Chat.find(
|
||||
{
|
||||
userId
|
||||
},
|
||||
'_id title top customTitle modelId updateTime latestChat'
|
||||
)
|
||||
.sort({ top: -1, updateTime: -1 })
|
||||
.limit(20);
|
||||
|
||||
jsonRes<HistoryItemType[]>(res, {
|
||||
data: data.map((item) => ({
|
||||
_id: item._id,
|
||||
updateTime: item.updateTime,
|
||||
modelId: item.modelId,
|
||||
title: item.customTitle || item.title,
|
||||
latestChat: item.latestChat,
|
||||
top: item.top
|
||||
}))
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
52
client/src/pages/api/chat/history/getHistoryQuote.ts
Normal file
52
client/src/pages/api/chat/history/getHistoryQuote.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, Chat } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { Types } from 'mongoose';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { chatId, historyId } = req.query as {
|
||||
chatId: string;
|
||||
historyId: string;
|
||||
};
|
||||
await connectToDatabase();
|
||||
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
if (!chatId || !historyId) {
|
||||
throw new Error('params is error');
|
||||
}
|
||||
|
||||
const history = await Chat.aggregate([
|
||||
{
|
||||
$match: {
|
||||
_id: new Types.ObjectId(chatId),
|
||||
userId: new Types.ObjectId(userId)
|
||||
}
|
||||
},
|
||||
{
|
||||
$unwind: '$content'
|
||||
},
|
||||
{
|
||||
$match: {
|
||||
'content._id': new Types.ObjectId(historyId)
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
quote: '$content.quote'
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
jsonRes(res, {
|
||||
data: history[0]?.quote || []
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
38
client/src/pages/api/chat/history/updateChatHistory.ts
Normal file
38
client/src/pages/api/chat/history/updateChatHistory.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, Chat } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
|
||||
export type Props = {
|
||||
chatId: '' | string;
|
||||
customTitle?: string;
|
||||
top?: boolean;
|
||||
};
|
||||
|
||||
/* 更新聊天标题 */
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { chatId, customTitle, top } = req.body as Props;
|
||||
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
await Chat.findOneAndUpdate(
|
||||
{
|
||||
_id: chatId,
|
||||
userId
|
||||
},
|
||||
{
|
||||
...(customTitle ? { customTitle } : {}),
|
||||
...(top ? { top } : { top: null })
|
||||
}
|
||||
);
|
||||
jsonRes(res);
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
51
client/src/pages/api/chat/history/updateHistoryQuote.ts
Normal file
51
client/src/pages/api/chat/history/updateHistoryQuote.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, Chat } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { Types } from 'mongoose';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
let { chatId, historyId, quoteId } = req.query as {
|
||||
chatId: string;
|
||||
historyId: string;
|
||||
quoteId: string;
|
||||
};
|
||||
await connectToDatabase();
|
||||
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
if (!chatId || !historyId || !quoteId) {
|
||||
throw new Error('params is error');
|
||||
}
|
||||
|
||||
await Chat.updateOne(
|
||||
{
|
||||
_id: new Types.ObjectId(chatId),
|
||||
userId: new Types.ObjectId(userId),
|
||||
'content._id': new Types.ObjectId(historyId)
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
'content.$.quote.$[quoteElem].source': '手动修改'
|
||||
}
|
||||
},
|
||||
{
|
||||
arrayFilters: [
|
||||
{
|
||||
'quoteElem.id': quoteId
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
jsonRes(res, {
|
||||
data: ''
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
111
client/src/pages/api/chat/init.ts
Normal file
111
client/src/pages/api/chat/init.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, Chat, Model } from '@/service/mongo';
|
||||
import type { InitChatResponse } from '@/api/response/chat';
|
||||
import { authUser } 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 { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
let { modelId, chatId } = req.query as {
|
||||
modelId: '' | string;
|
||||
chatId: '' | string;
|
||||
};
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
let model: ModelSchema;
|
||||
|
||||
// 没有 modelId 时,直接获取用户的第一个id
|
||||
if (!modelId) {
|
||||
const myModel = await Model.findOne({ userId });
|
||||
if (!myModel) {
|
||||
const { _id } = await Model.create({
|
||||
name: '应用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([
|
||||
{
|
||||
$match: {
|
||||
_id: new mongoose.Types.ObjectId(chatId),
|
||||
userId: new mongoose.Types.ObjectId(userId)
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
content: {
|
||||
$slice: ['$content', -50] // 返回 content 数组的最后50个元素
|
||||
}
|
||||
}
|
||||
},
|
||||
{ $unwind: '$content' },
|
||||
{
|
||||
$project: {
|
||||
_id: '$content._id',
|
||||
obj: '$content.obj',
|
||||
value: '$content.value',
|
||||
systemPrompt: '$content.systemPrompt',
|
||||
quoteLen: { $size: { $ifNull: ['$content.quote', []] } }
|
||||
}
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
jsonRes<InitChatResponse>(res, {
|
||||
data: {
|
||||
chatId: chatId || '',
|
||||
modelId: modelId,
|
||||
model: {
|
||||
name: model.name,
|
||||
avatar: model.avatar,
|
||||
intro: model.share.intro,
|
||||
canUse: model.share.isShare || String(model.userId) === userId
|
||||
},
|
||||
chatModel: model.chat.chatModel,
|
||||
history
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
26
client/src/pages/api/chat/removeHistory.ts
Normal file
26
client/src/pages/api/chat/removeHistory.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, Chat } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
|
||||
/* 获取历史记录 */
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { id } = req.query;
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
await Chat.findOneAndRemove({
|
||||
_id: id,
|
||||
userId
|
||||
});
|
||||
|
||||
jsonRes(res);
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
101
client/src/pages/api/chat/saveChat.ts
Normal file
101
client/src/pages/api/chat/saveChat.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { ChatItemType } from '@/types/chat';
|
||||
import { connectToDatabase, Chat, Model } from '@/service/mongo';
|
||||
import { authModel } from '@/service/utils/auth';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
type Props = {
|
||||
newChatId?: string;
|
||||
chatId?: string;
|
||||
modelId: string;
|
||||
prompts: [ChatItemType, ChatItemType];
|
||||
};
|
||||
|
||||
/* 聊天内容存存储 */
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { chatId, modelId, prompts, newChatId } = req.body as Props;
|
||||
|
||||
if (!prompts) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
const nId = await saveChat({
|
||||
chatId,
|
||||
modelId,
|
||||
prompts,
|
||||
newChatId,
|
||||
userId
|
||||
});
|
||||
|
||||
jsonRes(res, {
|
||||
data: nId
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveChat({
|
||||
chatId,
|
||||
newChatId,
|
||||
modelId,
|
||||
prompts,
|
||||
userId
|
||||
}: Props & { userId: string }) {
|
||||
await connectToDatabase();
|
||||
const { model } = await authModel({ modelId, userId, authOwner: false });
|
||||
|
||||
const content = prompts.map((item) => ({
|
||||
_id: item._id ? new mongoose.Types.ObjectId(item._id) : undefined,
|
||||
obj: item.obj,
|
||||
value: item.value,
|
||||
systemPrompt: item.systemPrompt,
|
||||
quote: item.quote || []
|
||||
}));
|
||||
|
||||
const [id] = await Promise.all([
|
||||
...(chatId // update chat
|
||||
? [
|
||||
Chat.findByIdAndUpdate(chatId, {
|
||||
$push: {
|
||||
content: {
|
||||
$each: content
|
||||
}
|
||||
},
|
||||
title: content[0].value.slice(0, 20),
|
||||
latestChat: content[1].value,
|
||||
updateTime: new Date()
|
||||
}).then(() => '')
|
||||
]
|
||||
: [
|
||||
Chat.create({
|
||||
_id: newChatId ? new mongoose.Types.ObjectId(newChatId) : undefined,
|
||||
userId,
|
||||
modelId,
|
||||
content,
|
||||
title: content[0].value.slice(0, 20),
|
||||
latestChat: content[1].value
|
||||
}).then((res) => res._id)
|
||||
]),
|
||||
// update model
|
||||
...(String(model.userId) === userId
|
||||
? [
|
||||
Model.findByIdAndUpdate(modelId, {
|
||||
updateTime: new Date()
|
||||
})
|
||||
]
|
||||
: [])
|
||||
]);
|
||||
|
||||
return {
|
||||
id
|
||||
};
|
||||
}
|
||||
140
client/src/pages/api/chat/shareChat/chat.ts
Normal file
140
client/src/pages/api/chat/shareChat/chat.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
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 { ChatModelMap, ModelVectorSearchModeMap } from '@/constants/model';
|
||||
import { pushChatBill, updateShareChatBill } from '@/service/events/pushBill';
|
||||
import { resStreamResponse } from '@/service/utils/chat';
|
||||
import { ChatRoleEnum } from '@/constants/chat';
|
||||
import { BillTypeEnum } from '@/constants/user';
|
||||
import { sensitiveCheck } from '@/service/api/text';
|
||||
import { appKbSearch } from '../../openapi/kb/appKbSearch';
|
||||
|
||||
/* 发送提示词 */
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
res.on('error', () => {
|
||||
console.log('error: ', 'request error');
|
||||
res.end();
|
||||
});
|
||||
|
||||
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, userOpenAiKey, systemAuthKey, userId } = await authShareChat({
|
||||
shareId,
|
||||
password
|
||||
});
|
||||
|
||||
const modelConstantsData = ChatModelMap[model.chat.chatModel];
|
||||
|
||||
const { code = 200, systemPrompts = [] } = await (async () => {
|
||||
// 使用了知识库搜索
|
||||
if (model.chat.relatedKbs.length > 0) {
|
||||
const { code, searchPrompts } = await appKbSearch({
|
||||
model,
|
||||
userId,
|
||||
fixedQuote: [],
|
||||
prompt: prompts[prompts.length - 1],
|
||||
similarity: ModelVectorSearchModeMap[model.chat.searchMode]?.similarity
|
||||
});
|
||||
|
||||
return {
|
||||
code,
|
||||
systemPrompts: searchPrompts
|
||||
};
|
||||
}
|
||||
if (model.chat.systemPrompt) {
|
||||
return {
|
||||
systemPrompts: [
|
||||
{
|
||||
obj: ChatRoleEnum.System,
|
||||
value: model.chat.systemPrompt
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
return {};
|
||||
})();
|
||||
|
||||
// search result is empty
|
||||
if (code === 201) {
|
||||
return res.send(systemPrompts[0]?.value);
|
||||
}
|
||||
|
||||
prompts.unshift(...systemPrompts);
|
||||
|
||||
// content check
|
||||
await sensitiveCheck({
|
||||
input: [...systemPrompts, prompts[prompts.length - 1]].map((item) => item.value).join('')
|
||||
});
|
||||
|
||||
// 计算温度
|
||||
const temperature = (modelConstantsData.maxTemperature * (model.chat.temperature / 10)).toFixed(
|
||||
2
|
||||
);
|
||||
|
||||
// 发出请求
|
||||
const { streamResponse, responseMessages } = 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`);
|
||||
|
||||
if (res.closed) return res.end();
|
||||
|
||||
try {
|
||||
const { totalTokens, finishMessages } = await resStreamResponse({
|
||||
model: model.chat.chatModel,
|
||||
res,
|
||||
chatResponse: streamResponse,
|
||||
prompts: responseMessages
|
||||
});
|
||||
|
||||
res.end();
|
||||
|
||||
/* bill */
|
||||
pushChatBill({
|
||||
isPay: !userOpenAiKey,
|
||||
chatModel: model.chat.chatModel,
|
||||
userId,
|
||||
textLen: finishMessages.map((item) => item.value).join('').length,
|
||||
tokens: totalTokens,
|
||||
type: BillTypeEnum.chat
|
||||
});
|
||||
updateShareChatBill({
|
||||
shareId,
|
||||
tokens: totalTokens
|
||||
});
|
||||
} catch (error) {
|
||||
res.end();
|
||||
console.log('error,结束', error);
|
||||
}
|
||||
} catch (err: any) {
|
||||
res.status(500);
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
40
client/src/pages/api/chat/shareChat/create.ts
Normal file
40
client/src/pages/api/chat/shareChat/create.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, ShareChat } from '@/service/mongo';
|
||||
import { authModel, authUser } 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 authUser({ req, authToken: true });
|
||||
await authModel({
|
||||
modelId,
|
||||
userId,
|
||||
authOwner: false
|
||||
});
|
||||
|
||||
const { _id } = await ShareChat.create({
|
||||
userId,
|
||||
modelId,
|
||||
name,
|
||||
password,
|
||||
maxContext
|
||||
});
|
||||
|
||||
jsonRes(res, {
|
||||
data: _id
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
29
client/src/pages/api/chat/shareChat/delete.ts
Normal file
29
client/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 { authUser } 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 authUser({ req, authToken: true });
|
||||
|
||||
await ShareChat.findOneAndRemove({
|
||||
_id: id,
|
||||
userId
|
||||
});
|
||||
|
||||
jsonRes(res);
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
64
client/src/pages/api/chat/shareChat/init.ts
Normal file
64
client/src/pages/api/chat/shareChat/init.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, ShareChat, User } from '@/service/mongo';
|
||||
import type { InitShareChatResponse } from '@/api/response/chat';
|
||||
import { authModel } from '@/service/utils/auth';
|
||||
import { hashPassword } from '@/service/utils/tools';
|
||||
import { HUMAN_ICON } from '@/constants/chat';
|
||||
|
||||
/* 初始化我的聊天框,需要身份验证 */
|
||||
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),
|
||||
authOwner: false
|
||||
});
|
||||
|
||||
const user = await User.findById(shareChat.userId, 'avatar');
|
||||
|
||||
jsonRes<InitShareChatResponse>(res, {
|
||||
data: {
|
||||
maxContext: shareChat.maxContext,
|
||||
userAvatar: user?.avatar || HUMAN_ICON,
|
||||
model: {
|
||||
name: model.name,
|
||||
avatar: model.avatar,
|
||||
intro: model.share.intro
|
||||
},
|
||||
chatModel: model.chat.chatModel
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
43
client/src/pages/api/chat/shareChat/list.ts
Normal file
43
client/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 { authUser } 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 authUser({ req, authToken: true });
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
48
client/src/pages/api/model/create.ts
Normal file
48
client/src/pages/api/model/create.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { ModelStatusEnum } from '@/constants/model';
|
||||
import { Model } from '@/service/models/model';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
const { name } = req.body as {
|
||||
name: string;
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
// 上限校验
|
||||
const authCount = await Model.countDocuments({
|
||||
userId
|
||||
});
|
||||
if (authCount >= 30) {
|
||||
throw new Error('上限 30 个应用');
|
||||
}
|
||||
|
||||
// 创建模型
|
||||
const response = await Model.create({
|
||||
name,
|
||||
userId,
|
||||
status: ModelStatusEnum.running
|
||||
});
|
||||
|
||||
jsonRes(res, {
|
||||
data: response._id
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
55
client/src/pages/api/model/del.ts
Normal file
55
client/src/pages/api/model/del.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { Chat, Model, connectToDatabase, Collection, ShareChat } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { authModel } from '@/service/utils/auth';
|
||||
|
||||
/* 获取我的模型 */
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
const { modelId } = req.query as { modelId: string };
|
||||
|
||||
if (!modelId) {
|
||||
throw new Error('参数错误');
|
||||
}
|
||||
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
// 验证是否是该用户的 model
|
||||
await authModel({
|
||||
modelId,
|
||||
userId
|
||||
});
|
||||
|
||||
// 删除对应的聊天
|
||||
await Chat.deleteMany({
|
||||
modelId
|
||||
});
|
||||
|
||||
// 删除收藏列表
|
||||
await Collection.deleteMany({
|
||||
modelId
|
||||
});
|
||||
|
||||
// 删除分享链接
|
||||
await ShareChat.deleteMany({
|
||||
modelId
|
||||
});
|
||||
|
||||
// 删除模型
|
||||
await Model.deleteOne({
|
||||
_id: modelId,
|
||||
userId
|
||||
});
|
||||
|
||||
jsonRes(res);
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
36
client/src/pages/api/model/detail.tsx
Normal file
36
client/src/pages/api/model/detail.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { authModel } from '@/service/utils/auth';
|
||||
|
||||
/* 获取我的模型 */
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
const { modelId } = req.query as { modelId: string };
|
||||
|
||||
if (!modelId) {
|
||||
throw new Error('参数错误');
|
||||
}
|
||||
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const { model } = await authModel({
|
||||
modelId,
|
||||
userId,
|
||||
authOwner: false
|
||||
});
|
||||
|
||||
jsonRes(res, {
|
||||
data: model
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
58
client/src/pages/api/model/list.ts
Normal file
58
client/src/pages/api/model/list.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, Collection, Model } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import type { ModelListResponse } from '@/api/response/model';
|
||||
|
||||
/* 获取模型列表 */
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
// 根据 userId 获取模型信息
|
||||
const [myModels, myCollections] = await Promise.all([
|
||||
Model.find(
|
||||
{
|
||||
userId
|
||||
},
|
||||
'_id avatar name chat.systemPrompt'
|
||||
).sort({
|
||||
updateTime: -1
|
||||
}),
|
||||
Collection.find({ userId })
|
||||
.populate({
|
||||
path: 'modelId',
|
||||
select: '_id avatar name chat.systemPrompt',
|
||||
match: { 'share.isShare': true }
|
||||
})
|
||||
.then((res) => res.filter((item) => item.modelId))
|
||||
]);
|
||||
|
||||
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, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
44
client/src/pages/api/model/share/collection.ts
Normal file
44
client/src/pages/api/model/share/collection.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, Collection, Model } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
|
||||
/* 模型收藏切换 */
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
const { modelId } = req.query as { modelId: string };
|
||||
|
||||
if (!modelId) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const collectionRecord = await Collection.findOne({
|
||||
userId,
|
||||
modelId
|
||||
});
|
||||
|
||||
if (collectionRecord) {
|
||||
await Collection.findByIdAndRemove(collectionRecord._id);
|
||||
} else {
|
||||
await Collection.create({
|
||||
userId,
|
||||
modelId
|
||||
});
|
||||
}
|
||||
|
||||
await Model.findByIdAndUpdate(modelId, {
|
||||
'share.collection': await Collection.countDocuments({ modelId })
|
||||
});
|
||||
|
||||
jsonRes(res);
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
111
client/src/pages/api/model/share/getModels.ts
Normal file
111
client/src/pages/api/model/share/getModels.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, Model } from '@/service/mongo';
|
||||
import type { PagingData } from '@/types';
|
||||
import type { ShareModelItem } from '@/types/model';
|
||||
import { parseCookie } from '@/service/utils/auth';
|
||||
import { Types } from 'mongoose';
|
||||
|
||||
/* 获取模型列表 */
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
const {
|
||||
searchText = '',
|
||||
pageNum = 1,
|
||||
pageSize = 20
|
||||
} = req.body as { searchText: string; pageNum: number; pageSize: number };
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
let userId = '';
|
||||
|
||||
try {
|
||||
userId = await parseCookie(req.headers.cookie);
|
||||
} catch (error) {
|
||||
error;
|
||||
}
|
||||
|
||||
const regex = new RegExp(searchText, 'i');
|
||||
|
||||
const where = {
|
||||
$and: [
|
||||
{ 'share.isShare': true },
|
||||
{
|
||||
$or: [{ name: { $regex: regex } }, { 'share.intro': { $regex: regex } }]
|
||||
}
|
||||
]
|
||||
};
|
||||
const pipeline = [
|
||||
{
|
||||
$match: where
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: 'collections',
|
||||
let: { modelId: '$_id' },
|
||||
pipeline: [
|
||||
{
|
||||
$match: {
|
||||
$expr: {
|
||||
$and: [
|
||||
{ $eq: ['$modelId', '$$modelId'] },
|
||||
{
|
||||
$eq: ['$userId', userId ? new Types.ObjectId(userId) : new Types.ObjectId()]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
as: 'collections'
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 1,
|
||||
avatar: { $ifNull: ['$avatar', '/icon/logo.png'] },
|
||||
name: 1,
|
||||
userId: 1,
|
||||
share: 1,
|
||||
isCollection: {
|
||||
$cond: {
|
||||
if: { $gt: [{ $size: '$collections' }, 0] },
|
||||
then: true,
|
||||
else: false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: { 'share.collection': -1 }
|
||||
},
|
||||
{
|
||||
$skip: (pageNum - 1) * pageSize
|
||||
},
|
||||
{
|
||||
$limit: pageSize
|
||||
}
|
||||
];
|
||||
|
||||
// 获取被分享的模型
|
||||
const [models, total] = await Promise.all([
|
||||
// @ts-ignore
|
||||
Model.aggregate(pipeline),
|
||||
Model.countDocuments(where)
|
||||
]);
|
||||
|
||||
jsonRes<PagingData<ShareModelItem>>(res, {
|
||||
data: {
|
||||
pageNum,
|
||||
pageSize,
|
||||
data: models,
|
||||
total
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
52
client/src/pages/api/model/update.ts
Normal file
52
client/src/pages/api/model/update.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { Model } from '@/service/models/model';
|
||||
import type { ModelUpdateParams } from '@/types/model';
|
||||
import { authModel } from '@/service/utils/auth';
|
||||
|
||||
/* 获取我的模型 */
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
const { name, avatar, chat, share } = req.body as ModelUpdateParams;
|
||||
const { modelId } = req.query as { modelId: string };
|
||||
|
||||
if (!name || !chat || !modelId) {
|
||||
throw new Error('参数错误');
|
||||
}
|
||||
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
await authModel({
|
||||
modelId,
|
||||
userId
|
||||
});
|
||||
|
||||
// 更新模型
|
||||
await Model.updateOne(
|
||||
{
|
||||
_id: modelId,
|
||||
userId
|
||||
},
|
||||
{
|
||||
name,
|
||||
avatar,
|
||||
chat,
|
||||
'share.isShare': share.isShare,
|
||||
'share.isShareDetail': share.isShareDetail,
|
||||
'share.intro': share.intro
|
||||
}
|
||||
);
|
||||
|
||||
jsonRes(res);
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
180
client/src/pages/api/openapi/chat/chat.ts
Normal file
180
client/src/pages/api/openapi/chat/chat.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { connectToDatabase } from '@/service/mongo';
|
||||
import { authUser, authModel, getApiKey } from '@/service/utils/auth';
|
||||
import { modelServiceToolMap, resStreamResponse } from '@/service/utils/chat';
|
||||
import { ChatItemSimpleType } from '@/types/chat';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { ChatModelMap, ModelVectorSearchModeMap } from '@/constants/model';
|
||||
import { pushChatBill } from '@/service/events/pushBill';
|
||||
import { ChatRoleEnum } from '@/constants/chat';
|
||||
import { withNextCors } from '@/service/utils/tools';
|
||||
import { BillTypeEnum } from '@/constants/user';
|
||||
import { sensitiveCheck } from '@/service/api/text';
|
||||
import { NEW_CHATID_HEADER } from '@/constants/chat';
|
||||
import { Types } from 'mongoose';
|
||||
import { appKbSearch } from '../kb/appKbSearch';
|
||||
|
||||
/* 发送提示词 */
|
||||
export default withNextCors(async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
res.on('close', () => {
|
||||
res.end();
|
||||
});
|
||||
res.on('error', () => {
|
||||
console.log('error: ', 'request error');
|
||||
res.end();
|
||||
});
|
||||
|
||||
try {
|
||||
const {
|
||||
chatId,
|
||||
prompts,
|
||||
modelId,
|
||||
isStream = true
|
||||
} = req.body as {
|
||||
chatId?: string;
|
||||
prompts: ChatItemSimpleType[];
|
||||
modelId: string;
|
||||
isStream: boolean;
|
||||
};
|
||||
|
||||
if (!prompts || !modelId) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
if (!Array.isArray(prompts)) {
|
||||
throw new Error('prompts is not array');
|
||||
}
|
||||
if (prompts.length > 30 || prompts.length === 0) {
|
||||
throw new Error('Prompts arr length range 1-30');
|
||||
}
|
||||
|
||||
await connectToDatabase();
|
||||
let startTime = Date.now();
|
||||
|
||||
/* 凭证校验 */
|
||||
const { userId } = await authUser({ req });
|
||||
|
||||
const { model } = await authModel({
|
||||
userId,
|
||||
modelId
|
||||
});
|
||||
|
||||
/* get api key */
|
||||
const { systemAuthKey: apiKey } = await getApiKey({
|
||||
model: model.chat.chatModel,
|
||||
userId,
|
||||
mustPay: true
|
||||
});
|
||||
|
||||
const modelConstantsData = ChatModelMap[model.chat.chatModel];
|
||||
|
||||
let systemPrompts: {
|
||||
obj: ChatRoleEnum;
|
||||
value: string;
|
||||
}[] = [];
|
||||
|
||||
// 使用了知识库搜索
|
||||
if (model.chat.relatedKbs.length > 0) {
|
||||
const { code, searchPrompts } = await appKbSearch({
|
||||
model,
|
||||
userId,
|
||||
fixedQuote: [],
|
||||
prompt: prompts[prompts.length - 1],
|
||||
similarity: ModelVectorSearchModeMap[model.chat.searchMode]?.similarity
|
||||
});
|
||||
|
||||
// search result is empty
|
||||
if (code === 201) {
|
||||
return isStream
|
||||
? res.send(searchPrompts[0]?.value)
|
||||
: jsonRes(res, {
|
||||
data: searchPrompts[0]?.value,
|
||||
message: searchPrompts[0]?.value
|
||||
});
|
||||
}
|
||||
|
||||
systemPrompts = searchPrompts;
|
||||
} else if (model.chat.systemPrompt) {
|
||||
systemPrompts = [
|
||||
{
|
||||
obj: ChatRoleEnum.System,
|
||||
value: model.chat.systemPrompt
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
prompts.unshift(...systemPrompts);
|
||||
|
||||
// content check
|
||||
await sensitiveCheck({
|
||||
input: [...systemPrompts, prompts[prompts.length - 1]].map((item) => item.value).join('')
|
||||
});
|
||||
|
||||
// 计算温度
|
||||
const temperature = (modelConstantsData.maxTemperature * (model.chat.temperature / 10)).toFixed(
|
||||
2
|
||||
);
|
||||
|
||||
// get conversationId. create a newId if it is null
|
||||
const conversationId = chatId || String(new Types.ObjectId());
|
||||
!chatId && res?.setHeader(NEW_CHATID_HEADER, conversationId);
|
||||
|
||||
// 发出请求
|
||||
const { streamResponse, responseMessages, responseText, totalTokens } =
|
||||
await modelServiceToolMap[model.chat.chatModel].chatCompletion({
|
||||
apiKey,
|
||||
temperature: +temperature,
|
||||
messages: prompts,
|
||||
stream: isStream,
|
||||
res,
|
||||
chatId: conversationId
|
||||
});
|
||||
|
||||
console.log('api response time:', `${(Date.now() - startTime) / 1000}s`);
|
||||
|
||||
if (res.closed) return res.end();
|
||||
|
||||
const { textLen = 0, tokens = totalTokens } = await (async () => {
|
||||
if (isStream) {
|
||||
try {
|
||||
const { finishMessages, totalTokens } = await resStreamResponse({
|
||||
model: model.chat.chatModel,
|
||||
res,
|
||||
chatResponse: streamResponse,
|
||||
prompts: responseMessages
|
||||
});
|
||||
res.end();
|
||||
return {
|
||||
textLen: finishMessages.map((item) => item.value).join('').length,
|
||||
tokens: totalTokens
|
||||
};
|
||||
} catch (error) {
|
||||
res.end();
|
||||
console.log('error,结束', error);
|
||||
}
|
||||
} else {
|
||||
jsonRes(res, {
|
||||
data: responseText
|
||||
});
|
||||
return {
|
||||
textLen: responseMessages.map((item) => item.value).join('').length
|
||||
};
|
||||
}
|
||||
return {};
|
||||
})();
|
||||
|
||||
pushChatBill({
|
||||
isPay: true,
|
||||
chatModel: model.chat.chatModel,
|
||||
userId,
|
||||
textLen,
|
||||
tokens,
|
||||
type: BillTypeEnum.openapiChat
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500);
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
});
|
||||
28
client/src/pages/api/openapi/delKey.ts
Normal file
28
client/src/pages/api/openapi/delKey.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, OpenApi } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { id } = req.query as { id: string };
|
||||
|
||||
if (!id) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
await OpenApi.findOneAndRemove({ _id: id, userId });
|
||||
|
||||
jsonRes(res);
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
37
client/src/pages/api/openapi/getKeys.ts
Normal file
37
client/src/pages/api/openapi/getKeys.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, OpenApi } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { UserOpenApiKey } from '@/types/openapi';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const findResponse = await OpenApi.find({ userId }).sort({ _id: -1 });
|
||||
|
||||
// jus save four data
|
||||
const apiKeys = findResponse.map<UserOpenApiKey>(
|
||||
({ _id, apiKey, createTime, lastUsedTime }) => {
|
||||
return {
|
||||
id: _id,
|
||||
apiKey: `${apiKey.substring(0, 2)}******${apiKey.substring(apiKey.length - 2)}`,
|
||||
createTime,
|
||||
lastUsedTime
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
jsonRes(res, {
|
||||
data: apiKeys
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
209
client/src/pages/api/openapi/kb/appKbSearch.ts
Normal file
209
client/src/pages/api/openapi/kb/appKbSearch.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { PgClient } from '@/service/pg';
|
||||
import { withNextCors } from '@/service/utils/tools';
|
||||
import type { ChatItemSimpleType } from '@/types/chat';
|
||||
import type { ModelSchema } from '@/types/mongoSchema';
|
||||
import { appVectorSearchModeEnum } from '@/constants/model';
|
||||
import { authModel } from '@/service/utils/auth';
|
||||
import { ChatModelMap } from '@/constants/model';
|
||||
import { ChatRoleEnum } from '@/constants/chat';
|
||||
import { openaiEmbedding } from '../plugin/openaiEmbedding';
|
||||
import { modelToolMap } from '@/utils/plugin';
|
||||
|
||||
export type QuoteItemType = {
|
||||
id: string;
|
||||
q: string;
|
||||
a: string;
|
||||
source?: string;
|
||||
};
|
||||
type Props = {
|
||||
prompts: ChatItemSimpleType[];
|
||||
similarity: number;
|
||||
appId: string;
|
||||
};
|
||||
type Response = {
|
||||
code: 200 | 201;
|
||||
rawSearch: QuoteItemType[];
|
||||
guidePrompt: string;
|
||||
searchPrompts: {
|
||||
obj: ChatRoleEnum;
|
||||
value: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export default withNextCors(async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
const { userId } = await authUser({ req });
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('userId is empty');
|
||||
}
|
||||
|
||||
const { prompts, similarity, appId } = req.body as Props;
|
||||
|
||||
if (!similarity || !Array.isArray(prompts) || !appId) {
|
||||
throw new Error('params is error');
|
||||
}
|
||||
|
||||
// auth model
|
||||
const { model } = await authModel({
|
||||
modelId: appId,
|
||||
userId
|
||||
});
|
||||
|
||||
const result = await appKbSearch({
|
||||
model,
|
||||
userId,
|
||||
fixedQuote: [],
|
||||
prompt: prompts[prompts.length - 1],
|
||||
similarity
|
||||
});
|
||||
|
||||
jsonRes<Response>(res, {
|
||||
data: result
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export async function appKbSearch({
|
||||
model,
|
||||
userId,
|
||||
fixedQuote,
|
||||
prompt,
|
||||
similarity
|
||||
}: {
|
||||
model: ModelSchema;
|
||||
userId: string;
|
||||
fixedQuote: QuoteItemType[];
|
||||
prompt: ChatItemSimpleType;
|
||||
similarity: number;
|
||||
}): Promise<Response> {
|
||||
const modelConstantsData = ChatModelMap[model.chat.chatModel];
|
||||
|
||||
// get vector
|
||||
const promptVector = await openaiEmbedding({
|
||||
userId,
|
||||
input: [prompt.value],
|
||||
type: 'chat'
|
||||
});
|
||||
|
||||
// search kb
|
||||
const res: any = await PgClient.query(
|
||||
`BEGIN;
|
||||
select id,q,a,source from modelData where kb_id IN (${model.chat.relatedKbs
|
||||
.map((item) => `'${item}'`)
|
||||
.join(',')}) AND vector <#> '[${promptVector[0]}]' < -${similarity} order by vector <#> '[${
|
||||
promptVector[0]
|
||||
}]' limit 8;
|
||||
COMMIT;`
|
||||
);
|
||||
|
||||
const searchRes: QuoteItemType[] = res?.[1]?.rows || [];
|
||||
|
||||
// filter same search result
|
||||
const idSet = new Set<string>();
|
||||
const filterSearch = [
|
||||
...searchRes.slice(0, 3),
|
||||
...fixedQuote.slice(0, 2),
|
||||
...searchRes.slice(3),
|
||||
...fixedQuote.slice(2, 5)
|
||||
].filter((item) => {
|
||||
if (idSet.has(item.id)) {
|
||||
return false;
|
||||
}
|
||||
idSet.add(item.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 计算固定提示词的 token 数量
|
||||
const guidePrompt = model.chat.systemPrompt // user system prompt
|
||||
? {
|
||||
obj: ChatRoleEnum.System,
|
||||
value: model.chat.systemPrompt
|
||||
}
|
||||
: model.chat.searchMode === appVectorSearchModeEnum.noContext
|
||||
? {
|
||||
obj: ChatRoleEnum.System,
|
||||
value: `知识库是关于"${model.name}"的内容,根据知识库内容回答问题.`
|
||||
}
|
||||
: {
|
||||
obj: ChatRoleEnum.System,
|
||||
value: `玩一个问答游戏,规则为:
|
||||
1.你完全忘记你已有的知识
|
||||
2.你只回答关于"${model.name}"的问题
|
||||
3.你只从知识库中选择内容进行回答
|
||||
4.如果问题不在知识库中,你会回答:"我不知道。"
|
||||
请务必遵守规则`
|
||||
};
|
||||
|
||||
const fixedSystemTokens = modelToolMap[model.chat.chatModel].countTokens({
|
||||
messages: [guidePrompt]
|
||||
});
|
||||
const sliceResult = modelToolMap[model.chat.chatModel]
|
||||
.tokenSlice({
|
||||
maxToken: modelConstantsData.systemMaxToken - fixedSystemTokens,
|
||||
messages: filterSearch.map((item) => ({
|
||||
obj: ChatRoleEnum.System,
|
||||
value: `${item.q}\n${item.a}`
|
||||
}))
|
||||
})
|
||||
.map((item) => item.value);
|
||||
|
||||
// slice filterSearch
|
||||
const rawSearch = filterSearch.slice(0, sliceResult.length);
|
||||
|
||||
// system prompt
|
||||
const systemPrompt = sliceResult.join('\n').trim();
|
||||
|
||||
/* 高相似度+不回复 */
|
||||
if (!systemPrompt && model.chat.searchMode === appVectorSearchModeEnum.hightSimilarity) {
|
||||
return {
|
||||
code: 201,
|
||||
rawSearch: [],
|
||||
guidePrompt: '',
|
||||
searchPrompts: [
|
||||
{
|
||||
obj: ChatRoleEnum.System,
|
||||
value: '对不起,你的问题不在知识库中。'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
/* 高相似度+无上下文,不添加额外知识,仅用系统提示词 */
|
||||
if (!systemPrompt && model.chat.searchMode === appVectorSearchModeEnum.noContext) {
|
||||
return {
|
||||
code: 200,
|
||||
rawSearch: [],
|
||||
guidePrompt: model.chat.systemPrompt || '',
|
||||
searchPrompts: model.chat.systemPrompt
|
||||
? [
|
||||
{
|
||||
obj: ChatRoleEnum.System,
|
||||
value: model.chat.systemPrompt
|
||||
}
|
||||
]
|
||||
: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
rawSearch,
|
||||
guidePrompt: guidePrompt.value || '',
|
||||
searchPrompts: [
|
||||
{
|
||||
obj: ChatRoleEnum.System,
|
||||
value: `知识库:${systemPrompt}`
|
||||
},
|
||||
guidePrompt
|
||||
]
|
||||
};
|
||||
}
|
||||
32
client/src/pages/api/openapi/kb/delDataById.ts
Normal file
32
client/src/pages/api/openapi/kb/delDataById.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { PgClient } from '@/service/pg';
|
||||
import { withNextCors } from '@/service/utils/tools';
|
||||
|
||||
export default withNextCors(async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
let { dataId } = req.query as {
|
||||
dataId: string;
|
||||
};
|
||||
|
||||
if (!dataId) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req });
|
||||
|
||||
await PgClient.delete('modelData', {
|
||||
where: [['user_id', userId], 'AND', ['id', dataId]]
|
||||
});
|
||||
|
||||
jsonRes(res);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
});
|
||||
149
client/src/pages/api/openapi/kb/pushData.ts
Normal file
149
client/src/pages/api/openapi/kb/pushData.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, TrainingData } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { authKb } from '@/service/utils/auth';
|
||||
import { withNextCors } from '@/service/utils/tools';
|
||||
import { TrainingModeEnum } from '@/constants/plugin';
|
||||
import { startQueue } from '@/service/utils/tools';
|
||||
import { PgClient } from '@/service/pg';
|
||||
|
||||
type DateItemType = { a: string; q: string; source?: string };
|
||||
|
||||
export type Props = {
|
||||
kbId: string;
|
||||
data: DateItemType[];
|
||||
mode: `${TrainingModeEnum}`;
|
||||
prompt?: string;
|
||||
};
|
||||
|
||||
export type Response = {
|
||||
insertLen: number;
|
||||
};
|
||||
|
||||
export default withNextCors(async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
const { kbId, data, mode, prompt } = req.body as Props;
|
||||
|
||||
if (!kbId || !Array.isArray(data)) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
await connectToDatabase();
|
||||
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req });
|
||||
|
||||
jsonRes<Response>(res, {
|
||||
data: await pushDataToKb({
|
||||
kbId,
|
||||
data,
|
||||
userId,
|
||||
mode,
|
||||
prompt
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export async function pushDataToKb({
|
||||
userId,
|
||||
kbId,
|
||||
data,
|
||||
mode,
|
||||
prompt
|
||||
}: { userId: string } & Props): Promise<Response> {
|
||||
await authKb({
|
||||
userId,
|
||||
kbId
|
||||
});
|
||||
|
||||
// 过滤重复的 qa 内容
|
||||
const set = new Set();
|
||||
const filterData: DateItemType[] = [];
|
||||
|
||||
data.forEach((item) => {
|
||||
const text = item.q + item.a;
|
||||
if (!set.has(text)) {
|
||||
filterData.push(item);
|
||||
set.add(text);
|
||||
}
|
||||
});
|
||||
|
||||
// 数据库去重
|
||||
const insertData = (
|
||||
await Promise.allSettled(
|
||||
filterData.map(async ({ q, a = '', source }) => {
|
||||
if (mode !== TrainingModeEnum.index) {
|
||||
return Promise.resolve({
|
||||
q,
|
||||
a,
|
||||
source
|
||||
});
|
||||
}
|
||||
|
||||
if (!q) {
|
||||
return Promise.reject('q为空');
|
||||
}
|
||||
|
||||
q = q.replace(/\\n/g, '\n').trim().replace(/'/g, '"');
|
||||
a = a.replace(/\\n/g, '\n').trim().replace(/'/g, '"');
|
||||
|
||||
// Exactly the same data, not push
|
||||
try {
|
||||
const { rows } = await PgClient.query(`
|
||||
SELECT COUNT(*) > 0 AS exists
|
||||
FROM modelData
|
||||
WHERE md5(q)=md5('${q}') AND md5(a)=md5('${a}') AND user_id='${userId}' AND kb_id='${kbId}'
|
||||
`);
|
||||
const exists = rows[0]?.exists || false;
|
||||
|
||||
if (exists) {
|
||||
return Promise.reject('已经存在');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
error;
|
||||
}
|
||||
return Promise.resolve({
|
||||
q,
|
||||
a,
|
||||
source
|
||||
});
|
||||
})
|
||||
)
|
||||
)
|
||||
.filter((item) => item.status === 'fulfilled')
|
||||
.map<DateItemType>((item: any) => item.value);
|
||||
|
||||
// 插入记录
|
||||
await TrainingData.insertMany(
|
||||
insertData.map((item) => ({
|
||||
q: item.q,
|
||||
a: item.a,
|
||||
source: item.source,
|
||||
userId,
|
||||
kbId,
|
||||
mode,
|
||||
prompt
|
||||
}))
|
||||
);
|
||||
|
||||
insertData.length > 0 && startQueue();
|
||||
|
||||
return {
|
||||
insertLen: insertData.length
|
||||
};
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: '20mb'
|
||||
}
|
||||
}
|
||||
};
|
||||
53
client/src/pages/api/openapi/kb/updateData.ts
Normal file
53
client/src/pages/api/openapi/kb/updateData.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { PgClient } from '@/service/pg';
|
||||
import { withNextCors } from '@/service/utils/tools';
|
||||
import { openaiEmbedding } from '../plugin/openaiEmbedding';
|
||||
|
||||
export default withNextCors(async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
const { dataId, a = '', q = '' } = req.body as { dataId: string; a?: string; q?: string };
|
||||
|
||||
if (!dataId) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req });
|
||||
|
||||
// get vector
|
||||
const vector = await (async () => {
|
||||
if (q) {
|
||||
return openaiEmbedding({
|
||||
userId,
|
||||
input: [q],
|
||||
type: 'chat'
|
||||
});
|
||||
}
|
||||
return [];
|
||||
})();
|
||||
|
||||
// 更新 pg 内容.仅修改a,不需要更新向量。
|
||||
await PgClient.update('modelData', {
|
||||
where: [['id', dataId], 'AND', ['user_id', userId]],
|
||||
values: [
|
||||
{ key: 'source', value: '手动修改' },
|
||||
{ key: 'a', value: a.replace(/'/g, '"') },
|
||||
...(q
|
||||
? [
|
||||
{ key: 'q', value: q.replace(/'/g, '"') },
|
||||
{ key: 'vector', value: `[${vector[0]}]` }
|
||||
]
|
||||
: [])
|
||||
]
|
||||
});
|
||||
|
||||
jsonRes(res);
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
});
|
||||
79
client/src/pages/api/openapi/plugin/openaiEmbedding.ts
Normal file
79
client/src/pages/api/openapi/plugin/openaiEmbedding.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { authUser, getApiKey } from '@/service/utils/auth';
|
||||
import { withNextCors } from '@/service/utils/tools';
|
||||
import { getOpenAIApi } from '@/service/utils/chat/openai';
|
||||
import { embeddingModel } from '@/constants/model';
|
||||
import { axiosConfig } from '@/service/utils/tools';
|
||||
import { pushGenerateVectorBill } from '@/service/events/pushBill';
|
||||
import { ApiKeyType } from '@/service/utils/auth';
|
||||
|
||||
type Props = {
|
||||
input: string[];
|
||||
type?: ApiKeyType;
|
||||
};
|
||||
type Response = number[][];
|
||||
|
||||
export default withNextCors(async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
const { userId } = await authUser({ req });
|
||||
let { input, type } = req.query as Props;
|
||||
|
||||
if (!Array.isArray(input)) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
jsonRes<Response>(res, {
|
||||
data: await openaiEmbedding({ userId, input, type, mustPay: true })
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export async function openaiEmbedding({
|
||||
userId,
|
||||
input,
|
||||
mustPay = false,
|
||||
type = 'chat'
|
||||
}: { userId: string; mustPay?: boolean } & Props) {
|
||||
const { userOpenAiKey, systemAuthKey } = await getApiKey({
|
||||
model: 'gpt-3.5-turbo',
|
||||
userId,
|
||||
mustPay,
|
||||
type
|
||||
});
|
||||
|
||||
// 获取 chatAPI
|
||||
const chatAPI = getOpenAIApi();
|
||||
|
||||
// 把输入的内容转成向量
|
||||
const result = await chatAPI
|
||||
.createEmbedding(
|
||||
{
|
||||
model: embeddingModel,
|
||||
input
|
||||
},
|
||||
{
|
||||
timeout: 60000,
|
||||
...axiosConfig(userOpenAiKey || systemAuthKey)
|
||||
}
|
||||
)
|
||||
.then((res) => ({
|
||||
tokenLen: res.data.usage.total_tokens || 0,
|
||||
vectors: res.data.data.map((item) => item.embedding)
|
||||
}));
|
||||
|
||||
pushGenerateVectorBill({
|
||||
isPay: !userOpenAiKey,
|
||||
userId,
|
||||
text: input.join(''),
|
||||
tokenLen: result.tokenLen
|
||||
});
|
||||
|
||||
return result.vectors;
|
||||
}
|
||||
37
client/src/pages/api/openapi/postKey.ts
Normal file
37
client/src/pages/api/openapi/postKey.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, OpenApi } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890');
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const count = await OpenApi.find({ userId }).countDocuments();
|
||||
|
||||
if (count >= 5) {
|
||||
throw new Error('最多 5 组API Key');
|
||||
}
|
||||
|
||||
const apiKey = `${userId}-${nanoid()}`;
|
||||
|
||||
await OpenApi.create({
|
||||
userId,
|
||||
apiKey
|
||||
});
|
||||
|
||||
jsonRes(res, {
|
||||
data: apiKey
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
66
client/src/pages/api/openapi/text/gptMessagesSlice.ts
Normal file
66
client/src/pages/api/openapi/text/gptMessagesSlice.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import type { ChatItemSimpleType } from '@/types/chat';
|
||||
import { countOpenAIToken } from '@/utils/plugin/openai';
|
||||
|
||||
type ModelType = 'gpt-3.5-turbo' | 'gpt-4' | 'gpt-4-32k';
|
||||
|
||||
type Props = {
|
||||
messages: ChatItemSimpleType[];
|
||||
model: ModelType;
|
||||
maxLen: number;
|
||||
};
|
||||
type Response = ChatItemSimpleType[];
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
await authUser({ req });
|
||||
|
||||
const { messages, model, maxLen } = req.body as Props;
|
||||
|
||||
if (!Array.isArray(messages) || !model || !maxLen) {
|
||||
throw new Error('params is error');
|
||||
}
|
||||
|
||||
return jsonRes<Response>(res, {
|
||||
data: gpt_chatItemTokenSlice({
|
||||
messages,
|
||||
model,
|
||||
maxToken: maxLen
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function gpt_chatItemTokenSlice({
|
||||
messages,
|
||||
model,
|
||||
maxToken
|
||||
}: {
|
||||
messages: ChatItemSimpleType[];
|
||||
model: ModelType;
|
||||
maxToken: number;
|
||||
}) {
|
||||
let result: ChatItemSimpleType[] = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msgs = [...result, messages[i]];
|
||||
|
||||
const tokens = countOpenAIToken({ messages: msgs, model });
|
||||
|
||||
if (tokens < maxToken) {
|
||||
result = msgs;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result.length === 0 && messages[0] ? [messages[0]] : result;
|
||||
}
|
||||
48
client/src/pages/api/openapi/text/sensitiveCheck.ts
Normal file
48
client/src/pages/api/openapi/text/sensitiveCheck.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { authUser, getSystemOpenAiKey } from '@/service/utils/auth';
|
||||
import type { TextPluginRequestParams } from '@/types/plugin';
|
||||
import axios from 'axios';
|
||||
import { axiosConfig } from '@/service/utils/tools';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
if (process.env.SENSITIVE_CHECK !== '1') {
|
||||
return jsonRes(res);
|
||||
}
|
||||
|
||||
await authUser({ req });
|
||||
|
||||
const { input } = req.body as TextPluginRequestParams;
|
||||
|
||||
const response = await axios({
|
||||
...axiosConfig(getSystemOpenAiKey('chat')),
|
||||
method: 'POST',
|
||||
url: `/moderations`,
|
||||
data: {
|
||||
input
|
||||
}
|
||||
});
|
||||
|
||||
const data = (response.data.results?.[0]?.category_scores as Record<string, number>) || {};
|
||||
|
||||
const values = Object.values(data);
|
||||
|
||||
for (const val of values) {
|
||||
if (val > 0.2) {
|
||||
return jsonRes(res, {
|
||||
code: 500,
|
||||
message: '您的内容不合规'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
jsonRes(res);
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
35
client/src/pages/api/plugins/kb/create.ts
Normal file
35
client/src/pages/api/plugins/kb/create.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, KB } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
const { name, tags } = req.body as {
|
||||
name: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const { _id } = await KB.create({
|
||||
name,
|
||||
userId,
|
||||
tags
|
||||
});
|
||||
|
||||
jsonRes(res, { data: _id });
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
79
client/src/pages/api/plugins/kb/data/exportModelData.ts
Normal file
79
client/src/pages/api/plugins/kb/data/exportModelData.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, User } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { PgClient } from '@/service/pg';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
let { kbId } = req.query as {
|
||||
kbId: string;
|
||||
};
|
||||
|
||||
if (!kbId) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
|
||||
|
||||
// auth export times
|
||||
const authTimes = await User.findOne(
|
||||
{
|
||||
_id: userId,
|
||||
$or: [
|
||||
{ 'limit.exportKbTime': { $exists: false } },
|
||||
{ 'limit.exportKbTime': { $lte: thirtyMinutesAgo } }
|
||||
]
|
||||
},
|
||||
'_id limit'
|
||||
);
|
||||
|
||||
if (!authTimes) {
|
||||
throw new Error('上次导出未到半小时,每半小时仅可导出一次。');
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
const count = await PgClient.count('modelData', {
|
||||
where: [['kb_id', kbId], 'AND', ['user_id', userId]]
|
||||
});
|
||||
// 从 pg 中获取所有数据
|
||||
const pgData = await PgClient.select<{ q: string; a: string }>('modelData', {
|
||||
where: [['kb_id', kbId], 'AND', ['user_id', userId]],
|
||||
fields: ['q', 'a'],
|
||||
order: [{ field: 'id', mode: 'DESC' }],
|
||||
limit: count
|
||||
});
|
||||
|
||||
const data: [string, string][] = pgData.rows.map((item) => [
|
||||
item.q.replace(/\n/g, '\\n'),
|
||||
item.a.replace(/\n/g, '\\n')
|
||||
]);
|
||||
|
||||
// update export time
|
||||
await User.findByIdAndUpdate(userId, {
|
||||
'limit.exportKbTime': new Date()
|
||||
});
|
||||
|
||||
jsonRes(res, {
|
||||
data
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: '100mb'
|
||||
}
|
||||
}
|
||||
};
|
||||
39
client/src/pages/api/plugins/kb/data/getDataById.ts
Normal file
39
client/src/pages/api/plugins/kb/data/getDataById.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { PgClient } from '@/service/pg';
|
||||
import type { KbDataItemType } from '@/types/plugin';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
let { dataId } = req.query as {
|
||||
dataId: string;
|
||||
};
|
||||
if (!dataId) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const where: any = [['user_id', userId], 'AND', ['id', dataId]];
|
||||
|
||||
const searchRes = await PgClient.select<KbDataItemType>('modelData', {
|
||||
fields: ['id', 'q', 'a', 'source'],
|
||||
where,
|
||||
limit: 1
|
||||
});
|
||||
|
||||
jsonRes(res, {
|
||||
data: searchRes.rows[0]
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
70
client/src/pages/api/plugins/kb/data/getDataList.ts
Normal file
70
client/src/pages/api/plugins/kb/data/getDataList.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { PgClient } from '@/service/pg';
|
||||
import type { KbDataItemType } from '@/types/plugin';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
let {
|
||||
kbId,
|
||||
pageNum = 1,
|
||||
pageSize = 10,
|
||||
searchText = ''
|
||||
} = req.body as {
|
||||
kbId: string;
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
searchText: string;
|
||||
};
|
||||
if (!kbId) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const where: any = [
|
||||
['user_id', userId],
|
||||
'AND',
|
||||
['kb_id', kbId],
|
||||
...(searchText
|
||||
? [
|
||||
'AND',
|
||||
`(q LIKE '%${searchText}%' OR a LIKE '%${searchText}%' OR source LIKE '%${searchText}%')`
|
||||
]
|
||||
: [])
|
||||
];
|
||||
|
||||
const [searchRes, total] = await Promise.all([
|
||||
PgClient.select<KbDataItemType>('modelData', {
|
||||
fields: ['id', 'q', 'a', 'source'],
|
||||
where,
|
||||
order: [{ field: 'id', mode: 'DESC' }],
|
||||
limit: pageSize,
|
||||
offset: pageSize * (pageNum - 1)
|
||||
}),
|
||||
PgClient.count('modelData', {
|
||||
fields: ['id'],
|
||||
where
|
||||
})
|
||||
]);
|
||||
|
||||
jsonRes(res, {
|
||||
data: {
|
||||
pageNum,
|
||||
pageSize,
|
||||
data: searchRes.rows,
|
||||
total
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
52
client/src/pages/api/plugins/kb/data/getTrainingData.ts
Normal file
52
client/src/pages/api/plugins/kb/data/getTrainingData.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, TrainingData } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { TrainingModeEnum } from '@/constants/plugin';
|
||||
import { Types } from 'mongoose';
|
||||
import { startQueue } from '@/service/utils/tools';
|
||||
|
||||
/* 拆分数据成QA */
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { kbId, init = false } = req.body as { kbId: string; init: boolean };
|
||||
if (!kbId) {
|
||||
throw new Error('参数错误');
|
||||
}
|
||||
await connectToDatabase();
|
||||
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
// split queue data
|
||||
const result = await TrainingData.aggregate([
|
||||
{
|
||||
$match: {
|
||||
userId: new Types.ObjectId(userId),
|
||||
kbId: new Types.ObjectId(kbId)
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$mode',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
jsonRes(res, {
|
||||
data: {
|
||||
qaListLen: result.find((item) => item._id === TrainingModeEnum.qa)?.count || 0,
|
||||
vectorListLen: result.find((item) => item._id === TrainingModeEnum.index)?.count || 0
|
||||
}
|
||||
});
|
||||
|
||||
if (init) {
|
||||
startQueue();
|
||||
}
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
55
client/src/pages/api/plugins/kb/delete.ts
Normal file
55
client/src/pages/api/plugins/kb/delete.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, KB, Model, TrainingData } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { PgClient } from '@/service/pg';
|
||||
import { Types } from 'mongoose';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
const { id } = req.query as {
|
||||
id: string;
|
||||
};
|
||||
|
||||
if (!id) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
// delete all pg data
|
||||
await PgClient.delete('modelData', {
|
||||
where: [['user_id', userId], 'AND', ['kb_id', id]]
|
||||
});
|
||||
|
||||
// delete training data
|
||||
await TrainingData.deleteMany({
|
||||
userId,
|
||||
kbId: id
|
||||
});
|
||||
|
||||
// delete related model
|
||||
await Model.updateMany(
|
||||
{
|
||||
userId
|
||||
},
|
||||
{ $pull: { 'chat.relatedKbs': new Types.ObjectId(id) } }
|
||||
);
|
||||
|
||||
// delete kb data
|
||||
await KB.findOneAndDelete({
|
||||
_id: id,
|
||||
userId
|
||||
});
|
||||
|
||||
jsonRes(res);
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
46
client/src/pages/api/plugins/kb/detail.ts
Normal file
46
client/src/pages/api/plugins/kb/detail.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, KB } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
const { id } = req.query as {
|
||||
id: string;
|
||||
};
|
||||
|
||||
if (!id) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const data = await KB.findOne({
|
||||
_id: id,
|
||||
userId
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
throw new Error('kb is not exist');
|
||||
}
|
||||
|
||||
jsonRes(res, {
|
||||
data: {
|
||||
_id: data._id,
|
||||
avatar: data.avatar,
|
||||
name: data.name,
|
||||
userId: data.userId,
|
||||
updateTime: data.updateTime,
|
||||
tags: data.tags.join(' ')
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
42
client/src/pages/api/plugins/kb/list.ts
Normal file
42
client/src/pages/api/plugins/kb/list.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, KB } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { PgClient } from '@/service/pg';
|
||||
import { KbItemType } from '@/types/plugin';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const kbList = await KB.find({
|
||||
userId
|
||||
}).sort({ updateTime: -1 });
|
||||
|
||||
const data = await Promise.all(
|
||||
kbList.map(async (item) => ({
|
||||
_id: item._id,
|
||||
avatar: item.avatar,
|
||||
name: item.name,
|
||||
userId: item.userId,
|
||||
updateTime: item.updateTime,
|
||||
tags: item.tags.join(' '),
|
||||
totalData: await PgClient.count('modelData', {
|
||||
where: [['user_id', userId], 'AND', ['kb_id', item._id]]
|
||||
})
|
||||
}))
|
||||
);
|
||||
|
||||
jsonRes<KbItemType[]>(res, {
|
||||
data
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
39
client/src/pages/api/plugins/kb/update.ts
Normal file
39
client/src/pages/api/plugins/kb/update.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, KB } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import type { KbUpdateParams } from '@/api/plugins/kb';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
const { id, name, tags, avatar } = req.body as KbUpdateParams;
|
||||
|
||||
if (!id || !name) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
// 凭证校验
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
await KB.findOneAndUpdate(
|
||||
{
|
||||
_id: id,
|
||||
userId
|
||||
},
|
||||
{
|
||||
avatar,
|
||||
name,
|
||||
tags: tags.split(' ').filter((item) => item)
|
||||
}
|
||||
);
|
||||
|
||||
jsonRes(res);
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
17
client/src/pages/api/system/getInitData.ts
Normal file
17
client/src/pages/api/system/getInitData.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
|
||||
export type InitDateResponse = {
|
||||
beianText: string;
|
||||
googleVerKey: string;
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
jsonRes<InitDateResponse>(res, {
|
||||
data: {
|
||||
beianText: process.env.SAFE_BEIAN_TEXT || '',
|
||||
googleVerKey: process.env.CLIENT_GOOGLE_VER_TOKEN || ''
|
||||
}
|
||||
});
|
||||
}
|
||||
23
client/src/pages/api/system/getModels.ts
Normal file
23
client/src/pages/api/system/getModels.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import type { ChatModelItemType } from '@/constants/model';
|
||||
import { ChatModelMap, OpenAiChatEnum, ClaudeEnum } from '@/constants/model';
|
||||
|
||||
// get the models available to the system
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const chatModelList: ChatModelItemType[] = [];
|
||||
|
||||
if (process.env.OPENAIKEY) {
|
||||
chatModelList.push(ChatModelMap[OpenAiChatEnum.GPT35]);
|
||||
}
|
||||
if (process.env.GPT4KEY) {
|
||||
chatModelList.push(ChatModelMap[OpenAiChatEnum.GPT4]);
|
||||
}
|
||||
if (process.env.CLAUDE_KEY) {
|
||||
chatModelList.push(ChatModelMap[ClaudeEnum.Claude]);
|
||||
}
|
||||
|
||||
jsonRes(res, {
|
||||
data: chatModelList
|
||||
});
|
||||
}
|
||||
121
client/src/pages/api/user/checkPayResult.ts
Normal file
121
client/src/pages/api/user/checkPayResult.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, User, Pay, TrainingData } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { PaySchema, UserModelSchema } from '@/types/mongoSchema';
|
||||
import dayjs from 'dayjs';
|
||||
import { getPayResult } from '@/service/utils/wxpay';
|
||||
import { pushPromotionRecord } from '@/service/utils/promotion';
|
||||
import { PRICE_SCALE } from '@/constants/common';
|
||||
import { startQueue } from '@/service/utils/tools';
|
||||
|
||||
/* 校验支付结果 */
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
let { payId } = req.query as { payId: string };
|
||||
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
// 查找订单记录校验
|
||||
const payOrder = await Pay.findById<PaySchema>(payId);
|
||||
|
||||
if (!payOrder) {
|
||||
throw new Error('订单不存在');
|
||||
}
|
||||
if (payOrder.status !== 'NOTPAY') {
|
||||
throw new Error('订单已结算');
|
||||
}
|
||||
|
||||
// 获取当前用户
|
||||
const user = await User.findById(userId);
|
||||
if (!user) {
|
||||
throw new Error('找不到用户');
|
||||
}
|
||||
// 获取邀请者
|
||||
let inviter: UserModelSchema | null = null;
|
||||
if (user.inviterId) {
|
||||
inviter = await User.findById(user.inviterId);
|
||||
}
|
||||
|
||||
const payRes = await getPayResult(payOrder.orderId);
|
||||
|
||||
// 校验下是否超过一天
|
||||
const orderTime = dayjs(payOrder.createTime);
|
||||
const diffInHours = dayjs().diff(orderTime, 'hours');
|
||||
|
||||
if (payRes.trade_state === 'SUCCESS') {
|
||||
// 订单已支付
|
||||
try {
|
||||
// 更新订单状态. 如果没有合适的订单,说明订单重复了
|
||||
const updateRes = await Pay.updateOne(
|
||||
{
|
||||
_id: payId,
|
||||
status: 'NOTPAY'
|
||||
},
|
||||
{
|
||||
status: 'SUCCESS'
|
||||
}
|
||||
);
|
||||
if (updateRes.modifiedCount === 1) {
|
||||
// 给用户账号充钱
|
||||
await User.findByIdAndUpdate(userId, {
|
||||
$inc: { balance: payOrder.price }
|
||||
});
|
||||
// 推广佣金发放
|
||||
if (inviter) {
|
||||
pushPromotionRecord({
|
||||
userId: inviter._id,
|
||||
objUId: userId,
|
||||
type: 'invite',
|
||||
// amount 单位为元,需要除以缩放比例,最后乘比例
|
||||
amount: (payOrder.price / PRICE_SCALE) * inviter.promotion.rate * 0.01
|
||||
});
|
||||
}
|
||||
jsonRes(res, {
|
||||
data: '支付成功'
|
||||
});
|
||||
unlockTask(userId);
|
||||
}
|
||||
} catch (error) {
|
||||
await Pay.findByIdAndUpdate(payId, {
|
||||
status: 'NOTPAY'
|
||||
});
|
||||
console.log(error);
|
||||
}
|
||||
} else if (payRes.trade_state === 'CLOSED' || diffInHours > 24) {
|
||||
// 订单已关闭
|
||||
await Pay.findByIdAndUpdate(payId, {
|
||||
status: 'CLOSED'
|
||||
});
|
||||
jsonRes(res, {
|
||||
data: '订单已过期'
|
||||
});
|
||||
} else {
|
||||
throw new Error(payRes?.trade_state_desc || '订单无效');
|
||||
}
|
||||
} catch (err) {
|
||||
// console.log(err);
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function unlockTask(userId: string) {
|
||||
try {
|
||||
await TrainingData.updateMany(
|
||||
{
|
||||
userId
|
||||
},
|
||||
{
|
||||
lockTime: new Date('2000/1/1')
|
||||
}
|
||||
);
|
||||
startQueue();
|
||||
} catch (error) {
|
||||
unlockTask(userId);
|
||||
}
|
||||
}
|
||||
49
client/src/pages/api/user/getBill.ts
Normal file
49
client/src/pages/api/user/getBill.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, Bill } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { adaptBill } from '@/utils/adapt';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
let { pageNum = 1, pageSize = 10 } = req.query as {
|
||||
pageNum: string;
|
||||
pageSize: string;
|
||||
};
|
||||
|
||||
pageNum = +pageNum;
|
||||
pageSize = +pageSize;
|
||||
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const where = {
|
||||
userId
|
||||
};
|
||||
|
||||
// get bill record and total by record
|
||||
const [bills, total] = await Promise.all([
|
||||
Bill.find(where)
|
||||
.sort({ time: -1 }) // 按照创建时间倒序排列
|
||||
.skip((pageNum - 1) * pageSize)
|
||||
.limit(pageSize),
|
||||
Bill.countDocuments(where)
|
||||
]);
|
||||
|
||||
jsonRes(res, {
|
||||
data: {
|
||||
pageNum,
|
||||
pageSize,
|
||||
data: bills.map(adaptBill),
|
||||
total
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
43
client/src/pages/api/user/getPayCode.ts
Normal file
43
client/src/pages/api/user/getPayCode.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import { connectToDatabase, Pay } from '@/service/mongo';
|
||||
import { PRICE_SCALE } from '@/constants/common';
|
||||
import { nativePay } from '@/service/utils/wxpay';
|
||||
|
||||
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 20);
|
||||
|
||||
/* 获取支付二维码 */
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
let { amount = 0 } = req.query as { amount: string };
|
||||
amount = +amount;
|
||||
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
const id = nanoid();
|
||||
await connectToDatabase();
|
||||
|
||||
const code_url = await nativePay(amount * 100, id);
|
||||
|
||||
// 充值记录 + 1
|
||||
const payOrder = await Pay.create({
|
||||
userId,
|
||||
price: amount * PRICE_SCALE,
|
||||
orderId: id
|
||||
});
|
||||
|
||||
jsonRes(res, {
|
||||
data: {
|
||||
payId: payOrder._id,
|
||||
codeUrl: code_url
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
27
client/src/pages/api/user/getPayOrders.ts
Normal file
27
client/src/pages/api/user/getPayOrders.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { connectToDatabase, Pay } from '@/service/mongo';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const records = await Pay.find({
|
||||
userId,
|
||||
status: { $ne: 'CLOSED' }
|
||||
}).sort({ createTime: -1 });
|
||||
|
||||
jsonRes(res, {
|
||||
data: records
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
31
client/src/pages/api/user/inform/countUnread.ts
Normal file
31
client/src/pages/api/user/inform/countUnread.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, Inform } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
if (!req.headers.cookie) {
|
||||
return jsonRes(res, {
|
||||
data: 0
|
||||
});
|
||||
}
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const data = await Inform.countDocuments({
|
||||
userId,
|
||||
read: false
|
||||
});
|
||||
|
||||
jsonRes(res, {
|
||||
data
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
data: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
40
client/src/pages/api/user/inform/list.ts
Normal file
40
client/src/pages/api/user/inform/list.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, Inform } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
const { pageNum, pageSize = 10 } = req.body as {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const [informs, total] = await Promise.all([
|
||||
Inform.find({ userId })
|
||||
.sort({ time: -1 }) // 按照创建时间倒序排列
|
||||
.skip((pageNum - 1) * pageSize)
|
||||
.limit(pageSize),
|
||||
Inform.countDocuments({ userId })
|
||||
]);
|
||||
|
||||
jsonRes(res, {
|
||||
data: {
|
||||
pageNum,
|
||||
pageSize,
|
||||
data: informs,
|
||||
total
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
29
client/src/pages/api/user/inform/read.ts
Normal file
29
client/src/pages/api/user/inform/read.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, Inform } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const { id } = req.query as { id: string };
|
||||
|
||||
await Inform.findOneAndUpdate(
|
||||
{
|
||||
_id: id,
|
||||
userId
|
||||
},
|
||||
{
|
||||
read: true
|
||||
}
|
||||
);
|
||||
|
||||
jsonRes(res);
|
||||
} catch (err) {
|
||||
jsonRes(res);
|
||||
}
|
||||
}
|
||||
75
client/src/pages/api/user/inform/send.ts
Normal file
75
client/src/pages/api/user/inform/send.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, Inform, User } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { InformTypeEnum } from '@/constants/user';
|
||||
|
||||
export type Props = {
|
||||
type: `${InformTypeEnum}`;
|
||||
title: string;
|
||||
content: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
await authUser({ req, authRoot: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
jsonRes(res, {
|
||||
data: await sendInform(req.body),
|
||||
message: '发送通知成功'
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendInform({ type, title, content, userId }: Props) {
|
||||
if (!type || !title || !content) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (userId) {
|
||||
// skip it if have same inform within 5 minutes
|
||||
const inform = await Inform.findOne({
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
userId,
|
||||
read: false,
|
||||
time: { $lte: new Date(Date.now() + 5 * 60 * 1000) }
|
||||
});
|
||||
|
||||
if (inform) return;
|
||||
|
||||
await Inform.create({
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
userId
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// send to all user
|
||||
const users = await User.find({}, '_id');
|
||||
await Inform.insertMany(
|
||||
users.map(({ _id }) => ({
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
userId: _id
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.log('send inform error', error);
|
||||
}
|
||||
}
|
||||
48
client/src/pages/api/user/loginByPassword.ts
Normal file
48
client/src/pages/api/user/loginByPassword.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase } from '@/service/mongo';
|
||||
import { User } from '@/service/models/user';
|
||||
import { setCookie } from '@/service/utils/tools';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
// 检测用户是否存在
|
||||
const authUser = await User.findOne({
|
||||
username
|
||||
});
|
||||
if (!authUser) {
|
||||
throw new Error('用户未注册');
|
||||
}
|
||||
|
||||
const user = await User.findOne({
|
||||
username,
|
||||
password
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('密码错误');
|
||||
}
|
||||
|
||||
setCookie(res, user._id);
|
||||
|
||||
jsonRes(res, {
|
||||
data: {
|
||||
user
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
16
client/src/pages/api/user/loginout.ts
Normal file
16
client/src/pages/api/user/loginout.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { clearCookie } from '@/service/utils/tools';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
clearCookie(res);
|
||||
jsonRes(res);
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
69
client/src/pages/api/user/promotion/getPromotionData.ts
Normal file
69
client/src/pages/api/user/promotion/getPromotionData.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, User, promotionRecord } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const invitedAmount = await User.countDocuments({
|
||||
inviterId: userId
|
||||
});
|
||||
|
||||
// 计算累计合
|
||||
const countHistory: { totalAmount: number }[] = await promotionRecord.aggregate([
|
||||
{
|
||||
$match: {
|
||||
userId: new mongoose.Types.ObjectId(userId),
|
||||
amount: { $gt: 0 }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null, // 分组条件,这里使用 null 表示不分组
|
||||
totalAmount: { $sum: '$amount' } // 计算 amount 字段的总和
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: false, // 排除 _id 字段
|
||||
totalAmount: true // 只返回 totalAmount 字段
|
||||
}
|
||||
}
|
||||
]);
|
||||
// 计算剩余金额
|
||||
const countResidue: { totalAmount: number }[] = await promotionRecord.aggregate([
|
||||
{ $match: { userId: new mongoose.Types.ObjectId(userId) } },
|
||||
{
|
||||
$group: {
|
||||
_id: null, // 分组条件,这里使用 null 表示不分组
|
||||
totalAmount: { $sum: '$amount' } // 计算 amount 字段的总和
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: false, // 排除 _id 字段
|
||||
totalAmount: true // 只返回 totalAmount 字段
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
jsonRes(res, {
|
||||
data: {
|
||||
invitedAmount,
|
||||
historyAmount: countHistory[0]?.totalAmount || 0,
|
||||
residueAmount: countResidue[0]?.totalAmount || 0
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
47
client/src/pages/api/user/promotion/getPromotions.ts
Normal file
47
client/src/pages/api/user/promotion/getPromotions.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase, promotionRecord } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
let { pageNum = 1, pageSize = 10 } = req.query as {
|
||||
pageNum: string;
|
||||
pageSize: string;
|
||||
};
|
||||
pageNum = +pageNum;
|
||||
pageSize = +pageSize;
|
||||
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const data = await promotionRecord
|
||||
.find(
|
||||
{
|
||||
userId
|
||||
},
|
||||
'_id createTime type amount'
|
||||
)
|
||||
.sort({ _id: -1 })
|
||||
.skip((pageNum - 1) * pageSize)
|
||||
.limit(pageSize);
|
||||
|
||||
jsonRes(res, {
|
||||
data: {
|
||||
pageNum,
|
||||
pageSize,
|
||||
data,
|
||||
total: await promotionRecord.countDocuments({
|
||||
userId
|
||||
})
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
72
client/src/pages/api/user/register.ts
Normal file
72
client/src/pages/api/user/register.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { User } from '@/service/models/user';
|
||||
import { AuthCode } from '@/service/models/authCode';
|
||||
import { connectToDatabase } from '@/service/mongo';
|
||||
import { setCookie } from '@/service/utils/tools';
|
||||
import { UserAuthTypeEnum } from '@/constants/common';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
const { username, code, password, inviterId } = req.body;
|
||||
|
||||
if (!username || !code || !password) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
// 验证码校验
|
||||
const authCode = await AuthCode.findOne({
|
||||
username,
|
||||
code,
|
||||
type: UserAuthTypeEnum.register,
|
||||
expiredTime: { $gte: Date.now() }
|
||||
});
|
||||
|
||||
if (!authCode) {
|
||||
throw new Error('验证码错误');
|
||||
}
|
||||
|
||||
// 重名校验
|
||||
const authRepeat = await User.findOne({
|
||||
username
|
||||
});
|
||||
|
||||
if (authRepeat) {
|
||||
throw new Error('该用户已被注册');
|
||||
}
|
||||
|
||||
const response = await User.create({
|
||||
username,
|
||||
password,
|
||||
inviterId: inviterId ? inviterId : undefined
|
||||
});
|
||||
|
||||
// 根据 id 获取用户信息
|
||||
const user = await User.findById(response._id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('获取用户信息异常');
|
||||
}
|
||||
|
||||
// 删除验证码记录
|
||||
await AuthCode.deleteMany({
|
||||
username
|
||||
});
|
||||
|
||||
setCookie(res, user._id);
|
||||
|
||||
jsonRes(res, {
|
||||
data: {
|
||||
user
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
71
client/src/pages/api/user/sendAuthCode.ts
Normal file
71
client/src/pages/api/user/sendAuthCode.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { AuthCode } from '@/service/models/authCode';
|
||||
import { connectToDatabase } from '@/service/mongo';
|
||||
import { sendPhoneCode, sendEmailCode } from '@/service/utils/sendNote';
|
||||
import { UserAuthTypeEnum } from '@/constants/common';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
const nanoid = customAlphabet('123456789', 6);
|
||||
import { authGoogleToken } from '@/utils/plugin/google';
|
||||
import requestIp from 'request-ip';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { username, type, googleToken } = req.body as {
|
||||
username: string;
|
||||
type: `${UserAuthTypeEnum}`;
|
||||
googleToken: string;
|
||||
};
|
||||
|
||||
if (!username || !type) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
// google auth
|
||||
process.env.SERVICE_GOOGLE_VER_TOKEN &&
|
||||
(await authGoogleToken({
|
||||
secret: process.env.SERVICE_GOOGLE_VER_TOKEN,
|
||||
response: googleToken,
|
||||
remoteip: requestIp.getClientIp(req) || undefined
|
||||
}));
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
const code = nanoid();
|
||||
|
||||
// 判断 1 分钟内是否有重复数据
|
||||
const authCode = await AuthCode.findOne({
|
||||
username,
|
||||
type,
|
||||
expiredTime: { $gte: Date.now() + 4 * 60 * 1000 } // 如果有一个记录的过期时间,大于当前+4分钟,说明距离上次发送还没到1分钟。(因为默认创建时,过期时间是未来5分钟)
|
||||
});
|
||||
|
||||
if (authCode) {
|
||||
throw new Error('请勿频繁获取验证码');
|
||||
}
|
||||
|
||||
// 创建 auth 记录
|
||||
await AuthCode.create({
|
||||
username,
|
||||
type,
|
||||
code
|
||||
});
|
||||
|
||||
if (username.includes('@')) {
|
||||
await sendEmailCode(username, code, type);
|
||||
} else {
|
||||
// 发送验证码
|
||||
await sendPhoneCode(username, code);
|
||||
}
|
||||
|
||||
jsonRes(res, {
|
||||
message: '发送验证码成功'
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
30
client/src/pages/api/user/tokenLogin.ts
Normal file
30
client/src/pages/api/user/tokenLogin.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { connectToDatabase } from '@/service/mongo';
|
||||
import { User } from '@/service/models/user';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
// 根据 id 获取用户信息
|
||||
const user = await User.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('账号异常');
|
||||
}
|
||||
|
||||
jsonRes(res, {
|
||||
data: user
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
35
client/src/pages/api/user/update.ts
Normal file
35
client/src/pages/api/user/update.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { User } from '@/service/models/user';
|
||||
import { connectToDatabase } from '@/service/mongo';
|
||||
import { authUser } from '@/service/utils/auth';
|
||||
import { UserUpdateParams } from '@/types/user';
|
||||
|
||||
/* 更新一些基本信息 */
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
const { openaiKey, avatar } = req.body as UserUpdateParams;
|
||||
|
||||
const { userId } = await authUser({ req, authToken: true });
|
||||
|
||||
await connectToDatabase();
|
||||
// 更新对应的记录
|
||||
await User.updateOne(
|
||||
{
|
||||
_id: userId
|
||||
},
|
||||
{
|
||||
...(avatar && { avatar }),
|
||||
...(openaiKey !== undefined && { openaiKey })
|
||||
}
|
||||
);
|
||||
|
||||
jsonRes(res);
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
64
client/src/pages/api/user/updatePasswordByCode.ts
Normal file
64
client/src/pages/api/user/updatePasswordByCode.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { jsonRes } from '@/service/response';
|
||||
import { User } from '@/service/models/user';
|
||||
import { AuthCode } from '@/service/models/authCode';
|
||||
import { connectToDatabase } from '@/service/mongo';
|
||||
import { UserAuthTypeEnum } from '@/constants/common';
|
||||
import { setCookie } from '@/service/utils/tools';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||
try {
|
||||
const { username, code, password } = req.body;
|
||||
|
||||
if (!username || !code || !password) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
await connectToDatabase();
|
||||
|
||||
// 验证码校验
|
||||
const authCode = await AuthCode.findOne({
|
||||
username,
|
||||
code,
|
||||
type: UserAuthTypeEnum.findPassword,
|
||||
expiredTime: { $gte: Date.now() }
|
||||
});
|
||||
|
||||
if (!authCode) {
|
||||
throw new Error('验证码错误');
|
||||
}
|
||||
|
||||
// 更新对应的记录
|
||||
await User.updateOne(
|
||||
{
|
||||
username
|
||||
},
|
||||
{
|
||||
password
|
||||
}
|
||||
);
|
||||
|
||||
// 根据 username 获取用户信息
|
||||
const user = await User.findOne({
|
||||
username
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('获取用户信息异常');
|
||||
}
|
||||
|
||||
setCookie(res, user._id);
|
||||
|
||||
jsonRes(res, {
|
||||
data: {
|
||||
user
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
jsonRes(res, {
|
||||
code: 500,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
58
client/src/pages/chat/components/Empty.tsx
Normal file
58
client/src/pages/chat/components/Empty.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Card, Box, Flex } from '@chakra-ui/react';
|
||||
import { useMarkdown } from '@/hooks/useMarkdown';
|
||||
import Markdown from '@/components/Markdown';
|
||||
import Avatar from '@/components/Avatar';
|
||||
|
||||
const Empty = ({
|
||||
showChatProblem,
|
||||
model: { name, intro, avatar }
|
||||
}: {
|
||||
showChatProblem: boolean;
|
||||
model: {
|
||||
name: string;
|
||||
intro: string;
|
||||
avatar: string;
|
||||
};
|
||||
}) => {
|
||||
const { data: chatProblem } = useMarkdown({ url: '/chatProblem.md' });
|
||||
const { data: versionIntro } = useMarkdown({ url: '/versionIntro.md' });
|
||||
|
||||
return (
|
||||
<Box
|
||||
minH={'100%'}
|
||||
w={'85%'}
|
||||
maxW={'600px'}
|
||||
m={'auto'}
|
||||
py={'5vh'}
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
>
|
||||
{name && (
|
||||
<Card p={4} mb={10}>
|
||||
<Flex mb={2} alignItems={'center'} justifyContent={'center'}>
|
||||
<Avatar src={avatar} w={'32px'} h={'32px'} />
|
||||
<Box ml={3} fontSize={'3xl'} fontWeight={'bold'}>
|
||||
{name}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box whiteSpace={'pre-line'}>{intro}</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showChatProblem && (
|
||||
<>
|
||||
{/* version intro */}
|
||||
<Card p={4} mb={10}>
|
||||
<Markdown source={versionIntro} />
|
||||
</Card>
|
||||
<Card p={4}>
|
||||
<Markdown source={chatProblem} />
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Empty;
|
||||
303
client/src/pages/chat/components/History.tsx
Normal file
303
client/src/pages/chat/components/History.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
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 MyIcon from '@/components/Icon';
|
||||
import type { HistoryItemType, ExportChatType } from '@/types/chat';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import ModelList from './ModelList';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import styles from '../index.module.scss';
|
||||
import { useEditInfo } from '@/hooks/useEditInfo';
|
||||
import { putChatHistory } from '@/api/chat';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { formatTimeToChatTime, getErrText } from '@/utils/tools';
|
||||
|
||||
const PcSliderBar = ({
|
||||
onclickDelHistory,
|
||||
onclickExportChat
|
||||
}: {
|
||||
onclickDelHistory: (historyId: string) => Promise<void>;
|
||||
onclickExportChat: (type: ExportChatType) => void;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { modelId = '', chatId = '' } = router.query as {
|
||||
modelId: string;
|
||||
chatId: string;
|
||||
};
|
||||
const ContextMenuRef = useRef(null);
|
||||
|
||||
const theme = useTheme();
|
||||
const { isPc } = useGlobalStore();
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
// custom title edit
|
||||
const { onOpenModal, EditModal: EditTitleModal } = useEditInfo({
|
||||
title: '自定义历史记录标题',
|
||||
placeholder: '如果设置为空,会自动跟随聊天记录。'
|
||||
});
|
||||
|
||||
// close contextMenu
|
||||
useOutsideClick({
|
||||
ref: ContextMenuRef,
|
||||
handler: () =>
|
||||
setTimeout(() => {
|
||||
setContextMenuData(undefined);
|
||||
}, 10)
|
||||
});
|
||||
|
||||
const onclickContextMenu = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>, history: HistoryItemType) => {
|
||||
e.preventDefault(); // 阻止默认右键菜单
|
||||
|
||||
if (!isPc) return;
|
||||
|
||||
setContextMenuData({
|
||||
left: e.clientX + 15,
|
||||
top: e.clientY + 10,
|
||||
history
|
||||
});
|
||||
},
|
||||
[isPc]
|
||||
);
|
||||
|
||||
useQuery(['loadModels'], () => loadMyModels(false));
|
||||
|
||||
const { isLoading: isLoadingHistory } = useQuery(['loadingHistory'], () =>
|
||||
loadHistory({ pageNum: 1 })
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
position={'relative'}
|
||||
flexDirection={'column'}
|
||||
w={'100%'}
|
||||
h={'100%'}
|
||||
bg={'white'}
|
||||
borderRight={['', theme.borders.base]}
|
||||
>
|
||||
{/* 新对话 */}
|
||||
{isPc && (
|
||||
<Box
|
||||
className={styles.newChat}
|
||||
zIndex={1001}
|
||||
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.modelListContainer}
|
||||
position={'absolute'}
|
||||
w={'115%'}
|
||||
left={0}
|
||||
top={'40px'}
|
||||
transition={'0.15s ease-out'}
|
||||
bg={'white'}
|
||||
>
|
||||
<Box
|
||||
className={styles.modelList}
|
||||
mt={'6px'}
|
||||
h={'calc(100% - 6px)'}
|
||||
overflow={'overlay'}
|
||||
>
|
||||
<ModelList models={models} modelId={modelId} />
|
||||
</Box>
|
||||
</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={{
|
||||
bg: ['', '#dee0e3']
|
||||
}}
|
||||
{...(item._id === chatId
|
||||
? {
|
||||
bg: 'myGray.100 !important',
|
||||
borderLeftColor: 'myBlue.600 !important'
|
||||
}
|
||||
: {
|
||||
bg: item.top ? 'myBlue.200' : ''
|
||||
})}
|
||||
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 () => {
|
||||
try {
|
||||
await putChatHistory({
|
||||
chatId: contextMenuData.history._id,
|
||||
top: !contextMenuData.history.top
|
||||
});
|
||||
loadHistory({ pageNum: 1, init: true });
|
||||
} catch (error) {}
|
||||
}}
|
||||
>
|
||||
{contextMenuData.history.top ? '取消置顶' : '置顶'}
|
||||
</MenuItem>
|
||||
<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={() =>
|
||||
onOpenModal({
|
||||
defaultVal: contextMenuData.history.title,
|
||||
onSuccess: async (val: string) => {
|
||||
await putChatHistory({
|
||||
chatId: contextMenuData.history._id,
|
||||
customTitle: val,
|
||||
top: contextMenuData.history.top
|
||||
});
|
||||
toast({
|
||||
title: '自定义标题成功',
|
||||
status: 'success'
|
||||
});
|
||||
loadHistory({ pageNum: 1, init: true });
|
||||
},
|
||||
onError(err) {
|
||||
toast({
|
||||
title: getErrText(err),
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
自定义标题
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => onclickExportChat('html')}>导出HTML格式</MenuItem>
|
||||
<MenuItem onClick={() => onclickExportChat('pdf')}>导出PDF格式</MenuItem>
|
||||
<MenuItem onClick={() => onclickExportChat('md')}>导出Markdown格式</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Box>
|
||||
)}
|
||||
<EditTitleModal />
|
||||
<Loading loading={isLoadingHistory} fixed={false} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default PcSliderBar;
|
||||
52
client/src/pages/chat/components/ModelList.tsx
Normal file
52
client/src/pages/chat/components/ModelList.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { ModelListItemType } from '@/types/model';
|
||||
import Avatar from '@/components/Avatar';
|
||||
|
||||
const ModelList = ({ models, modelId }: { models: ModelListItemType[]; modelId: string }) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
{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']}
|
||||
zIndex={0}
|
||||
_hover={{
|
||||
backgroundColor: ['', '#dee0e3']
|
||||
}}
|
||||
{...(modelId === item._id
|
||||
? {
|
||||
backgroundColor: '#eff0f1',
|
||||
borderLeftColor: 'myBlue.600'
|
||||
}
|
||||
: {})}
|
||||
onClick={() => {
|
||||
router.replace(`/chat?modelId=${item._id}`);
|
||||
}}
|
||||
>
|
||||
<Avatar src={item.avatar} w={'34px'} h={'34px'} />
|
||||
<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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelList;
|
||||
194
client/src/pages/chat/components/PhoneSliderBar.tsx
Normal file
194
client/src/pages/chat/components/PhoneSliderBar.tsx
Normal 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
|
||||
} from '@chakra-ui/react';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/router';
|
||||
import MyIcon from '@/components/Icon';
|
||||
import WxConcat from '@/components/WxConcat';
|
||||
import { delChatHistoryById } from '@/api/chat';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import Avatar from '@/components/Avatar';
|
||||
|
||||
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();
|
||||
}}
|
||||
>
|
||||
<Avatar src={item.avatar} mr={2} w={'18px'} h={'18px'} />
|
||||
<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('/model')}>
|
||||
<>
|
||||
<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;
|
||||
175
client/src/pages/chat/components/QuoteModal.tsx
Normal file
175
client/src/pages/chat/components/QuoteModal.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalHeader,
|
||||
Box,
|
||||
useTheme
|
||||
} from '@chakra-ui/react';
|
||||
import { QuoteItemType } from '@/pages/api/openapi/kb/appKbSearch';
|
||||
import MyIcon from '@/components/Icon';
|
||||
import InputDataModal from '@/pages/kb/components/InputDataModal';
|
||||
import { getKbDataItemById } from '@/api/plugins/kb';
|
||||
import { useLoading } from '@/hooks/useLoading';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getHistoryQuote, updateHistoryQuote } from '@/api/chat';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { getErrText } from '@/utils/tools';
|
||||
|
||||
const QuoteModal = ({
|
||||
historyId,
|
||||
chatId,
|
||||
onClose
|
||||
}: {
|
||||
historyId: string;
|
||||
chatId: string;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { toast } = useToast();
|
||||
const { setIsLoading, Loading } = useLoading();
|
||||
const [editDataItem, setEditDataItem] = useState<{
|
||||
dataId: string;
|
||||
a: string;
|
||||
q: string;
|
||||
}>();
|
||||
|
||||
const {
|
||||
data: quote = [],
|
||||
refetch,
|
||||
isLoading
|
||||
} = useQuery(['getHistoryQuote'], () => getHistoryQuote({ historyId, chatId }));
|
||||
|
||||
/**
|
||||
* click edit, get new kbDataItem
|
||||
*/
|
||||
const onclickEdit = useCallback(
|
||||
async (item: QuoteItemType) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = (await getKbDataItemById(item.id)) as QuoteItemType;
|
||||
|
||||
if (!data) {
|
||||
throw new Error('该数据已被删除');
|
||||
}
|
||||
|
||||
setEditDataItem({
|
||||
dataId: data.id,
|
||||
q: data.q,
|
||||
a: data.a
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: getErrText(err)
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
},
|
||||
[setIsLoading, toast]
|
||||
);
|
||||
|
||||
/**
|
||||
* update kbData, update mongo status and reload quotes
|
||||
*/
|
||||
const updateQuoteStatus = useCallback(
|
||||
async (quoteId: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await updateHistoryQuote({
|
||||
chatId,
|
||||
historyId,
|
||||
quoteId
|
||||
});
|
||||
// reload quote
|
||||
refetch();
|
||||
} catch (err) {
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: getErrText(err)
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
},
|
||||
[chatId, historyId, refetch, setIsLoading, toast]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={true} onClose={onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent
|
||||
position={'relative'}
|
||||
maxW={'min(90vw, 700px)'}
|
||||
h={'80vh'}
|
||||
overflow={'overlay'}
|
||||
>
|
||||
<ModalHeader>
|
||||
知识库引用({quote.length}条)
|
||||
<Box fontSize={'sm'} fontWeight={'normal'}>
|
||||
注意: 修改知识库内容成功后,此处不会显示。点击编辑后,才是显示最新的内容。
|
||||
</Box>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pt={0} whiteSpace={'pre-wrap'} textAlign={'justify'} fontSize={'sm'}>
|
||||
{quote.map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
flex={'1 0 0'}
|
||||
p={2}
|
||||
borderRadius={'sm'}
|
||||
border={theme.borders.base}
|
||||
_notLast={{ mb: 2 }}
|
||||
position={'relative'}
|
||||
_hover={{ '& .edit': { display: 'flex' } }}
|
||||
>
|
||||
{item.source && <Box color={'myGray.600'}>({item.source})</Box>}
|
||||
<Box>{item.q}</Box>
|
||||
<Box>{item.a}</Box>
|
||||
<Box
|
||||
className="edit"
|
||||
display={'none'}
|
||||
position={'absolute'}
|
||||
right={0}
|
||||
top={0}
|
||||
bottom={0}
|
||||
w={'40px'}
|
||||
bg={'rgba(255,255,255,0.9)'}
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
boxShadow={'-10px 0 10px rgba(255,255,255,1)'}
|
||||
>
|
||||
<MyIcon
|
||||
name={'edit'}
|
||||
w={'18px'}
|
||||
h={'18px'}
|
||||
cursor={'pointer'}
|
||||
color={'myGray.600'}
|
||||
_hover={{
|
||||
color: 'myBlue.700'
|
||||
}}
|
||||
onClick={() => onclickEdit(item)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</ModalBody>
|
||||
<Loading loading={isLoading} fixed={false} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
{editDataItem && (
|
||||
<InputDataModal
|
||||
onClose={() => setEditDataItem(undefined)}
|
||||
onSuccess={() => updateQuoteStatus(editDataItem.dataId)}
|
||||
kbId=""
|
||||
defaultValues={editDataItem}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuoteModal;
|
||||
204
client/src/pages/chat/components/ShareHistory.tsx
Normal file
204
client/src/pages/chat/components/ShareHistory.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
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 { useGlobalStore } from '@/store/global';
|
||||
|
||||
import styles from '../index.module.scss';
|
||||
|
||||
const PcSliderBar = ({
|
||||
onclickDelHistory,
|
||||
onclickExportChat,
|
||||
onCloseSlider
|
||||
}: {
|
||||
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 } = useGlobalStore();
|
||||
|
||||
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;
|
||||
30
client/src/pages/chat/index.module.scss
Normal file
30
client/src/pages/chat/index.module.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
.stopIcon {
|
||||
animation: zoomStopIcon 0.4s infinite alternate;
|
||||
}
|
||||
@keyframes zoomStopIcon {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.newChat {
|
||||
.modelListContainer {
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modelList {
|
||||
border-radius: 6px;
|
||||
}
|
||||
&:hover {
|
||||
.modelListContainer {
|
||||
height: 60vh;
|
||||
}
|
||||
.modelList {
|
||||
box-shadow: 0 0 5px rgba($color: #000000, $alpha: 0.05);
|
||||
border: 1px solid #dee0e2;
|
||||
}
|
||||
}
|
||||
}
|
||||
953
client/src/pages/chat/index.tsx
Normal file
953
client/src/pages/chat/index.tsx
Normal file
@@ -0,0 +1,953 @@
|
||||
import React, { useCallback, useState, useRef, useMemo, useEffect, MouseEvent } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getInitChatSiteInfo, delChatRecordByIndex, delChatHistoryById } from '@/api/chat';
|
||||
import type { ChatItemType, ChatSiteItemType, ExportChatType } from '@/types/chat';
|
||||
import {
|
||||
Textarea,
|
||||
Box,
|
||||
Flex,
|
||||
useColorModeValue,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalHeader,
|
||||
useDisclosure,
|
||||
Drawer,
|
||||
DrawerOverlay,
|
||||
DrawerContent,
|
||||
Card,
|
||||
Tooltip,
|
||||
useOutsideClick,
|
||||
useTheme
|
||||
} from '@chakra-ui/react';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useCopyData, voiceBroadcast, hasVoiceApi, delay } from '@/utils/tools';
|
||||
import { streamFetch } from '@/api/fetch';
|
||||
import MyIcon from '@/components/Icon';
|
||||
import { throttle } from 'lodash';
|
||||
import { Types } from 'mongoose';
|
||||
import { ChatModelMap } from '@/constants/model';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { useLoading } from '@/hooks/useLoading';
|
||||
import { fileDownload } from '@/utils/file';
|
||||
import { htmlTemplate } from '@/constants/common';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import Loading from '@/components/Loading';
|
||||
import Markdown from '@/components/Markdown';
|
||||
import SideBar from '@/components/SideBar';
|
||||
import Avatar from '@/components/Avatar';
|
||||
import Empty from './components/Empty';
|
||||
import QuoteModal from './components/QuoteModal';
|
||||
import { HUMAN_ICON } from '@/constants/chat';
|
||||
|
||||
const PhoneSliderBar = dynamic(() => import('./components/PhoneSliderBar'), {
|
||||
ssr: false
|
||||
});
|
||||
const History = dynamic(() => import('./components/History'), {
|
||||
loading: () => <Loading fixed={false} />,
|
||||
ssr: false
|
||||
});
|
||||
|
||||
import styles from './index.module.scss';
|
||||
|
||||
const textareaMinH = '22px';
|
||||
|
||||
const Chat = ({ modelId, chatId }: { modelId: string; chatId: string }) => {
|
||||
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 [showHistoryQuote, setShowHistoryQuote] = useState<string>();
|
||||
const [showSystemPrompt, setShowSystemPrompt] = useState('');
|
||||
const [messageContextMenuData, setMessageContextMenuData] = useState<{
|
||||
// message messageContextMenuData
|
||||
left: number;
|
||||
top: number;
|
||||
message: ChatSiteItemType;
|
||||
}>();
|
||||
|
||||
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 { toast } = useToast();
|
||||
const { copyData } = useCopyData();
|
||||
const { isPc } = useGlobalStore();
|
||||
const { Loading, setIsLoading } = useLoading();
|
||||
const { userInfo, loadMyModels } = useUserStore();
|
||||
const { isOpen: isOpenSlider, onClose: onCloseSlider, onOpen: onOpenSlider } = 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) => {
|
||||
if (!TextareaDom.current) return;
|
||||
TextareaDom.current.value = val;
|
||||
setTimeout(() => {
|
||||
/* 回到最小高度 */
|
||||
if (TextareaDom.current) {
|
||||
TextareaDom.current.style.height =
|
||||
val === '' ? textareaMinH : `${TextareaDom.current.scrollHeight}px`;
|
||||
}
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
// gpt 对话
|
||||
const gptChatPrompt = useCallback(
|
||||
async (prompts: ChatSiteItemType[]) => {
|
||||
// create abort obj
|
||||
const abortSignal = new AbortController();
|
||||
controller.current = abortSignal;
|
||||
isLeavePage.current = false;
|
||||
|
||||
const prompt: ChatItemType[] = prompts.map((item) => ({
|
||||
_id: item._id,
|
||||
obj: item.obj,
|
||||
value: item.value
|
||||
}));
|
||||
|
||||
// 流请求,获取数据
|
||||
const { newChatId, quoteLen, systemPrompt } = await streamFetch({
|
||||
url: '/api/chat/chat',
|
||||
data: {
|
||||
prompt,
|
||||
chatId,
|
||||
modelId
|
||||
},
|
||||
onMessage: (text: string) => {
|
||||
setChatData((state) => ({
|
||||
...state,
|
||||
history: state.history.map((item, index) => {
|
||||
if (index !== state.history.length - 1) return item;
|
||||
return {
|
||||
...item,
|
||||
value: item.value + text
|
||||
};
|
||||
})
|
||||
}));
|
||||
generatingMessage();
|
||||
},
|
||||
abortSignal
|
||||
});
|
||||
|
||||
// 重置了页面,说明退出了当前聊天, 不缓存任何内容
|
||||
if (isLeavePage.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newChatId) {
|
||||
setForbidLoadChatData(true);
|
||||
router.replace(`/chat?modelId=${modelId}&chatId=${newChatId}`);
|
||||
}
|
||||
|
||||
abortSignal.signal.aborted && (await delay(500));
|
||||
|
||||
// 设置聊天内容为完成状态
|
||||
setChatData((state) => ({
|
||||
...state,
|
||||
chatId: newChatId || state.chatId, // 如果有 Id,说明是新创建的对话
|
||||
history: state.history.map((item, index) => {
|
||||
if (index !== state.history.length - 1) return item;
|
||||
return {
|
||||
...item,
|
||||
status: 'finish',
|
||||
quoteLen,
|
||||
systemPrompt
|
||||
};
|
||||
})
|
||||
}));
|
||||
|
||||
// refresh data
|
||||
setTimeout(() => {
|
||||
generatingMessage();
|
||||
loadHistory({ pageNum: 1, init: true });
|
||||
loadMyModels(true);
|
||||
}, 100);
|
||||
},
|
||||
[
|
||||
chatId,
|
||||
modelId,
|
||||
setChatData,
|
||||
loadHistory,
|
||||
loadMyModels,
|
||||
generatingMessage,
|
||||
setForbidLoadChatData,
|
||||
router
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* 发送一个内容
|
||||
*/
|
||||
const sendPrompt = useCallback(async () => {
|
||||
// get value
|
||||
if (isChatting) {
|
||||
toast({
|
||||
title: '正在聊天中...请等待结束',
|
||||
status: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// get input value
|
||||
const value = TextareaDom.current?.value || '';
|
||||
const val = value.trim().replace(/\n\s*/g, '\n');
|
||||
|
||||
if (!val) {
|
||||
toast({
|
||||
title: '内容为空',
|
||||
status: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newChatList: ChatSiteItemType[] = [
|
||||
...chatData.history,
|
||||
{
|
||||
_id: String(new Types.ObjectId()),
|
||||
obj: 'Human',
|
||||
value: val,
|
||||
status: 'finish'
|
||||
},
|
||||
{
|
||||
_id: String(new Types.ObjectId()),
|
||||
obj: 'AI',
|
||||
value: '',
|
||||
status: 'loading'
|
||||
}
|
||||
];
|
||||
|
||||
// 插入内容
|
||||
setChatData((state) => ({
|
||||
...state,
|
||||
history: newChatList
|
||||
}));
|
||||
|
||||
// 清空输入内容
|
||||
resetInputVal('');
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
|
||||
try {
|
||||
await gptChatPrompt(newChatList.slice(newChatList.length - 2));
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: typeof err === 'string' ? err : err?.message || '聊天出错了~',
|
||||
status: 'warning',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
|
||||
resetInputVal(value);
|
||||
|
||||
setChatData((state) => ({
|
||||
...state,
|
||||
history: newChatList.slice(0, newChatList.length - 2)
|
||||
}));
|
||||
}
|
||||
}, [
|
||||
isChatting,
|
||||
chatData.history,
|
||||
setChatData,
|
||||
resetInputVal,
|
||||
toast,
|
||||
scrollToBottom,
|
||||
gptChatPrompt
|
||||
]);
|
||||
|
||||
// 删除一句话
|
||||
const delChatRecord = useCallback(
|
||||
async (index: number, historyId: string) => {
|
||||
if (!messageContextMenuData) return;
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 删除数据库最后一句
|
||||
await delChatRecordByIndex(chatId, historyId);
|
||||
|
||||
setChatData((state) => ({
|
||||
...state,
|
||||
history: state.history.filter((_, i) => i !== index)
|
||||
}));
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
setIsLoading(false);
|
||||
},
|
||||
[chatId, messageContextMenuData, setChatData, setIsLoading]
|
||||
);
|
||||
|
||||
// 复制内容
|
||||
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: 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]
|
||||
);
|
||||
|
||||
// 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 (!isPc) {
|
||||
PhoneContextShow.current = true;
|
||||
}
|
||||
|
||||
setMessageContextMenuData({
|
||||
left: e.clientX - 20,
|
||||
top: e.clientY,
|
||||
message
|
||||
});
|
||||
|
||||
return false;
|
||||
},
|
||||
[isPc]
|
||||
);
|
||||
|
||||
// 获取对话信息
|
||||
const loadChatInfo = useCallback(
|
||||
async ({
|
||||
modelId,
|
||||
chatId,
|
||||
loading = false
|
||||
}: {
|
||||
modelId: string;
|
||||
chatId: string;
|
||||
loading?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
loading && setIsLoading(true);
|
||||
const res = await getInitChatSiteInfo(modelId, chatId);
|
||||
|
||||
setChatData({
|
||||
...res,
|
||||
history: res.history.map((item) => ({
|
||||
...item,
|
||||
status: 'finish'
|
||||
}))
|
||||
});
|
||||
|
||||
// have records.
|
||||
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;
|
||||
},
|
||||
[
|
||||
setIsLoading,
|
||||
setChatData,
|
||||
scrollToBottom,
|
||||
setForbidLoadChatData,
|
||||
router,
|
||||
setLastChatModelId,
|
||||
setLastChatId,
|
||||
loadHistory
|
||||
]
|
||||
);
|
||||
// 初始化聊天框
|
||||
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);
|
||||
|
||||
if (forbidLoadChatData) {
|
||||
setForbidLoadChatData(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
return loadChatInfo({
|
||||
modelId,
|
||||
chatId,
|
||||
loading: true
|
||||
});
|
||||
});
|
||||
|
||||
// abort stream
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.speechSynthesis?.cancel();
|
||||
isLeavePage.current = true;
|
||||
controller.current?.abort();
|
||||
};
|
||||
}, [modelId, chatId]);
|
||||
|
||||
// context menu component
|
||||
const RenderContextMenu = useCallback(
|
||||
({
|
||||
history,
|
||||
index,
|
||||
AiDetail = false
|
||||
}: {
|
||||
history: ChatSiteItemType;
|
||||
index: number;
|
||||
AiDetail?: boolean;
|
||||
}) => (
|
||||
<MenuList fontSize={'sm'} minW={'100px !important'}>
|
||||
<MenuItem onClick={() => onclickCopy(history.value)}>复制</MenuItem>
|
||||
{AiDetail && chatData.model.canUse && history.obj === 'AI' && (
|
||||
<MenuItem
|
||||
borderBottom={theme.borders.base}
|
||||
onClick={() => router.push(`/model?modelId=${chatData.modelId}`)}
|
||||
>
|
||||
应用详情
|
||||
</MenuItem>
|
||||
)}
|
||||
{hasVoiceApi && (
|
||||
<MenuItem
|
||||
borderBottom={theme.borders.base}
|
||||
onClick={() => voiceBroadcast({ text: history.value })}
|
||||
>
|
||||
语音播报
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
<MenuItem onClick={() => delChatRecord(index, history._id)}>删除</MenuItem>
|
||||
</MenuList>
|
||||
),
|
||||
[
|
||||
chatData.model.canUse,
|
||||
chatData.modelId,
|
||||
delChatRecord,
|
||||
onclickCopy,
|
||||
router,
|
||||
theme.borders.base
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
h={'100%'}
|
||||
flexDirection={['column', 'row']}
|
||||
backgroundColor={useColorModeValue('#fdfdfd', '')}
|
||||
>
|
||||
{/* pc always show history. */}
|
||||
{(isPc || !modelId) && (
|
||||
<SideBar>
|
||||
<History onclickDelHistory={onclickDelHistory} onclickExportChat={onclickExportChat} />
|
||||
</SideBar>
|
||||
)}
|
||||
|
||||
{/* 聊天内容 */}
|
||||
{modelId && (
|
||||
<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={'menu'}
|
||||
w={'20px'}
|
||||
h={'20px'}
|
||||
color={useColorModeValue('blackAlpha.700', 'white')}
|
||||
onClick={onOpenSlider}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
cursor={'pointer'}
|
||||
lineHeight={1.2}
|
||||
textAlign={'center'}
|
||||
px={3}
|
||||
fontSize={['sm', 'md']}
|
||||
onClick={() => router.push(`/model?modelId=${chatData.modelId}`)}
|
||||
>
|
||||
{chatData.model.name} {ChatModelMap[chatData.chatModel].name}
|
||||
{chatData.history.length > 0 ? ` (${chatData.history.length})` : ''}
|
||||
</Box>
|
||||
{chatId ? (
|
||||
<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?modelId=${modelId}`);
|
||||
} 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>
|
||||
) : (
|
||||
<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'}>
|
||||
{chatData.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>
|
||||
<Tooltip label={item.obj === 'AI' ? '应用详情' : ''}>
|
||||
<MenuButton
|
||||
as={Box}
|
||||
{...(item.obj === 'AI'
|
||||
? {
|
||||
order: 1,
|
||||
mr: ['6px', 2],
|
||||
cursor: 'pointer',
|
||||
onClick: () =>
|
||||
isPc &&
|
||||
chatData.model.canUse &&
|
||||
router.push(`/model?modelId=${chatData.modelId}`)
|
||||
}
|
||||
: {
|
||||
order: 3,
|
||||
ml: ['6px', 2]
|
||||
})}
|
||||
>
|
||||
<Avatar
|
||||
className="avatar"
|
||||
src={
|
||||
item.obj === 'Human'
|
||||
? userInfo?.avatar || HUMAN_ICON
|
||||
: chatData.model.avatar
|
||||
}
|
||||
w={['20px', '34px']}
|
||||
h={['20px', '34px']}
|
||||
/>
|
||||
</MenuButton>
|
||||
</Tooltip>
|
||||
{!isPc && <RenderContextMenu history={item} index={index} AiDetail />}
|
||||
</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 === chatData.history.length - 1}
|
||||
formatLink
|
||||
/>
|
||||
<Flex>
|
||||
{!!item.systemPrompt && (
|
||||
<Button
|
||||
mt={2}
|
||||
mr={3}
|
||||
size={'xs'}
|
||||
fontWeight={'normal'}
|
||||
colorScheme={'gray'}
|
||||
variant={'outline'}
|
||||
px={[2, 4]}
|
||||
onClick={() => setShowSystemPrompt(item.systemPrompt || '')}
|
||||
>
|
||||
提示词
|
||||
</Button>
|
||||
)}
|
||||
{!!item.quoteLen && (
|
||||
<Button
|
||||
mt={2}
|
||||
size={'xs'}
|
||||
fontWeight={'normal'}
|
||||
colorScheme={'gray'}
|
||||
variant={'outline'}
|
||||
px={[2, 4]}
|
||||
onClick={() => setShowHistoryQuote(item._id)}
|
||||
>
|
||||
{item.quoteLen}条引用
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</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>
|
||||
))}
|
||||
{chatData.history.length === 0 && (
|
||||
<Empty model={chatData.model} showChatProblem={true} />
|
||||
)}
|
||||
</Box>
|
||||
</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 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'}
|
||||
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;
|
||||
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();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<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 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'}>
|
||||
<PhoneSliderBar chatId={chatId} modelId={modelId} onClose={onCloseSlider} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
{/* quote modal*/}
|
||||
{showHistoryQuote && chatId && (
|
||||
<QuoteModal
|
||||
historyId={showHistoryQuote}
|
||||
chatId={chatId}
|
||||
onClose={() => setShowHistoryQuote(undefined)}
|
||||
/>
|
||||
)}
|
||||
{/* system prompt show modal */}
|
||||
{
|
||||
<Modal isOpen={!!showSystemPrompt} onClose={() => setShowSystemPrompt('')}>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW={'min(90vw, 600px)'} maxH={'80vh'} minH={'50vh'} overflow={'overlay'}>
|
||||
<ModalCloseButton />
|
||||
<ModalHeader>提示词</ModalHeader>
|
||||
<ModalBody pt={0} whiteSpace={'pre-wrap'} textAlign={'justify'} fontSize={'xs'}>
|
||||
{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={chatData.history.findIndex(
|
||||
(item) => item._id === messageContextMenuData.message._id
|
||||
)}
|
||||
AiDetail={!isPc}
|
||||
/>
|
||||
</Menu>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
Chat.getInitialProps = ({ query, req }: any) => {
|
||||
return {
|
||||
modelId: query?.modelId || '',
|
||||
chatId: query?.chatId || ''
|
||||
};
|
||||
};
|
||||
|
||||
export default Chat;
|
||||
850
client/src/pages/chat/share.tsx
Normal file
850
client/src/pages/chat/share.tsx
Normal file
@@ -0,0 +1,850 @@
|
||||
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,
|
||||
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 { useGlobalStore } from '@/store/global';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useCopyData, voiceBroadcast, hasVoiceApi } from '@/utils/tools';
|
||||
import { streamFetch } from '@/api/fetch';
|
||||
import MyIcon from '@/components/Icon';
|
||||
import { throttle } from 'lodash';
|
||||
import { Types } from 'mongoose';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { useLoading } from '@/hooks/useLoading';
|
||||
import { fileDownload } from '@/utils/file';
|
||||
import { htmlTemplate } from '@/constants/common';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import Loading from '@/components/Loading';
|
||||
import Markdown from '@/components/Markdown';
|
||||
import SideBar from '@/components/SideBar';
|
||||
import Avatar from '@/components/Avatar';
|
||||
import Empty from './components/Empty';
|
||||
import { HUMAN_ICON } from '@/constants/chat';
|
||||
|
||||
const ShareHistory = dynamic(() => import('./components/ShareHistory'), {
|
||||
loading: () => <Loading fixed={false} />,
|
||||
ssr: false
|
||||
});
|
||||
|
||||
import styles from './index.module.scss';
|
||||
|
||||
const textareaMinH = '22px';
|
||||
|
||||
const Chat = ({ shareId, historyId }: { shareId: string; historyId: string }) => {
|
||||
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 [messageContextMenuData, setMessageContextMenuData] = useState<{
|
||||
// message messageContextMenuData
|
||||
left: number;
|
||||
top: number;
|
||||
message: ChatSiteItemType;
|
||||
}>();
|
||||
|
||||
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 } = useGlobalStore();
|
||||
const { Loading, setIsLoading } = useLoading();
|
||||
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 } = 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'
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
history: responseHistory
|
||||
};
|
||||
});
|
||||
|
||||
setShareChatHistory({
|
||||
historyId,
|
||||
shareId,
|
||||
title: formatPrompts[formatPrompts.length - 2].value,
|
||||
latestChat: responseText,
|
||||
chats: responseHistory
|
||||
});
|
||||
|
||||
window.top?.postMessage(
|
||||
{
|
||||
type: 'shareChatFinish',
|
||||
data: {
|
||||
responseText
|
||||
}
|
||||
},
|
||||
'*'
|
||||
);
|
||||
|
||||
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 (!isPc) {
|
||||
PhoneContextShow.current = true;
|
||||
}
|
||||
|
||||
setMessageContextMenuData({
|
||||
left: e.clientX - 20,
|
||||
top: e.clientY,
|
||||
message
|
||||
});
|
||||
|
||||
return false;
|
||||
},
|
||||
[isPc]
|
||||
);
|
||||
|
||||
// 获取对话信息
|
||||
const loadChatInfo = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await initShareChatInfo({
|
||||
shareId,
|
||||
password
|
||||
});
|
||||
|
||||
const history = shareChatHistory.find((item) => item._id === historyId)?.chats || [];
|
||||
|
||||
setShareChatData({
|
||||
...res,
|
||||
history
|
||||
});
|
||||
|
||||
onClosePassword();
|
||||
|
||||
history.length > 0 &&
|
||||
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, historyId, onclickCopy, theme.borders.base]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
h={'100%'}
|
||||
flexDirection={['column', 'row']}
|
||||
backgroundColor={useColorModeValue('#fdfdfd', '')}
|
||||
>
|
||||
{/* pc always show history. */}
|
||||
{isPc && (
|
||||
<SideBar>
|
||||
<ShareHistory
|
||||
onclickDelHistory={delShareHistoryById}
|
||||
onclickExportChat={onclickExportChat}
|
||||
onCloseSlider={onCloseSlider}
|
||||
/>
|
||||
</SideBar>
|
||||
)}
|
||||
|
||||
{/* 聊天内容 */}
|
||||
<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={'menu'}
|
||||
w={'20px'}
|
||||
h={'20px'}
|
||||
color={useColorModeValue('blackAlpha.700', 'white')}
|
||||
onClick={onOpenSlider}
|
||||
/>
|
||||
)}
|
||||
<Box 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>
|
||||
<Tooltip label={item.obj === 'AI' ? '应用详情' : ''}>
|
||||
<MenuButton
|
||||
as={Box}
|
||||
{...(item.obj === 'AI'
|
||||
? {
|
||||
order: 1,
|
||||
mr: ['6px', 2]
|
||||
}
|
||||
: {
|
||||
order: 3,
|
||||
ml: ['6px', 2]
|
||||
})}
|
||||
>
|
||||
<Avatar
|
||||
src={
|
||||
item.obj === 'Human'
|
||||
? shareChatData.userAvatar || HUMAN_ICON
|
||||
: shareChatData.model.avatar
|
||||
}
|
||||
w={['20px', '34px']}
|
||||
h={['20px', '34px']}
|
||||
/>
|
||||
</MenuButton>
|
||||
</Tooltip>
|
||||
{!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}
|
||||
formatLink
|
||||
/>
|
||||
</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} showChatProblem={false} />
|
||||
)}
|
||||
</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 (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();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<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}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
{/* 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 || ''
|
||||
};
|
||||
};
|
||||
|
||||
export default Chat;
|
||||
5
client/src/pages/index.module.scss
Normal file
5
client/src/pages/index.module.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.home {
|
||||
* {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
202
client/src/pages/index.tsx
Normal file
202
client/src/pages/index.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Card, Box, Link, Flex, Image, Button } from '@chakra-ui/react';
|
||||
import Markdown from '@/components/Markdown';
|
||||
import { useMarkdown } from '@/hooks/useMarkdown';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
|
||||
import styles from './index.module.scss';
|
||||
|
||||
const Home = () => {
|
||||
const router = useRouter();
|
||||
const { inviterId } = router.query as { inviterId: string };
|
||||
const { data } = useMarkdown({ url: '/intro.md' });
|
||||
const {
|
||||
isPc,
|
||||
initData: { beianText }
|
||||
} = useGlobalStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (inviterId) {
|
||||
localStorage.setItem('inviterId', inviterId);
|
||||
}
|
||||
}, [inviterId]);
|
||||
|
||||
/* 加载动画 */
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
window.particlesJS?.('particles-js', {
|
||||
particles: {
|
||||
number: {
|
||||
value: 40,
|
||||
density: {
|
||||
enable: true,
|
||||
value_area: 500
|
||||
}
|
||||
},
|
||||
color: {
|
||||
value: '#4e83fd'
|
||||
},
|
||||
shape: {
|
||||
type: 'circle',
|
||||
stroke: {
|
||||
width: 0,
|
||||
color: '#000000'
|
||||
},
|
||||
polygon: {
|
||||
nb_sides: 5
|
||||
}
|
||||
},
|
||||
opacity: {
|
||||
value: 0.5,
|
||||
random: false,
|
||||
anim: {
|
||||
enable: false,
|
||||
speed: 0.1,
|
||||
opacity_min: 0.1,
|
||||
sync: false
|
||||
}
|
||||
},
|
||||
size: {
|
||||
value: 3,
|
||||
random: true,
|
||||
anim: {
|
||||
enable: false,
|
||||
speed: 10,
|
||||
size_min: 0.1,
|
||||
sync: false
|
||||
}
|
||||
},
|
||||
line_linked: {
|
||||
enable: true,
|
||||
distance: 150,
|
||||
color: '#adceff',
|
||||
opacity: 0.4,
|
||||
width: 1
|
||||
},
|
||||
move: {
|
||||
enable: true,
|
||||
speed: 2,
|
||||
direction: 'none',
|
||||
random: true,
|
||||
straight: false,
|
||||
out_mode: 'out',
|
||||
bounce: false,
|
||||
attract: {
|
||||
enable: false,
|
||||
rotateX: 600,
|
||||
rotateY: 1200
|
||||
}
|
||||
}
|
||||
},
|
||||
interactivity: {
|
||||
detect_on: 'canvas',
|
||||
events: {
|
||||
onhover: {
|
||||
enable: true,
|
||||
mode: 'grab'
|
||||
},
|
||||
onclick: {
|
||||
enable: true,
|
||||
mode: 'push'
|
||||
},
|
||||
resize: true
|
||||
},
|
||||
modes: {
|
||||
grab: {
|
||||
distance: 140,
|
||||
line_linked: {
|
||||
opacity: 1
|
||||
}
|
||||
},
|
||||
bubble: {
|
||||
distance: 400,
|
||||
size: 40,
|
||||
duration: 2,
|
||||
opacity: 8,
|
||||
speed: 3
|
||||
},
|
||||
repulse: {
|
||||
distance: 200,
|
||||
duration: 0.4
|
||||
},
|
||||
push: {
|
||||
particles_nb: 4
|
||||
},
|
||||
remove: {
|
||||
particles_nb: 2
|
||||
}
|
||||
}
|
||||
},
|
||||
retina_detect: true
|
||||
});
|
||||
} catch (error) {}
|
||||
}, 500);
|
||||
}, [isPc]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
className={styles.home}
|
||||
position={'relative'}
|
||||
flexDirection={'column'}
|
||||
alignItems={'center'}
|
||||
h={'100%'}
|
||||
overflow={'overlay'}
|
||||
>
|
||||
<Box id={'particles-js'} position={'absolute'} top={0} left={0} right={0} bottom={0} />
|
||||
|
||||
<Flex
|
||||
flexDirection={'column'}
|
||||
alignItems={'center'}
|
||||
mt={'22vh'}
|
||||
position={'absolute'}
|
||||
userSelect={'none'}
|
||||
>
|
||||
<Image src="/icon/logo.png" w={['70px', '120px']} h={['70px', '120px']} alt={''}></Image>
|
||||
<Box
|
||||
fontWeight={'bold'}
|
||||
fontSize={['40px', '70px']}
|
||||
letterSpacing={'5px'}
|
||||
color={'myBlue.600'}
|
||||
>
|
||||
FastGpt
|
||||
</Box>
|
||||
<Box color={'myBlue.600'} fontSize={['30px', '50px']}>
|
||||
三分钟
|
||||
</Box>
|
||||
<Box color={'myBlue.600'} fontSize={['30px', '50px']}>
|
||||
搭建 AI 知识库
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
my={5}
|
||||
fontSize={['xl', '3xl']}
|
||||
h={'auto'}
|
||||
py={[2, 3]}
|
||||
onClick={() => router.push(`/model`)}
|
||||
>
|
||||
点击开始
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Box w={'100%'} mt={'100vh'} px={[5, 10]} pb={[5, 10]}>
|
||||
<Card p={5} lineHeight={2}>
|
||||
<Markdown source={data} isChatting={false} />
|
||||
</Card>
|
||||
|
||||
<Card p={5} mt={4} textAlign={'center'}>
|
||||
{beianText && (
|
||||
<Link href="https://beian.miit.gov.cn/" target="_blank">
|
||||
{beianText}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Box>Made by FastGpt Team.</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
313
client/src/pages/kb/components/DataCard.tsx
Normal file
313
client/src/pages/kb/components/DataCard.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import React, { useCallback, useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TableContainer,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
IconButton,
|
||||
Flex,
|
||||
Button,
|
||||
useDisclosure,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Input,
|
||||
Tooltip
|
||||
} from '@chakra-ui/react';
|
||||
import { QuestionOutlineIcon } from '@chakra-ui/icons';
|
||||
import type { BoxProps } from '@chakra-ui/react';
|
||||
import type { KbDataItemType } from '@/types/plugin';
|
||||
import { usePagination } from '@/hooks/usePagination';
|
||||
import {
|
||||
getKbDataList,
|
||||
getExportDataList,
|
||||
delOneKbDataByDataId,
|
||||
getTrainingData
|
||||
} from '@/api/plugins/kb';
|
||||
import { DeleteIcon, RepeatIcon, EditIcon } from '@chakra-ui/icons';
|
||||
import { useLoading } from '@/hooks/useLoading';
|
||||
import { fileDownload } from '@/utils/file';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import Papa from 'papaparse';
|
||||
import dynamic from 'next/dynamic';
|
||||
import InputModal, { FormData as InputDataType } from './InputDataModal';
|
||||
|
||||
const SelectFileModal = dynamic(() => import('./SelectFileModal'));
|
||||
const SelectCsvModal = dynamic(() => import('./SelectCsvModal'));
|
||||
|
||||
const DataCard = ({ kbId }: { kbId: string }) => {
|
||||
const lastSearch = useRef('');
|
||||
const tdStyles = useRef<BoxProps>({
|
||||
fontSize: 'xs',
|
||||
minW: '150px',
|
||||
maxW: '500px',
|
||||
maxH: '250px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowY: 'auto'
|
||||
});
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const { Loading, setIsLoading } = useLoading();
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
data: kbDataList,
|
||||
isLoading,
|
||||
Pagination,
|
||||
total,
|
||||
getData,
|
||||
pageNum
|
||||
} = usePagination<KbDataItemType>({
|
||||
api: getKbDataList,
|
||||
pageSize: 10,
|
||||
params: {
|
||||
kbId,
|
||||
searchText
|
||||
},
|
||||
defaultRequest: false
|
||||
});
|
||||
|
||||
const [editInputData, setEditInputData] = useState<InputDataType>();
|
||||
|
||||
const {
|
||||
isOpen: isOpenSelectFileModal,
|
||||
onOpen: onOpenSelectFileModal,
|
||||
onClose: onCloseSelectFileModal
|
||||
} = useDisclosure();
|
||||
const {
|
||||
isOpen: isOpenSelectCsvModal,
|
||||
onOpen: onOpenSelectCsvModal,
|
||||
onClose: onCloseSelectCsvModal
|
||||
} = useDisclosure();
|
||||
|
||||
const { data: { qaListLen = 0, vectorListLen = 0 } = {}, refetch } = useQuery(
|
||||
['getModelSplitDataList', kbId],
|
||||
() => getTrainingData({ kbId, init: false }),
|
||||
{
|
||||
onError(err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const refetchData = useCallback(
|
||||
(num = pageNum) => {
|
||||
getData(num);
|
||||
refetch();
|
||||
return null;
|
||||
},
|
||||
[getData, pageNum, refetch]
|
||||
);
|
||||
|
||||
// get al data and export csv
|
||||
const { mutate: onclickExport, isLoading: isLoadingExport = false } = useMutation({
|
||||
mutationFn: () => getExportDataList(kbId),
|
||||
onSuccess(res) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const text = Papa.unparse({
|
||||
fields: ['question', 'answer'],
|
||||
data: res
|
||||
});
|
||||
fileDownload({
|
||||
text,
|
||||
type: 'text/csv',
|
||||
filename: 'data.csv'
|
||||
});
|
||||
toast({
|
||||
title: '导出成功,下次导出需要半小时后',
|
||||
status: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
error;
|
||||
}
|
||||
setIsLoading(false);
|
||||
},
|
||||
onError(err: any) {
|
||||
toast({
|
||||
title: typeof err === 'string' ? err : err?.message || '导出异常',
|
||||
status: 'error'
|
||||
});
|
||||
console.log(err);
|
||||
}
|
||||
});
|
||||
|
||||
// interval get data
|
||||
useQuery(['refetchData'], () => refetchData(1), {
|
||||
refetchInterval: 5000,
|
||||
enabled: qaListLen > 0 || vectorListLen > 0
|
||||
});
|
||||
useQuery(['getKbData', kbId], () => {
|
||||
setSearchText('');
|
||||
getData(1);
|
||||
return null;
|
||||
});
|
||||
|
||||
return (
|
||||
<Box position={'relative'}>
|
||||
<Flex>
|
||||
<Box fontWeight={'bold'} fontSize={'lg'} flex={1} mr={2}>
|
||||
知识库数据: {total}组
|
||||
</Box>
|
||||
<IconButton
|
||||
icon={<RepeatIcon />}
|
||||
aria-label={'refresh'}
|
||||
variant={'outline'}
|
||||
mr={[2, 4]}
|
||||
size={'sm'}
|
||||
onClick={() => {
|
||||
refetchData(pageNum);
|
||||
getTrainingData({ kbId, init: true });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
mr={2}
|
||||
size={'sm'}
|
||||
isLoading={isLoadingExport}
|
||||
title={'半小时仅能导出1次'}
|
||||
onClick={() => onclickExport()}
|
||||
>
|
||||
导出csv
|
||||
</Button>
|
||||
<Menu autoSelect={false}>
|
||||
<MenuButton as={Button} size={'sm'}>
|
||||
导入
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
setEditInputData({
|
||||
a: '',
|
||||
q: ''
|
||||
})
|
||||
}
|
||||
>
|
||||
手动输入
|
||||
</MenuItem>
|
||||
<MenuItem onClick={onOpenSelectFileModal}>文本/文件拆分</MenuItem>
|
||||
<MenuItem onClick={onOpenSelectCsvModal}>csv 问答对导入</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Flex>
|
||||
<Flex mt={4}>
|
||||
{(qaListLen > 0 || vectorListLen > 0) && (
|
||||
<Box fontSize={'xs'}>
|
||||
{qaListLen > 0 ? `${qaListLen}条数据正在拆分,` : ''}
|
||||
{vectorListLen > 0 ? `${vectorListLen}条数据正在生成索引,` : ''}
|
||||
请耐心等待...
|
||||
</Box>
|
||||
)}
|
||||
<Box flex={1} />
|
||||
<Input
|
||||
maxW={['90%', '300px']}
|
||||
size={'sm'}
|
||||
value={searchText}
|
||||
placeholder="搜索匹配知识,补充知识和来源,回车确认"
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (searchText === lastSearch.current) return;
|
||||
getData(1);
|
||||
lastSearch.current = searchText;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (searchText === lastSearch.current) return;
|
||||
if (e.key === 'Enter') {
|
||||
getData(1);
|
||||
lastSearch.current = searchText;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<TableContainer mt={4} minH={'200px'}>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>
|
||||
匹配的知识点
|
||||
<Tooltip
|
||||
label={
|
||||
'对话时,会将用户的问题和知识库的 "匹配知识点" 进行比较,找到最相似的前 n 条记录,将这些记录的 "匹配知识点"+"补充知识点" 作为 chatgpt 的系统提示词。'
|
||||
}
|
||||
>
|
||||
<QuestionOutlineIcon ml={1} />
|
||||
</Tooltip>
|
||||
</Th>
|
||||
<Th>补充知识</Th>
|
||||
<Th>来源</Th>
|
||||
<Th>操作</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{kbDataList.map((item) => (
|
||||
<Tr key={item.id} fontSize={'sm'}>
|
||||
<Td>
|
||||
<Box {...tdStyles.current}>{item.q}</Box>
|
||||
</Td>
|
||||
<Td>
|
||||
<Box {...tdStyles.current}>{item.a || '-'}</Box>
|
||||
</Td>
|
||||
<Td maxW={'15%'} whiteSpace={'pre-wrap'} userSelect={'all'}>
|
||||
{item.source?.trim() || '-'}
|
||||
</Td>
|
||||
<Td>
|
||||
<IconButton
|
||||
mr={5}
|
||||
icon={<EditIcon />}
|
||||
variant={'outline'}
|
||||
aria-label={'delete'}
|
||||
size={'sm'}
|
||||
onClick={() =>
|
||||
setEditInputData({
|
||||
dataId: item.id,
|
||||
q: item.q,
|
||||
a: item.a
|
||||
})
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
variant={'outline'}
|
||||
colorScheme={'gray'}
|
||||
aria-label={'delete'}
|
||||
size={'sm'}
|
||||
onClick={async () => {
|
||||
await delOneKbDataByDataId(item.id);
|
||||
refetchData(pageNum);
|
||||
}}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex mt={2} justifyContent={'flex-end'}>
|
||||
<Pagination />
|
||||
</Flex>
|
||||
|
||||
<Loading loading={isLoading} fixed={false} />
|
||||
{editInputData !== undefined && (
|
||||
<InputModal
|
||||
kbId={kbId}
|
||||
defaultValues={editInputData}
|
||||
onClose={() => setEditInputData(undefined)}
|
||||
onSuccess={refetchData}
|
||||
/>
|
||||
)}
|
||||
{isOpenSelectFileModal && (
|
||||
<SelectFileModal kbId={kbId} onClose={onCloseSelectFileModal} onSuccess={refetchData} />
|
||||
)}
|
||||
{isOpenSelectCsvModal && (
|
||||
<SelectCsvModal kbId={kbId} onClose={onCloseSelectCsvModal} onSuccess={refetchData} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataCard;
|
||||
243
client/src/pages/kb/components/Detail.tsx
Normal file
243
client/src/pages/kb/components/Detail.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import React, { useCallback, useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
Card,
|
||||
Box,
|
||||
Flex,
|
||||
Button,
|
||||
Tooltip,
|
||||
FormControl,
|
||||
Input,
|
||||
Tag,
|
||||
IconButton
|
||||
} from '@chakra-ui/react';
|
||||
import { QuestionOutlineIcon, DeleteIcon } from '@chakra-ui/icons';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { delKbById, putKbById } from '@/api/plugins/kb';
|
||||
import { KbItemType } from '@/types/plugin';
|
||||
import { useSelectFile } from '@/hooks/useSelectFile';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { compressImg } from '@/utils/file';
|
||||
import DataCard from './DataCard';
|
||||
import { getErrText } from '@/utils/tools';
|
||||
import Avatar from '@/components/Avatar';
|
||||
|
||||
const Detail = ({ kbId }: { kbId: string }) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const InputRef = useRef<HTMLInputElement>(null);
|
||||
const { setLastKbId, kbDetail, getKbDetail, loadKbList, myKbList } = useUserStore();
|
||||
const [btnLoading, setBtnLoading] = useState(false);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
|
||||
const { getValues, formState, setValue, reset, register, handleSubmit } = useForm<KbItemType>({
|
||||
defaultValues: kbDetail
|
||||
});
|
||||
const { openConfirm, ConfirmChild } = useConfirm({
|
||||
content: '确认删除该知识库?数据将无法恢复,请确认!'
|
||||
});
|
||||
|
||||
const { File, onOpen: onOpenSelectFile } = useSelectFile({
|
||||
fileType: '.jpg,.png',
|
||||
multiple: false
|
||||
});
|
||||
|
||||
useQuery([kbId, myKbList], () => getKbDetail(kbId), {
|
||||
onSuccess(res) {
|
||||
kbId && setLastKbId(kbId);
|
||||
if (res) {
|
||||
reset(res);
|
||||
if (InputRef.current) {
|
||||
InputRef.current.value = res.tags;
|
||||
}
|
||||
}
|
||||
},
|
||||
onError(err: any) {
|
||||
loadKbList(true);
|
||||
setLastKbId('');
|
||||
router.replace(`/kb`);
|
||||
toast({
|
||||
title: getErrText(err, '获取知识库异常'),
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/* 点击删除 */
|
||||
const onclickDelKb = useCallback(async () => {
|
||||
setBtnLoading(true);
|
||||
try {
|
||||
await delKbById(kbId);
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success'
|
||||
});
|
||||
router.replace(`/kb?kbId=${myKbList.find((item) => item._id !== kbId)?._id || ''}`);
|
||||
await loadKbList(true);
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: err?.message || '删除失败',
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
setBtnLoading(false);
|
||||
}, [setBtnLoading, kbId, toast, router, myKbList, loadKbList]);
|
||||
|
||||
const saveSubmitSuccess = useCallback(
|
||||
async (data: KbItemType) => {
|
||||
setBtnLoading(true);
|
||||
try {
|
||||
await putKbById({
|
||||
id: kbId,
|
||||
...data
|
||||
});
|
||||
await getKbDetail(kbId, true);
|
||||
toast({
|
||||
title: '更新成功',
|
||||
status: 'success'
|
||||
});
|
||||
loadKbList(true);
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: err?.message || '更新失败',
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
setBtnLoading(false);
|
||||
},
|
||||
[getKbDetail, kbId, loadKbList, toast]
|
||||
);
|
||||
const saveSubmitError = useCallback(() => {
|
||||
// deep search message
|
||||
const deepSearch = (obj: any): string => {
|
||||
if (!obj) return '提交表单错误';
|
||||
if (!!obj.message) {
|
||||
return obj.message;
|
||||
}
|
||||
return deepSearch(Object.values(obj)[0]);
|
||||
};
|
||||
toast({
|
||||
title: deepSearch(formState.errors),
|
||||
status: 'error',
|
||||
duration: 4000,
|
||||
isClosable: true
|
||||
});
|
||||
}, [formState.errors, toast]);
|
||||
|
||||
const onSelectFile = useCallback(
|
||||
async (e: File[]) => {
|
||||
const file = e[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const base64 = await compressImg({
|
||||
file,
|
||||
maxW: 100,
|
||||
maxH: 100
|
||||
});
|
||||
setValue('avatar', base64);
|
||||
setRefresh((state) => !state);
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: typeof err === 'string' ? err : '头像选择异常',
|
||||
status: 'warning'
|
||||
});
|
||||
}
|
||||
},
|
||||
[setRefresh, setValue, toast]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box h={'100%'} p={5} overflow={'overlay'} position={'relative'}>
|
||||
<Card p={6}>
|
||||
<Flex>
|
||||
<Box fontWeight={'bold'} fontSize={'2xl'} flex={1}>
|
||||
知识库信息
|
||||
</Box>
|
||||
{kbDetail._id && (
|
||||
<>
|
||||
<Button
|
||||
isLoading={btnLoading}
|
||||
mr={3}
|
||||
onClick={handleSubmit(saveSubmitSuccess, saveSubmitError)}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<IconButton
|
||||
isLoading={btnLoading}
|
||||
icon={<DeleteIcon />}
|
||||
aria-label={''}
|
||||
variant={'solid'}
|
||||
colorScheme={'red'}
|
||||
onClick={openConfirm(onclickDelKb)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex mt={5} alignItems={'center'}>
|
||||
<Box flex={'0 0 60px'} w={0}>
|
||||
头像
|
||||
</Box>
|
||||
<Avatar
|
||||
src={getValues('avatar')}
|
||||
w={['28px', '36px']}
|
||||
h={['28px', '36px']}
|
||||
cursor={'pointer'}
|
||||
title={'点击切换头像'}
|
||||
onClick={onOpenSelectFile}
|
||||
/>
|
||||
</Flex>
|
||||
<FormControl mt={5}>
|
||||
<Flex alignItems={'center'} maxW={'350px'}>
|
||||
<Box flex={'0 0 60px'} w={0}>
|
||||
名称
|
||||
</Box>
|
||||
<Input
|
||||
{...register('name', {
|
||||
required: '知识库名称不能为空'
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
</FormControl>
|
||||
<Box>
|
||||
<Flex mt={5} alignItems={'center'} maxW={'350px'} flexWrap={'wrap'}>
|
||||
<Box flex={'0 0 60px'} w={0}>
|
||||
标签
|
||||
<Tooltip label={'仅用于记忆,用空格隔开多个标签'}>
|
||||
<QuestionOutlineIcon ml={1} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Input
|
||||
flex={1}
|
||||
ref={InputRef}
|
||||
placeholder={'标签,使用空格分割。'}
|
||||
onChange={(e) => {
|
||||
setValue('tags', e.target.value);
|
||||
setRefresh(!refresh);
|
||||
}}
|
||||
/>
|
||||
<Box pl={'60px'} mt={2} w="100%">
|
||||
{getValues('tags')
|
||||
.split(' ')
|
||||
.filter((item) => item)
|
||||
.map((item, i) => (
|
||||
<Tag mr={2} mb={2} key={i} variant={'outline'} colorScheme={'blue'}>
|
||||
{item}
|
||||
</Tag>
|
||||
))}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Card>
|
||||
<Card p={6} mt={5}>
|
||||
<DataCard kbId={kbId} />
|
||||
</Card>
|
||||
<File onSelect={onSelectFile} />
|
||||
<ConfirmChild />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Detail;
|
||||
189
client/src/pages/kb/components/InputDataModal.tsx
Normal file
189
client/src/pages/kb/components/InputDataModal.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
Textarea
|
||||
} from '@chakra-ui/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { postKbDataFromList, putKbDataById } from '@/api/plugins/kb';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { TrainingModeEnum } from '@/constants/plugin';
|
||||
import { getErrText } from '@/utils/tools';
|
||||
|
||||
export type FormData = { dataId?: string; a: string; q: string };
|
||||
|
||||
const InputDataModal = ({
|
||||
onClose,
|
||||
onSuccess,
|
||||
kbId,
|
||||
defaultValues = {
|
||||
a: '',
|
||||
q: ''
|
||||
}
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
kbId: string;
|
||||
defaultValues?: FormData;
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const { register, handleSubmit, reset } = useForm<FormData>({
|
||||
defaultValues
|
||||
});
|
||||
|
||||
/**
|
||||
* 确认导入新数据
|
||||
*/
|
||||
const sureImportData = useCallback(
|
||||
async (e: FormData) => {
|
||||
if (e.a.length + e.q.length >= 3000) {
|
||||
toast({
|
||||
title: '总长度超长了',
|
||||
status: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { insertLen } = await postKbDataFromList({
|
||||
kbId,
|
||||
mode: TrainingModeEnum.index,
|
||||
data: [
|
||||
{
|
||||
a: e.a,
|
||||
q: e.q,
|
||||
source: '手动录入'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (insertLen === 0) {
|
||||
toast({
|
||||
title: '已存在完全一致的数据',
|
||||
status: 'warning'
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: '导入数据成功,需要一段时间训练',
|
||||
status: 'success'
|
||||
});
|
||||
reset({
|
||||
a: '',
|
||||
q: ''
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: getErrText(err, '出现了点意外~'),
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
},
|
||||
[kbId, onSuccess, reset, toast]
|
||||
);
|
||||
|
||||
const updateData = useCallback(
|
||||
async (e: FormData) => {
|
||||
if (!e.dataId) return;
|
||||
|
||||
if (e.a !== defaultValues.a || e.q !== defaultValues.q) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await putKbDataById({
|
||||
dataId: e.dataId,
|
||||
a: e.a,
|
||||
q: e.q === defaultValues.q ? '' : e.q
|
||||
});
|
||||
onSuccess();
|
||||
} catch (error) {}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '修改数据成功',
|
||||
status: 'success'
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
[defaultValues, onClose, onSuccess, toast]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal isOpen={true} onClose={onClose} isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent
|
||||
m={0}
|
||||
display={'flex'}
|
||||
flexDirection={'column'}
|
||||
h={'90vh'}
|
||||
maxW={'90vw'}
|
||||
position={'relative'}
|
||||
>
|
||||
<ModalHeader>{defaultValues.dataId ? '变更数据' : '手动导入数据'}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
|
||||
<Box
|
||||
display={'flex'}
|
||||
flexDirection={['column', 'row']}
|
||||
flex={'1 0 0'}
|
||||
h={['100%', 0]}
|
||||
overflow={'overlay'}
|
||||
px={6}
|
||||
pb={2}
|
||||
>
|
||||
<Box flex={1} mr={[0, 4]} mb={[4, 0]} h={['50%', '100%']}>
|
||||
<Box h={'30px'}>{'匹配的知识点'}</Box>
|
||||
<Textarea
|
||||
placeholder={'匹配的知识点。这部分内容会被搜索,请把控内容的质量。总和最多 3000 字。'}
|
||||
maxLength={3000}
|
||||
resize={'none'}
|
||||
h={'calc(100% - 30px)'}
|
||||
{...register(`q`, {
|
||||
required: true
|
||||
})}
|
||||
/>
|
||||
</Box>
|
||||
<Box flex={1} h={['50%', '100%']}>
|
||||
<Box h={'30px'}>补充知识</Box>
|
||||
<Textarea
|
||||
placeholder={
|
||||
'补充知识。这部分内容不会被搜索,但会作为"匹配的知识点"的内容补充,你可以讲一些细节的内容填写在这里。总和最多 3000 字。'
|
||||
}
|
||||
maxLength={3000}
|
||||
resize={'none'}
|
||||
h={'calc(100% - 30px)'}
|
||||
{...register('a')}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Flex px={6} pt={2} pb={4}>
|
||||
<Box flex={1}></Box>
|
||||
<Button variant={'outline'} mr={3} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={loading}
|
||||
onClick={handleSubmit(defaultValues.dataId ? updateData : sureImportData)}
|
||||
>
|
||||
{defaultValues.dataId ? '确认变更' : '确认导入'}
|
||||
</Button>
|
||||
</Flex>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputDataModal;
|
||||
150
client/src/pages/kb/components/KbList.tsx
Normal file
150
client/src/pages/kb/components/KbList.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useCallback, useState, useMemo } from 'react';
|
||||
import { Box, Flex, useTheme, Input, IconButton, Tooltip, Tag } from '@chakra-ui/react';
|
||||
import { AddIcon } from '@chakra-ui/icons';
|
||||
import { useRouter } from 'next/router';
|
||||
import { postCreateKb } from '@/api/plugins/kb';
|
||||
import { useLoading } from '@/hooks/useLoading';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import MyIcon from '@/components/Icon';
|
||||
import Avatar from '@/components/Avatar';
|
||||
|
||||
const KbList = ({ kbId }: { kbId: string }) => {
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { Loading, setIsLoading } = useLoading();
|
||||
const { myKbList, loadKbList } = useUserStore();
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
const kbs = useMemo(
|
||||
() => myKbList.filter((item) => new RegExp(searchText, 'ig').test(item.name + item.tags)),
|
||||
[myKbList, searchText]
|
||||
);
|
||||
|
||||
/* 加载模型 */
|
||||
const { isFetching } = useQuery(['loadModels'], () => loadKbList(false));
|
||||
|
||||
const handleCreateModel = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const name = `知识库${myKbList.length + 1}`;
|
||||
const id = await postCreateKb({ name });
|
||||
await loadKbList(true);
|
||||
toast({
|
||||
title: '创建成功',
|
||||
status: 'success'
|
||||
});
|
||||
router.replace(`/kb?kbId=${id}`);
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: typeof err === 'string' ? err : err.message || '出现了意外',
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [loadKbList, myKbList.length, router, setIsLoading, toast]);
|
||||
|
||||
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="搜索知识库"
|
||||
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={'新建一个知识库'}>
|
||||
<IconButton
|
||||
h={'32px'}
|
||||
icon={<AddIcon />}
|
||||
aria-label={''}
|
||||
variant={'outline'}
|
||||
onClick={handleCreateModel}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Box flex={'1 0 0'} h={0} overflow={'overlay'}>
|
||||
{kbs.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']
|
||||
}}
|
||||
{...(kbId === item._id
|
||||
? {
|
||||
backgroundColor: '#eff0f1',
|
||||
borderLeftColor: 'myBlue.600 !important'
|
||||
}
|
||||
: {})}
|
||||
onClick={() => {
|
||||
if (item._id === kbId) return;
|
||||
router.push(`/kb?kbId=${item._id}`);
|
||||
}}
|
||||
>
|
||||
<Avatar src={item.avatar} w={'34px'} h={'34px'} />
|
||||
<Box flex={'1 0 0'} w={0} ml={3}>
|
||||
<Box className="textEllipsis" color={'myGray.1000'}>
|
||||
{item.name}
|
||||
</Box>
|
||||
{/* tags */}
|
||||
<Box className="textEllipsis" color={'myGray.400'} mt={1} fontSize={'sm'}>
|
||||
{!item.tags ? (
|
||||
<>{item.tags || '你还没设置标签~'}</>
|
||||
) : (
|
||||
item.tags.split(' ').map((item, i) => (
|
||||
<Tag key={i} mr={2} mb={2} variant={'outline'} colorScheme={'blue'} size={'sm'}>
|
||||
{item}
|
||||
</Tag>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
{!isFetching && myKbList.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>
|
||||
<Loading loading={isFetching} fixed={false} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default KbList;
|
||||
180
client/src/pages/kb/components/SelectCsvModal.tsx
Normal file
180
client/src/pages/kb/components/SelectCsvModal.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody
|
||||
} from '@chakra-ui/react';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useSelectFile } from '@/hooks/useSelectFile';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { readCsvContent } from '@/utils/file';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { postKbDataFromList } from '@/api/plugins/kb';
|
||||
import Markdown from '@/components/Markdown';
|
||||
import { useMarkdown } from '@/hooks/useMarkdown';
|
||||
import { fileDownload } from '@/utils/file';
|
||||
import { TrainingModeEnum } from '@/constants/plugin';
|
||||
import { getErrText } from '@/utils/tools';
|
||||
|
||||
const csvTemplate = `question,answer\n"什么是 laf","laf 是一个云函数开发平台……"\n"什么是 sealos","Sealos 是以 kubernetes 为内核的云操作系统发行版,可以……"`;
|
||||
|
||||
const SelectJsonModal = ({
|
||||
onClose,
|
||||
onSuccess,
|
||||
kbId
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
kbId: string;
|
||||
}) => {
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const { File, onOpen } = useSelectFile({ fileType: '.csv', multiple: false });
|
||||
const [fileData, setFileData] = useState<{ q: string; a: string }[]>([]);
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [successData, setSuccessData] = useState(0);
|
||||
const { openConfirm, ConfirmChild } = useConfirm({
|
||||
content: '确认导入该数据集?'
|
||||
});
|
||||
|
||||
const onSelectFile = useCallback(
|
||||
async (e: File[]) => {
|
||||
const file = e[0];
|
||||
setSelecting(true);
|
||||
setFileName(file.name);
|
||||
try {
|
||||
const { header, data } = await readCsvContent(file);
|
||||
if (header[0] !== 'question' || header[1] !== 'answer') {
|
||||
throw new Error('csv 文件格式有误');
|
||||
}
|
||||
setFileData(
|
||||
data.map((item) => ({
|
||||
q: item[0] || '',
|
||||
a: item[1] || ''
|
||||
}))
|
||||
);
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: getErrText(error, 'csv 文件格式有误'),
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
setSelecting(false);
|
||||
},
|
||||
[setSelecting, toast]
|
||||
);
|
||||
|
||||
const { mutate, isLoading: uploading } = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!fileData || fileData.length === 0) return;
|
||||
|
||||
let success = 0;
|
||||
|
||||
// subsection import
|
||||
const step = 100;
|
||||
for (let i = 0; i < fileData.length; i += step) {
|
||||
const { insertLen } = await postKbDataFromList({
|
||||
kbId,
|
||||
data: fileData.slice(i, i + step).map((item) => ({
|
||||
...item,
|
||||
source: fileName
|
||||
})),
|
||||
mode: TrainingModeEnum.index
|
||||
});
|
||||
success += insertLen || 0;
|
||||
setSuccessData((state) => state + step);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: `导入数据成功,最终导入: ${success} 条数据。需要一段时间训练`,
|
||||
status: 'success',
|
||||
duration: 4000
|
||||
});
|
||||
onClose();
|
||||
onSuccess();
|
||||
},
|
||||
onError(err) {
|
||||
toast({
|
||||
title: getErrText(err, '导入文件失败'),
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const { data: intro } = useMarkdown({ url: '/csvSelect.md' });
|
||||
|
||||
return (
|
||||
<Modal isOpen={true} onClose={onClose} isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW={'90vw'} position={'relative'} m={0} h={'90vh'}>
|
||||
<ModalHeader>csv 问答对导入</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
|
||||
<ModalBody h={'100%'} display={['block', 'flex']} fontSize={'sm'} overflowY={'auto'}>
|
||||
<Box flex={'2 0 0'} w={['100%', 0]} mr={[0, 4]} mb={[4, 0]}>
|
||||
<Markdown source={intro} />
|
||||
<Box
|
||||
my={3}
|
||||
cursor={'pointer'}
|
||||
textDecoration={'underline'}
|
||||
color={'myBlue.600'}
|
||||
onClick={() =>
|
||||
fileDownload({
|
||||
text: csvTemplate,
|
||||
type: 'text/csv',
|
||||
filename: 'template.csv'
|
||||
})
|
||||
}
|
||||
>
|
||||
点击下载csv模板
|
||||
</Box>
|
||||
<Box>
|
||||
<Button isLoading={selecting} isDisabled={uploading} onClick={onOpen}>
|
||||
选择 csv 问答对
|
||||
</Button>
|
||||
<Box mt={4}>
|
||||
【{fileName}】一共有 {fileData.length} 组数据(下面最多展示100组)
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flex={'3 0 0'} h={'100%'} overflow={'auto'} p={2} backgroundColor={'blackAlpha.50'}>
|
||||
{fileData.slice(0, 100).map((item, index) => (
|
||||
<Box key={index}>
|
||||
<Box>
|
||||
Q{index + 1}. {item.q}
|
||||
</Box>
|
||||
<Box>
|
||||
A{index + 1}. {item.a}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</ModalBody>
|
||||
|
||||
<Flex px={6} pt={2} pb={4}>
|
||||
<Box flex={1}></Box>
|
||||
<Button variant={'outline'} isLoading={uploading} mr={3} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button isDisabled={fileData.length === 0 || uploading} onClick={openConfirm(mutate)}>
|
||||
{uploading ? (
|
||||
<Box>{Math.round((successData / fileData.length) * 100)}%</Box>
|
||||
) : (
|
||||
'确认导入'
|
||||
)}
|
||||
</Button>
|
||||
</Flex>
|
||||
</ModalContent>
|
||||
<ConfirmChild />
|
||||
<File onSelect={onSelectFile} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectJsonModal;
|
||||
316
client/src/pages/kb/components/SelectFileModal.tsx
Normal file
316
client/src/pages/kb/components/SelectFileModal.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
Input,
|
||||
Textarea
|
||||
} from '@chakra-ui/react';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useSelectFile } from '@/hooks/useSelectFile';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { readTxtContent, readPdfContent, readDocContent } from '@/utils/file';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { postKbDataFromList } from '@/api/plugins/kb';
|
||||
import Radio from '@/components/Radio';
|
||||
import { splitText_token } from '@/utils/file';
|
||||
import { TrainingModeEnum } from '@/constants/plugin';
|
||||
import { getErrText } from '@/utils/tools';
|
||||
import { ChatModelMap, OpenAiChatEnum, embeddingPrice } from '@/constants/model';
|
||||
import { formatPrice } from '@/utils/user';
|
||||
|
||||
const fileExtension = '.txt,.doc,.docx,.pdf,.md';
|
||||
|
||||
const modeMap = {
|
||||
[TrainingModeEnum.qa]: {
|
||||
maxLen: 2600,
|
||||
slideLen: 700,
|
||||
price: ChatModelMap[OpenAiChatEnum.GPT35].price,
|
||||
isPrompt: true
|
||||
},
|
||||
[TrainingModeEnum.index]: {
|
||||
maxLen: 700,
|
||||
slideLen: 300,
|
||||
price: embeddingPrice,
|
||||
isPrompt: false
|
||||
}
|
||||
};
|
||||
|
||||
const SelectFileModal = ({
|
||||
onClose,
|
||||
onSuccess,
|
||||
kbId
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
kbId: string;
|
||||
}) => {
|
||||
const [btnLoading, setBtnLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const { File, onOpen } = useSelectFile({
|
||||
fileType: fileExtension,
|
||||
multiple: true
|
||||
});
|
||||
const [mode, setMode] = useState<`${TrainingModeEnum}`>(TrainingModeEnum.index);
|
||||
const [files, setFiles] = useState<{ filename: string; text: string }[]>([
|
||||
{ filename: '文本1', text: '' }
|
||||
]);
|
||||
const [splitRes, setSplitRes] = useState<{
|
||||
price: number;
|
||||
chunks: { filename: string; value: string }[];
|
||||
successChunks: number;
|
||||
}>({
|
||||
price: 0,
|
||||
successChunks: 0,
|
||||
chunks: []
|
||||
});
|
||||
const { openConfirm, ConfirmChild } = useConfirm({
|
||||
content: `确认导入该文件,需要一定时间进行拆解,该任务无法终止!如果余额不足,未完成的任务会被暂停。一共 ${
|
||||
splitRes.chunks.length
|
||||
} 组。${splitRes.price ? `大约 ${splitRes.price} 元。` : ''}`
|
||||
});
|
||||
|
||||
const onSelectFile = useCallback(
|
||||
async (files: File[]) => {
|
||||
setBtnLoading(true);
|
||||
try {
|
||||
let promise = Promise.resolve();
|
||||
files.forEach((file) => {
|
||||
promise = promise.then(async () => {
|
||||
const extension = file?.name?.split('.')?.pop()?.toLowerCase();
|
||||
const text = await (async () => {
|
||||
switch (extension) {
|
||||
case 'txt':
|
||||
case 'md':
|
||||
return readTxtContent(file);
|
||||
case 'pdf':
|
||||
return readPdfContent(file);
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return readDocContent(file);
|
||||
}
|
||||
return '';
|
||||
})();
|
||||
|
||||
text && setFiles((state) => [{ filename: file.name, text }].concat(state));
|
||||
return;
|
||||
});
|
||||
});
|
||||
await promise;
|
||||
} catch (error: any) {
|
||||
console.log(error);
|
||||
toast({
|
||||
title: typeof error === 'string' ? error : '解析文件失败',
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
setBtnLoading(false);
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
const { mutate, isLoading: uploading } = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (splitRes.chunks.length === 0) return;
|
||||
|
||||
// subsection import
|
||||
let success = 0;
|
||||
const step = 100;
|
||||
for (let i = 0; i < splitRes.chunks.length; i += step) {
|
||||
const { insertLen } = await postKbDataFromList({
|
||||
kbId,
|
||||
data: splitRes.chunks
|
||||
.slice(i, i + step)
|
||||
.map((item) => ({ q: item.value, a: '', source: item.filename })),
|
||||
prompt: `下面是"${prompt || '一段长文本'}"`,
|
||||
mode
|
||||
});
|
||||
|
||||
success += insertLen;
|
||||
setSplitRes((state) => ({
|
||||
...state,
|
||||
successChunks: state.successChunks + step
|
||||
}));
|
||||
}
|
||||
|
||||
toast({
|
||||
title: `去重后共导入 ${success} 条数据,需要一段拆解和训练.`,
|
||||
status: 'success'
|
||||
});
|
||||
onClose();
|
||||
onSuccess();
|
||||
},
|
||||
onError(err) {
|
||||
toast({
|
||||
title: getErrText(err, '导入文件失败'),
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const onclickImport = useCallback(async () => {
|
||||
setBtnLoading(true);
|
||||
try {
|
||||
const splitRes = files
|
||||
.map((item) =>
|
||||
splitText_token({
|
||||
text: item.text,
|
||||
...modeMap[mode]
|
||||
})
|
||||
)
|
||||
.map((item, i) => ({
|
||||
...item,
|
||||
filename: files[i].filename
|
||||
}))
|
||||
.filter((item) => item.tokens > 0);
|
||||
|
||||
let price = formatPrice(
|
||||
splitRes.reduce((sum, item) => sum + item.tokens, 0) * modeMap[mode].price
|
||||
);
|
||||
|
||||
if (mode === 'qa') {
|
||||
price *= 1.2;
|
||||
}
|
||||
|
||||
setSplitRes({
|
||||
price,
|
||||
chunks: splitRes
|
||||
.map((item) =>
|
||||
item.chunks.map((chunk) => ({
|
||||
filename: item.filename,
|
||||
value: chunk
|
||||
}))
|
||||
)
|
||||
.flat(),
|
||||
successChunks: 0
|
||||
});
|
||||
|
||||
openConfirm(mutate)();
|
||||
} catch (error) {
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: getErrText(error, '拆分文本异常')
|
||||
});
|
||||
}
|
||||
setBtnLoading(false);
|
||||
}, [files, mode, mutate, openConfirm, toast]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={true} onClose={onClose} isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent
|
||||
display={'flex'}
|
||||
maxW={'min(1000px, 90vw)'}
|
||||
m={0}
|
||||
position={'relative'}
|
||||
h={'90vh'}
|
||||
>
|
||||
<ModalHeader>文件导入</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
|
||||
<ModalBody
|
||||
flex={1}
|
||||
h={0}
|
||||
display={'flex'}
|
||||
flexDirection={'column'}
|
||||
p={0}
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
fontSize={'sm'}
|
||||
>
|
||||
<Box mt={2} px={5} maxW={['100%', '70%']} textAlign={'justify'} color={'blackAlpha.600'}>
|
||||
支持 {fileExtension} 文件。Gpt会自动对文本进行 QA 拆分,需要较长训练时间,拆分需要消耗
|
||||
tokens,账号余额不足时,未拆分的数据会被删除。一个{files.length}
|
||||
个文本。
|
||||
</Box>
|
||||
{/* 拆分模式 */}
|
||||
<Flex w={'100%'} px={5} alignItems={'center'} mt={4}>
|
||||
<Box flex={'0 0 70px'}>分段模式:</Box>
|
||||
<Radio
|
||||
ml={3}
|
||||
list={[
|
||||
{ label: '直接分段', value: 'index' },
|
||||
{ label: 'QA拆分', value: 'qa' }
|
||||
]}
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e as 'index' | 'qa')}
|
||||
/>
|
||||
</Flex>
|
||||
{/* 内容介绍 */}
|
||||
{modeMap[mode].isPrompt && (
|
||||
<Flex w={'100%'} px={5} alignItems={'center'} mt={4}>
|
||||
<Box flex={'0 0 70px'} mr={2}>
|
||||
下面是
|
||||
</Box>
|
||||
<Input
|
||||
placeholder="提示词,例如: Laf的介绍/关于gpt4的论文/一段长文本"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
size={'sm'}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
{/* 文本内容 */}
|
||||
<Box flex={'1 0 0'} px={5} h={0} w={'100%'} overflowY={'auto'} mt={4}>
|
||||
{files.slice(0, 100).map((item, i) => (
|
||||
<Box key={i} mb={5}>
|
||||
<Box mb={1}>{item.filename}</Box>
|
||||
<Textarea
|
||||
placeholder="文件内容,空内容会自动忽略"
|
||||
maxLength={-1}
|
||||
rows={10}
|
||||
fontSize={'xs'}
|
||||
whiteSpace={'pre-wrap'}
|
||||
value={item.text}
|
||||
onChange={(e) => {
|
||||
setFiles([
|
||||
...files.slice(0, i),
|
||||
{ ...item, text: e.target.value },
|
||||
...files.slice(i + 1)
|
||||
]);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (files.length > 1 && e.target.value === '') {
|
||||
setFiles((state) => [...state.slice(0, i), ...state.slice(i + 1)]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</ModalBody>
|
||||
|
||||
<Flex px={6} pt={2} pb={4}>
|
||||
<Button isLoading={btnLoading} isDisabled={uploading} onClick={onOpen}>
|
||||
选择文件
|
||||
</Button>
|
||||
<Box flex={1}></Box>
|
||||
<Button variant={'outline'} isLoading={uploading} mr={3} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={uploading || btnLoading || files[0]?.text === ''}
|
||||
onClick={onclickImport}
|
||||
>
|
||||
{uploading ? (
|
||||
<Box>{Math.round((splitRes.successChunks / splitRes.chunks.length) * 100)}%</Box>
|
||||
) : (
|
||||
'确认导入'
|
||||
)}
|
||||
</Button>
|
||||
</Flex>
|
||||
</ModalContent>
|
||||
<ConfirmChild />
|
||||
<File onSelect={onSelectFile} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectFileModal;
|
||||
43
client/src/pages/kb/index.tsx
Normal file
43
client/src/pages/kb/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import SideBar from '@/components/SideBar';
|
||||
import KbList from './components/KbList';
|
||||
import KbDetail from './components/Detail';
|
||||
|
||||
const Kb = ({ kbId }: { kbId: string }) => {
|
||||
const router = useRouter();
|
||||
const { isPc } = useGlobalStore();
|
||||
const { lastKbId } = useUserStore();
|
||||
|
||||
// redirect
|
||||
useEffect(() => {
|
||||
if (isPc && !kbId && lastKbId) {
|
||||
router.replace(`/kb?kbId=${lastKbId}`);
|
||||
}
|
||||
}, [isPc, kbId, lastKbId, router]);
|
||||
|
||||
return (
|
||||
<Flex h={'100%'} position={'relative'} overflow={'hidden'}>
|
||||
{/* 模型列表 */}
|
||||
{(isPc || !kbId) && (
|
||||
<SideBar w={['100%', '0 0 250px', '0 0 270px', '0 0 290px']}>
|
||||
<KbList kbId={kbId} />
|
||||
</SideBar>
|
||||
)}
|
||||
<Box flex={'1 0 0'} w={0} h={'100%'} position={'relative'}>
|
||||
{kbId && <KbDetail kbId={kbId} />}
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default Kb;
|
||||
|
||||
Kb.getInitialProps = ({ query, req }: any) => {
|
||||
return {
|
||||
kbId: query?.kbId || ''
|
||||
};
|
||||
};
|
||||
182
client/src/pages/login/components/ForgetPasswordForm.tsx
Normal file
182
client/src/pages/login/components/ForgetPasswordForm.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React, { useState, Dispatch, useCallback } from 'react';
|
||||
import { FormControl, Box, Input, Button, FormErrorMessage, Flex } from '@chakra-ui/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { PageTypeEnum } from '../../../constants/user';
|
||||
import { postFindPassword } from '@/api/user';
|
||||
import { useSendCode } from '@/hooks/useSendCode';
|
||||
import type { ResLogin } from '@/api/response/user';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
interface Props {
|
||||
setPageType: Dispatch<`${PageTypeEnum}`>;
|
||||
loginSuccess: (e: ResLogin) => void;
|
||||
}
|
||||
|
||||
interface RegisterType {
|
||||
username: string;
|
||||
code: string;
|
||||
password: string;
|
||||
password2: string;
|
||||
}
|
||||
|
||||
const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
|
||||
const { toast } = useToast();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
trigger,
|
||||
formState: { errors }
|
||||
} = useForm<RegisterType>({
|
||||
mode: 'onBlur'
|
||||
});
|
||||
|
||||
const { codeSending, sendCodeText, sendCode, codeCountDown } = useSendCode();
|
||||
|
||||
const onclickSendCode = useCallback(async () => {
|
||||
const check = await trigger('username');
|
||||
if (!check) return;
|
||||
sendCode({
|
||||
username: getValues('username'),
|
||||
type: 'findPassword'
|
||||
});
|
||||
}, [getValues, sendCode, trigger]);
|
||||
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
|
||||
const onclickFindPassword = useCallback(
|
||||
async ({ username, code, password }: RegisterType) => {
|
||||
setRequesting(true);
|
||||
try {
|
||||
loginSuccess(
|
||||
await postFindPassword({
|
||||
username,
|
||||
code,
|
||||
password
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: `密码已找回`,
|
||||
status: 'success'
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: error.message || '修改密码异常',
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
setRequesting(false);
|
||||
},
|
||||
[loginSuccess, toast]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box fontWeight={'bold'} fontSize={'2xl'} textAlign={'center'}>
|
||||
找回 FastGPT 账号
|
||||
</Box>
|
||||
<form onSubmit={handleSubmit(onclickFindPassword)}>
|
||||
<FormControl mt={5} isInvalid={!!errors.username}>
|
||||
<Input
|
||||
placeholder="邮箱/手机号"
|
||||
size={['md', 'lg']}
|
||||
{...register('username', {
|
||||
required: '邮箱/手机号不能为空',
|
||||
pattern: {
|
||||
value:
|
||||
/(^1[3456789]\d{9}$)|(^[A-Za-z0-9]+([_\.][A-Za-z0-9]+)*@([A-Za-z0-9\-]+\.)+[A-Za-z]{2,6}$)/,
|
||||
message: '邮箱/手机号格式错误'
|
||||
}
|
||||
})}
|
||||
></Input>
|
||||
<FormErrorMessage position={'absolute'} fontSize="xs">
|
||||
{!!errors.username && errors.username.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl mt={8} isInvalid={!!errors.username}>
|
||||
<Flex>
|
||||
<Input
|
||||
flex={1}
|
||||
placeholder="验证码"
|
||||
size={['md', 'lg']}
|
||||
{...register('code', {
|
||||
required: '验证码不能为空'
|
||||
})}
|
||||
></Input>
|
||||
<Button
|
||||
ml={5}
|
||||
w={'145px'}
|
||||
maxW={'50%'}
|
||||
size={['md', 'lg']}
|
||||
onClick={onclickSendCode}
|
||||
isDisabled={codeCountDown > 0}
|
||||
isLoading={codeSending}
|
||||
>
|
||||
{sendCodeText}
|
||||
</Button>
|
||||
</Flex>
|
||||
<FormErrorMessage position={'absolute'} fontSize="xs">
|
||||
{!!errors.code && errors.code.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl mt={8} isInvalid={!!errors.password}>
|
||||
<Input
|
||||
type={'password'}
|
||||
placeholder="新密码"
|
||||
size={['md', 'lg']}
|
||||
{...register('password', {
|
||||
required: '密码不能为空',
|
||||
minLength: {
|
||||
value: 4,
|
||||
message: '密码最少4位最多12位'
|
||||
},
|
||||
maxLength: {
|
||||
value: 12,
|
||||
message: '密码最少4位最多12位'
|
||||
}
|
||||
})}
|
||||
></Input>
|
||||
<FormErrorMessage position={'absolute'} fontSize="xs">
|
||||
{!!errors.password && errors.password.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl mt={8} isInvalid={!!errors.password2}>
|
||||
<Input
|
||||
type={'password'}
|
||||
placeholder="确认密码"
|
||||
size={['md', 'lg']}
|
||||
{...register('password2', {
|
||||
validate: (val) => (getValues('password') === val ? true : '两次密码不一致')
|
||||
})}
|
||||
></Input>
|
||||
<FormErrorMessage position={'absolute'} fontSize="xs">
|
||||
{!!errors.password2 && errors.password2.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<Box
|
||||
float={'right'}
|
||||
fontSize="sm"
|
||||
mt={2}
|
||||
color={'myBlue.600'}
|
||||
cursor={'pointer'}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
onClick={() => setPageType('login')}
|
||||
>
|
||||
去登录
|
||||
</Box>
|
||||
<Button
|
||||
type="submit"
|
||||
mt={5}
|
||||
w={'100%'}
|
||||
size={['md', 'lg']}
|
||||
colorScheme="blue"
|
||||
isLoading={requesting}
|
||||
>
|
||||
找回密码
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterForm;
|
||||
131
client/src/pages/login/components/LoginForm.tsx
Normal file
131
client/src/pages/login/components/LoginForm.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useState, Dispatch, useCallback } from 'react';
|
||||
import { FormControl, Flex, Input, Button, FormErrorMessage, Box } from '@chakra-ui/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { PageTypeEnum } from '@/constants/user';
|
||||
import { postLogin } from '@/api/user';
|
||||
import type { ResLogin } from '@/api/response/user';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
interface Props {
|
||||
setPageType: Dispatch<`${PageTypeEnum}`>;
|
||||
loginSuccess: (e: ResLogin) => void;
|
||||
}
|
||||
|
||||
interface LoginFormType {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const LoginForm = ({ setPageType, loginSuccess }: Props) => {
|
||||
const { toast } = useToast();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors }
|
||||
} = useForm<LoginFormType>();
|
||||
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
|
||||
const onclickLogin = useCallback(
|
||||
async ({ username, password }: LoginFormType) => {
|
||||
setRequesting(true);
|
||||
try {
|
||||
loginSuccess(
|
||||
await postLogin({
|
||||
username,
|
||||
password
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: '登录成功',
|
||||
status: 'success'
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: error.message || '登录异常',
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
setRequesting(false);
|
||||
},
|
||||
[loginSuccess, toast]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box fontWeight={'bold'} fontSize={'2xl'} textAlign={'center'}>
|
||||
登录 FastGPT
|
||||
</Box>
|
||||
<form onSubmit={handleSubmit(onclickLogin)}>
|
||||
<FormControl mt={8} isInvalid={!!errors.username}>
|
||||
<Input
|
||||
placeholder="邮箱/手机号"
|
||||
size={['md', 'lg']}
|
||||
{...register('username', {
|
||||
required: '邮箱/手机号不能为空',
|
||||
pattern: {
|
||||
value:
|
||||
/(^1[3456789]\d{9}$)|(^[A-Za-z0-9]+([_\.][A-Za-z0-9]+)*@([A-Za-z0-9\-]+\.)+[A-Za-z]{2,6}$)/,
|
||||
message: '邮箱/手机号格式错误'
|
||||
}
|
||||
})}
|
||||
></Input>
|
||||
<FormErrorMessage position={'absolute'} fontSize="xs">
|
||||
{!!errors.username && errors.username.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl mt={8} isInvalid={!!errors.password}>
|
||||
<Input
|
||||
type={'password'}
|
||||
size={['md', 'lg']}
|
||||
placeholder="密码"
|
||||
{...register('password', {
|
||||
required: '密码不能为空',
|
||||
minLength: {
|
||||
value: 4,
|
||||
message: '密码最少4位最多12位'
|
||||
},
|
||||
maxLength: {
|
||||
value: 12,
|
||||
message: '密码最少4位最多12位'
|
||||
}
|
||||
})}
|
||||
></Input>
|
||||
<FormErrorMessage position={'absolute'} fontSize="xs">
|
||||
{!!errors.password && errors.password.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<Flex align={'center'} justifyContent={'space-between'} mt={6} color={'myBlue.600'}>
|
||||
<Box
|
||||
cursor={'pointer'}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
onClick={() => setPageType('forgetPassword')}
|
||||
fontSize="sm"
|
||||
>
|
||||
忘记密码?
|
||||
</Box>
|
||||
<Box
|
||||
cursor={'pointer'}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
onClick={() => setPageType('register')}
|
||||
fontSize="sm"
|
||||
>
|
||||
注册账号
|
||||
</Box>
|
||||
</Flex>
|
||||
<Button
|
||||
type="submit"
|
||||
mt={8}
|
||||
w={'100%'}
|
||||
size={['md', 'lg']}
|
||||
colorScheme="blue"
|
||||
isLoading={requesting}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
190
client/src/pages/login/components/RegisterForm.tsx
Normal file
190
client/src/pages/login/components/RegisterForm.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useState, Dispatch, useCallback } from 'react';
|
||||
import { FormControl, Box, Input, Button, FormErrorMessage, Flex } from '@chakra-ui/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { PageTypeEnum } from '@/constants/user';
|
||||
import { postRegister } from '@/api/user';
|
||||
import { useSendCode } from '@/hooks/useSendCode';
|
||||
import type { ResLogin } from '@/api/response/user';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useRouter } from 'next/router';
|
||||
import { postCreateModel } from '@/api/model';
|
||||
|
||||
interface Props {
|
||||
loginSuccess: (e: ResLogin) => void;
|
||||
setPageType: Dispatch<`${PageTypeEnum}`>;
|
||||
}
|
||||
|
||||
interface RegisterType {
|
||||
username: string;
|
||||
password: string;
|
||||
password2: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
|
||||
const { inviterId = '' } = useRouter().query as { inviterId: string };
|
||||
const { toast } = useToast();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
trigger,
|
||||
formState: { errors }
|
||||
} = useForm<RegisterType>({
|
||||
mode: 'onBlur'
|
||||
});
|
||||
|
||||
const { codeSending, sendCodeText, sendCode, codeCountDown } = useSendCode();
|
||||
|
||||
const onclickSendCode = useCallback(async () => {
|
||||
const check = await trigger('username');
|
||||
if (!check) return;
|
||||
sendCode({
|
||||
username: getValues('username'),
|
||||
type: 'register'
|
||||
});
|
||||
}, [getValues, sendCode, trigger]);
|
||||
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
|
||||
const onclickRegister = useCallback(
|
||||
async ({ username, password, code }: RegisterType) => {
|
||||
setRequesting(true);
|
||||
try {
|
||||
loginSuccess(
|
||||
await postRegister({
|
||||
username,
|
||||
code,
|
||||
password,
|
||||
inviterId: inviterId || localStorage.getItem('inviterId') || ''
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: `注册成功`,
|
||||
status: 'success'
|
||||
});
|
||||
// aut register a model
|
||||
postCreateModel({
|
||||
name: '应用1'
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: error.message || '注册异常',
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
setRequesting(false);
|
||||
},
|
||||
[inviterId, loginSuccess, toast]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box fontWeight={'bold'} fontSize={'2xl'} textAlign={'center'}>
|
||||
注册 FastGPT 账号
|
||||
</Box>
|
||||
<form onSubmit={handleSubmit(onclickRegister)}>
|
||||
<FormControl mt={5} isInvalid={!!errors.username}>
|
||||
<Input
|
||||
placeholder="邮箱/手机号"
|
||||
size={['md', 'lg']}
|
||||
{...register('username', {
|
||||
required: '邮箱/手机号不能为空',
|
||||
pattern: {
|
||||
value:
|
||||
/(^1[3456789]\d{9}$)|(^[A-Za-z0-9]+([_\.][A-Za-z0-9]+)*@([A-Za-z0-9\-]+\.)+[A-Za-z]{2,6}$)/,
|
||||
message: '邮箱/手机号格式错误'
|
||||
}
|
||||
})}
|
||||
></Input>
|
||||
<FormErrorMessage position={'absolute'} fontSize="xs">
|
||||
{!!errors.username && errors.username.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl mt={8} isInvalid={!!errors.username}>
|
||||
<Flex>
|
||||
<Input
|
||||
flex={1}
|
||||
size={['md', 'lg']}
|
||||
placeholder="验证码"
|
||||
{...register('code', {
|
||||
required: '验证码不能为空'
|
||||
})}
|
||||
></Input>
|
||||
<Button
|
||||
ml={5}
|
||||
w={'145px'}
|
||||
maxW={'50%'}
|
||||
size={['md', 'lg']}
|
||||
onClick={onclickSendCode}
|
||||
isDisabled={codeCountDown > 0}
|
||||
isLoading={codeSending}
|
||||
>
|
||||
{sendCodeText}
|
||||
</Button>
|
||||
</Flex>
|
||||
<FormErrorMessage position={'absolute'} fontSize="xs">
|
||||
{!!errors.code && errors.code.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl mt={8} isInvalid={!!errors.password}>
|
||||
<Input
|
||||
type={'password'}
|
||||
placeholder="密码"
|
||||
size={['md', 'lg']}
|
||||
{...register('password', {
|
||||
required: '密码不能为空',
|
||||
minLength: {
|
||||
value: 4,
|
||||
message: '密码最少4位最多12位'
|
||||
},
|
||||
maxLength: {
|
||||
value: 12,
|
||||
message: '密码最少4位最多12位'
|
||||
}
|
||||
})}
|
||||
></Input>
|
||||
<FormErrorMessage position={'absolute'} fontSize="xs">
|
||||
{!!errors.password && errors.password.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl mt={8} isInvalid={!!errors.password2}>
|
||||
<Input
|
||||
type={'password'}
|
||||
placeholder="确认密码"
|
||||
size={['md', 'lg']}
|
||||
{...register('password2', {
|
||||
validate: (val) => (getValues('password') === val ? true : '两次密码不一致')
|
||||
})}
|
||||
></Input>
|
||||
<FormErrorMessage position={'absolute'} fontSize="xs">
|
||||
{!!errors.password2 && errors.password2.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<Box
|
||||
float={'right'}
|
||||
fontSize="sm"
|
||||
mt={2}
|
||||
color={'myBlue.600'}
|
||||
cursor={'pointer'}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
onClick={() => setPageType('login')}
|
||||
>
|
||||
已有账号,去登录
|
||||
</Box>
|
||||
<Button
|
||||
type="submit"
|
||||
mt={5}
|
||||
w={'100%'}
|
||||
size={['md', 'lg']}
|
||||
colorScheme="blue"
|
||||
isLoading={requesting}
|
||||
>
|
||||
确认注册
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterForm;
|
||||
5
client/src/pages/login/index.module.scss
Normal file
5
client/src/pages/login/index.module.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.loginPage {
|
||||
background: url('/icon/login-bg.svg') no-repeat;
|
||||
background-size: cover;
|
||||
user-select: none;
|
||||
}
|
||||
120
client/src/pages/login/index.tsx
Normal file
120
client/src/pages/login/index.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import styles from './index.module.scss';
|
||||
import { Box, Flex, Image } from '@chakra-ui/react';
|
||||
import { PageTypeEnum } from '@/constants/user';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import type { ResLogin } from '@/api/response/user';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import LoginForm from './components/LoginForm';
|
||||
import dynamic from 'next/dynamic';
|
||||
const RegisterForm = dynamic(() => import('./components/RegisterForm'));
|
||||
const ForgetPasswordForm = dynamic(() => import('./components/ForgetPasswordForm'));
|
||||
|
||||
const Login = () => {
|
||||
const router = useRouter();
|
||||
const { lastRoute = '' } = router.query as { lastRoute: string };
|
||||
const { isPc } = useGlobalStore();
|
||||
const [pageType, setPageType] = useState<`${PageTypeEnum}`>(PageTypeEnum.login);
|
||||
const { setUserInfo, setLastModelId, loadMyModels, loadKbList, setLastKbId } = useUserStore();
|
||||
const { setLastChatId, setLastChatModelId, loadHistory } = useChatStore();
|
||||
|
||||
const loginSuccess = useCallback(
|
||||
(res: ResLogin) => {
|
||||
// init store
|
||||
setLastChatId('');
|
||||
setLastModelId('');
|
||||
setLastChatModelId('');
|
||||
setLastKbId('');
|
||||
loadMyModels(true);
|
||||
loadKbList(true);
|
||||
loadHistory({ pageNum: 1, init: true });
|
||||
|
||||
setUserInfo(res.user);
|
||||
setTimeout(() => {
|
||||
router.push(lastRoute ? decodeURIComponent(lastRoute) : '/model');
|
||||
}, 100);
|
||||
},
|
||||
[
|
||||
lastRoute,
|
||||
loadHistory,
|
||||
loadKbList,
|
||||
loadMyModels,
|
||||
router,
|
||||
setLastChatId,
|
||||
setLastChatModelId,
|
||||
setLastKbId,
|
||||
setLastModelId,
|
||||
setUserInfo
|
||||
]
|
||||
);
|
||||
|
||||
function DynamicComponent({ type }: { type: `${PageTypeEnum}` }) {
|
||||
const TypeMap = {
|
||||
[PageTypeEnum.login]: LoginForm,
|
||||
[PageTypeEnum.register]: RegisterForm,
|
||||
[PageTypeEnum.forgetPassword]: ForgetPasswordForm
|
||||
};
|
||||
|
||||
const Component = TypeMap[type];
|
||||
|
||||
return <Component setPageType={setPageType} loginSuccess={loginSuccess} />;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
router.prefetch('/model');
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
className={styles.loginPage}
|
||||
h={'100%'}
|
||||
px={[0, '10vw']}
|
||||
>
|
||||
<Flex
|
||||
height="100%"
|
||||
w={'100%'}
|
||||
maxW={'1240px'}
|
||||
maxH={['auto', 'max(660px,80vh)']}
|
||||
backgroundColor={'#fff'}
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
py={[5, 10]}
|
||||
px={'5vw'}
|
||||
borderRadius={isPc ? 'md' : 'none'}
|
||||
gap={5}
|
||||
>
|
||||
{isPc && (
|
||||
<Image
|
||||
src={'/icon/loginLeft.svg'}
|
||||
order={pageType === PageTypeEnum.login ? 0 : 2}
|
||||
flex={'1 0 0'}
|
||||
w="0"
|
||||
maxW={'600px'}
|
||||
height={'100%'}
|
||||
maxH={'450px'}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box
|
||||
order={1}
|
||||
flex={`0 0 ${isPc ? '400px' : '100%'}`}
|
||||
height={'100%'}
|
||||
border="1px"
|
||||
borderColor="gray.200"
|
||||
py={5}
|
||||
px={10}
|
||||
borderRadius={isPc ? 'md' : 'none'}
|
||||
>
|
||||
<DynamicComponent type={pageType} />
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
168
client/src/pages/model/components/ModelList.tsx
Normal file
168
client/src/pages/model/components/ModelList.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Box, Flex, useTheme, Input, IconButton, Tooltip } 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';
|
||||
import Avatar from '@/components/Avatar';
|
||||
|
||||
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 { isFetching } = 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 !important'
|
||||
}
|
||||
: {})}
|
||||
onClick={() => {
|
||||
if (item._id === modelId) return;
|
||||
router.push(`/model?modelId=${item._id}`);
|
||||
}}
|
||||
>
|
||||
<Avatar src={item.avatar} w={'34px'} h={'34px'} />
|
||||
<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>
|
||||
))}
|
||||
|
||||
{!isFetching && 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={isFetching} fixed={false} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelList;
|
||||
@@ -0,0 +1,641 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Flex,
|
||||
FormControl,
|
||||
Input,
|
||||
Textarea,
|
||||
Slider,
|
||||
SliderTrack,
|
||||
SliderFilledTrack,
|
||||
SliderThumb,
|
||||
SliderMark,
|
||||
Tooltip,
|
||||
Button,
|
||||
Select,
|
||||
Switch,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Checkbox
|
||||
} from '@chakra-ui/react';
|
||||
import { QuestionOutlineIcon } from '@chakra-ui/icons';
|
||||
import { useForm, UseFormReturn } from 'react-hook-form';
|
||||
import { ChatModelMap, ModelVectorSearchModeMap, getChatModelList } from '@/constants/model';
|
||||
import { formatPrice } from '@/utils/user';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
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 type { ModelSchema } from '@/types/mongoSchema';
|
||||
import { formatTimeToChatTime, useCopyData, getErrText } from '@/utils/tools';
|
||||
import MyIcon from '@/components/Icon';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import Avatar from '@/components/Avatar';
|
||||
|
||||
const ModelEditForm = ({
|
||||
formHooks,
|
||||
isOwner,
|
||||
canRead,
|
||||
handleDelModel
|
||||
}: {
|
||||
formHooks: UseFormReturn<ModelSchema>;
|
||||
isOwner: boolean;
|
||||
canRead: boolean;
|
||||
handleDelModel: () => void;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { modelId } = router.query as { modelId: string };
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const { setLoading } = useGlobalStore();
|
||||
const { loadKbList } = useUserStore();
|
||||
|
||||
const { openConfirm, ConfirmChild } = useConfirm({
|
||||
content: '确认删除该应用?'
|
||||
});
|
||||
const { copyData } = useCopyData();
|
||||
const { register, setValue, getValues } = formHooks;
|
||||
const {
|
||||
register: registerShareChat,
|
||||
getValues: getShareChatValues,
|
||||
setValue: setShareChatValues,
|
||||
handleSubmit: submitShareChat,
|
||||
reset: resetShareChat
|
||||
} = useForm({
|
||||
defaultValues: defaultShareChat
|
||||
});
|
||||
const {
|
||||
isOpen: isOpenCreateShareChat,
|
||||
onOpen: onOpenCreateShareChat,
|
||||
onClose: onCloseCreateShareChat
|
||||
} = useDisclosure();
|
||||
const {
|
||||
isOpen: isOpenKbSelect,
|
||||
onOpen: onOpenKbSelect,
|
||||
onClose: onCloseKbSelect
|
||||
} = useDisclosure();
|
||||
const { File, onOpen: onOpenSelectFile } = useSelectFile({
|
||||
fileType: '.jpg,.png',
|
||||
multiple: false
|
||||
});
|
||||
|
||||
const onSelectFile = useCallback(
|
||||
async (e: File[]) => {
|
||||
const file = e[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const base64 = await compressImg({
|
||||
file,
|
||||
maxW: 100,
|
||||
maxH: 100
|
||||
});
|
||||
setValue('avatar', base64);
|
||||
setRefresh((state) => !state);
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: getErrText(err, '头像选择异常'),
|
||||
status: 'warning'
|
||||
});
|
||||
}
|
||||
},
|
||||
[setValue, toast]
|
||||
);
|
||||
|
||||
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 (err) {
|
||||
toast({
|
||||
title: getErrText(err, '创建分享链接异常'),
|
||||
status: 'warning'
|
||||
});
|
||||
console.log(err);
|
||||
}
|
||||
setLoading(false);
|
||||
},
|
||||
[
|
||||
copyData,
|
||||
getValues,
|
||||
modelId,
|
||||
onCloseCreateShareChat,
|
||||
refetchShareChatList,
|
||||
resetShareChat,
|
||||
setLoading,
|
||||
toast
|
||||
]
|
||||
);
|
||||
|
||||
// format share used token
|
||||
const formatTokens = (tokens: number) => {
|
||||
if (tokens < 10000) return tokens;
|
||||
return `${(tokens / 10000).toFixed(2)}万`;
|
||||
};
|
||||
|
||||
// init kb select list
|
||||
const { data: kbList = [] } = useQuery(['loadKbList'], () => loadKbList());
|
||||
const RenderSelectedKbList = useCallback(() => {
|
||||
const kbs = getValues('chat.relatedKbs').map((id) => kbList.find((kb) => kb._id === id));
|
||||
|
||||
return (
|
||||
<>
|
||||
{kbs.map((item) =>
|
||||
item ? (
|
||||
<Card
|
||||
key={item._id}
|
||||
p={3}
|
||||
mt={3}
|
||||
cursor={'pointer'}
|
||||
onClick={() => router.push(`/kb?kbId=${item._id}`)}
|
||||
>
|
||||
<Flex alignItems={'center'}>
|
||||
<Avatar src={item.avatar} w={'20px'} h={'20px'}></Avatar>
|
||||
<Box ml={3} fontWeight={'bold'}>
|
||||
{item.name}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Card>
|
||||
) : null
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}, [getValues, kbList, router]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* basic info */}
|
||||
<Card p={4}>
|
||||
<Box fontWeight={'bold'}>基本信息</Box>
|
||||
<Flex alignItems={'center'} mt={4}>
|
||||
<Box flex={'0 0 80px'} w={0}>
|
||||
modelId
|
||||
</Box>
|
||||
<Box userSelect={'all'}>{getValues('_id')}</Box>
|
||||
</Flex>
|
||||
<Flex mt={4} alignItems={'center'}>
|
||||
<Box flex={'0 0 80px'} w={0}>
|
||||
头像
|
||||
</Box>
|
||||
<Avatar
|
||||
src={getValues('avatar')}
|
||||
w={['28px', '36px']}
|
||||
h={['28px', '36px']}
|
||||
cursor={isOwner ? 'pointer' : 'default'}
|
||||
title={'点击切换头像'}
|
||||
onClick={() => isOwner && onOpenSelectFile()}
|
||||
/>
|
||||
</Flex>
|
||||
<FormControl mt={4}>
|
||||
<Flex alignItems={'center'}>
|
||||
<Box flex={'0 0 80px'} w={0}>
|
||||
名称
|
||||
</Box>
|
||||
<Input
|
||||
isDisabled={!isOwner}
|
||||
{...register('name', {
|
||||
required: '展示名称不能为空'
|
||||
})}
|
||||
></Input>
|
||||
</Flex>
|
||||
</FormControl>
|
||||
|
||||
<Flex alignItems={'center'} mt={5}>
|
||||
<Box flex={'0 0 80px'} w={0}>
|
||||
对话模型
|
||||
</Box>
|
||||
<Select
|
||||
isDisabled={!isOwner}
|
||||
{...register('chat.chatModel', {
|
||||
onChange() {
|
||||
setRefresh((state) => !state);
|
||||
}
|
||||
})}
|
||||
>
|
||||
{chatModelList.map((item) => (
|
||||
<option key={item.chatModel} value={item.chatModel}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} mt={5}>
|
||||
<Box flex={'0 0 80px'} w={0}>
|
||||
价格
|
||||
</Box>
|
||||
<Box>
|
||||
{formatPrice(ChatModelMap[getValues('chat.chatModel')]?.price, 1000)}
|
||||
元/1K tokens(包括上下文和回答)
|
||||
</Box>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} mt={5}>
|
||||
<Box flex={'0 0 80px'} w={0}>
|
||||
收藏人数:
|
||||
</Box>
|
||||
<Box>{getValues('share.collection')}人</Box>
|
||||
</Flex>
|
||||
{isOwner && (
|
||||
<Flex mt={5} alignItems={'center'}>
|
||||
<Box flex={'0 0 100px'}>删除应用</Box>
|
||||
<Button
|
||||
colorScheme={'gray'}
|
||||
variant={'outline'}
|
||||
size={'sm'}
|
||||
onClick={openConfirm(handleDelModel)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Card>
|
||||
{/* model effect */}
|
||||
{canRead && (
|
||||
<Card p={4}>
|
||||
<Box fontWeight={'bold'}>模型效果</Box>
|
||||
<FormControl mt={4}>
|
||||
<Flex alignItems={'center'}>
|
||||
<Box flex={'0 0 80px'} w={0}>
|
||||
<Box as={'span'} mr={2}>
|
||||
温度
|
||||
</Box>
|
||||
<Tooltip label={'温度越高,模型的发散能力越强;温度越低,内容越严谨。'}>
|
||||
<QuestionOutlineIcon />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Slider
|
||||
aria-label="slider-ex-1"
|
||||
min={0}
|
||||
max={10}
|
||||
step={1}
|
||||
value={getValues('chat.temperature')}
|
||||
isDisabled={!isOwner}
|
||||
onChange={(e) => {
|
||||
setValue('chat.temperature', e);
|
||||
setRefresh(!refresh);
|
||||
}}
|
||||
>
|
||||
<SliderMark
|
||||
value={getValues('chat.temperature')}
|
||||
textAlign="center"
|
||||
bg="myBlue.600"
|
||||
color="white"
|
||||
w={'18px'}
|
||||
h={'18px'}
|
||||
borderRadius={'100px'}
|
||||
fontSize={'xs'}
|
||||
transform={'translate(-50%, -200%)'}
|
||||
>
|
||||
{getValues('chat.temperature')}
|
||||
</SliderMark>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack bg={'myBlue.700'} />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</Flex>
|
||||
</FormControl>
|
||||
{getValues('chat.relatedKbs').length > 0 && (
|
||||
<Flex mt={4} alignItems={'center'}>
|
||||
<Box mr={4} whiteSpace={'nowrap'}>
|
||||
搜索模式 
|
||||
</Box>
|
||||
<Select
|
||||
isDisabled={!isOwner}
|
||||
{...register('chat.searchMode', {
|
||||
required: '搜索模式不能为空'
|
||||
})}
|
||||
>
|
||||
{Object.entries(ModelVectorSearchModeMap).map(([key, { text }]) => (
|
||||
<option key={key} value={key}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Box mt={4}>
|
||||
<Box mb={1}>系统提示词</Box>
|
||||
<Textarea
|
||||
rows={8}
|
||||
maxLength={-1}
|
||||
isDisabled={!isOwner}
|
||||
placeholder={
|
||||
'模型默认的 prompt 词,通过调整该内容,可以引导模型聊天方向。\n\n如果使用了知识库搜索,没有填写该内容时,系统会自动补充提示词;如果填写了内容,则以填写的内容为准。'
|
||||
}
|
||||
{...register('chat.systemPrompt')}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
{isOwner && (
|
||||
<>
|
||||
{/* model share setting */}
|
||||
<Card p={4}>
|
||||
<Box fontWeight={'bold'}>分享设置</Box>
|
||||
<Box>
|
||||
<Flex mt={5} alignItems={'center'}>
|
||||
<Box mr={1} fontSize={['sm', 'md']}>
|
||||
模型分享:
|
||||
</Box>
|
||||
<Tooltip label="开启模型分享后,你的模型将会出现在共享市场,可供 FastGpt 所有用户使用。用户使用时不会消耗你的 tokens,而是消耗使用者的 tokens。">
|
||||
<QuestionOutlineIcon mr={3} />
|
||||
</Tooltip>
|
||||
<Switch
|
||||
isChecked={getValues('share.isShare')}
|
||||
onChange={() => {
|
||||
setValue('share.isShare', !getValues('share.isShare'));
|
||||
setRefresh(!refresh);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box ml={12} mr={1} fontSize={['sm', 'md']}>
|
||||
分享模型细节:
|
||||
</Box>
|
||||
<Tooltip label="开启分享详情后,其他用户可以查看该模型的特有数据:温度、提示词和数据集。">
|
||||
<QuestionOutlineIcon mr={3} />
|
||||
</Tooltip>
|
||||
<Switch
|
||||
isChecked={getValues('share.isShareDetail')}
|
||||
onChange={() => {
|
||||
setValue('share.isShareDetail', !getValues('share.isShareDetail'));
|
||||
setRefresh(!refresh);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<Box mt={5}>
|
||||
<Box>模型介绍</Box>
|
||||
<Textarea
|
||||
mt={1}
|
||||
rows={6}
|
||||
maxLength={150}
|
||||
{...register('share.intro')}
|
||||
placeholder={'介绍模型的功能、场景等,吸引更多人来使用!最多150字。'}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
<Card p={4}>
|
||||
<Flex justifyContent={'space-between'}>
|
||||
<Box fontWeight={'bold'}>关联的知识库</Box>
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'outline'}
|
||||
colorScheme={'myBlue'}
|
||||
onClick={onOpenKbSelect}
|
||||
>
|
||||
选择
|
||||
</Button>
|
||||
</Flex>
|
||||
<RenderSelectedKbList />
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
{/* shareChat */}
|
||||
<Card p={4} gridColumnStart={1} gridColumnEnd={[2, 3]}>
|
||||
<Flex justifyContent={'space-between'}>
|
||||
<Box fontWeight={'bold'}>
|
||||
免登录聊天窗口
|
||||
<Tooltip label="可以直接分享该模型给其他用户去进行对话,对方无需登录即可直接进行对话。注意,这个功能会消耗你账号的tokens。请保管好链接和密码。">
|
||||
<QuestionOutlineIcon ml={1} />
|
||||
</Tooltip>
|
||||
(Beta)
|
||||
</Box>
|
||||
<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>
|
||||
{/* select kb modal */}
|
||||
<Modal isOpen={isOpenKbSelect} onClose={onCloseKbSelect}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>选择关联的知识库</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{kbList.map((item) => (
|
||||
<Card key={item._id} p={3} mb={3}>
|
||||
<Checkbox
|
||||
isChecked={getValues('chat.relatedKbs').includes(item._id)}
|
||||
onChange={(e) => {
|
||||
const ids = getValues('chat.relatedKbs');
|
||||
// toggle to true
|
||||
if (e.target.checked) {
|
||||
setValue('chat.relatedKbs', ids.concat(item._id));
|
||||
} else {
|
||||
const i = ids.findIndex((id) => id === item._id);
|
||||
ids.splice(i, 1);
|
||||
setValue('chat.relatedKbs', ids);
|
||||
}
|
||||
setRefresh(!refresh);
|
||||
}}
|
||||
>
|
||||
<Flex alignItems={'center'}>
|
||||
<Avatar src={item.avatar} w={'20px'} h={'20px'} />
|
||||
<Box ml={3} fontWeight={'bold'}>
|
||||
{item.name}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Checkbox>
|
||||
</Card>
|
||||
))}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onClick={onCloseKbSelect}>完成,记得点保存修改</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<File onSelect={onSelectFile} />
|
||||
<ConfirmChild />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelEditForm;
|
||||
217
client/src/pages/model/components/detail/index.tsx
Normal file
217
client/src/pages/model/components/detail/index.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React, { useCallback, useState, useMemo, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { delModelById, putModelById } from '@/api/model';
|
||||
import type { ModelSchema } from '@/types/mongoSchema';
|
||||
import { Card, Box, Flex, Button, Grid } from '@chakra-ui/react';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { useLoading } from '@/hooks/useLoading';
|
||||
import ModelEditForm from './components/ModelEditForm';
|
||||
|
||||
const ModelDetail = ({ modelId, isPc }: { modelId: string; isPc: boolean }) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const { userInfo, modelDetail, loadModelDetail, refreshModel, setLastModelId } = useUserStore();
|
||||
const { Loading, setIsLoading } = useLoading();
|
||||
const [btnLoading, setBtnLoading] = useState(false);
|
||||
|
||||
const formHooks = useForm({
|
||||
defaultValues: modelDetail
|
||||
});
|
||||
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
|
||||
const isOwner = useMemo(
|
||||
() => modelDetail.userId === userInfo?._id,
|
||||
[modelDetail.userId, userInfo?._id]
|
||||
);
|
||||
|
||||
const canRead = useMemo(
|
||||
() => isOwner || isLoading || modelDetail.share.isShareDetail,
|
||||
[isLoading, isOwner, modelDetail.share.isShareDetail]
|
||||
);
|
||||
|
||||
/* 点击删除 */
|
||||
const handleDelModel = useCallback(async () => {
|
||||
if (!modelDetail) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await delModelById(modelDetail._id);
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success'
|
||||
});
|
||||
refreshModel.removeModelDetail(modelDetail._id);
|
||||
router.replace('/model');
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: err?.message || '删除失败',
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [modelDetail, setIsLoading, toast, refreshModel, router]);
|
||||
|
||||
/* 点前往聊天预览页 */
|
||||
const handlePreviewChat = useCallback(async () => {
|
||||
router.push(`/chat?modelId=${modelId}`);
|
||||
}, [router, modelId]);
|
||||
|
||||
// 提交保存模型修改
|
||||
const saveSubmitSuccess = useCallback(
|
||||
async (data: ModelSchema) => {
|
||||
setBtnLoading(true);
|
||||
try {
|
||||
await putModelById(data._id, {
|
||||
name: data.name,
|
||||
avatar: data.avatar || '/icon/logo.png',
|
||||
chat: data.chat,
|
||||
share: data.share
|
||||
});
|
||||
|
||||
refreshModel.updateModelDetail(data);
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: err?.message || '更新失败',
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
setBtnLoading(false);
|
||||
},
|
||||
[refreshModel, toast]
|
||||
);
|
||||
// 提交保存表单失败
|
||||
const saveSubmitError = useCallback(() => {
|
||||
// deep search message
|
||||
const deepSearch = (obj: any): string => {
|
||||
if (!obj) return '提交表单错误';
|
||||
if (!!obj.message) {
|
||||
return obj.message;
|
||||
}
|
||||
return deepSearch(Object.values(obj)[0]);
|
||||
};
|
||||
toast({
|
||||
title: deepSearch(formHooks.formState.errors),
|
||||
status: 'error',
|
||||
duration: 4000,
|
||||
isClosable: true
|
||||
});
|
||||
}, [formHooks.formState.errors, toast]);
|
||||
|
||||
const saveUpdateModel = useCallback(
|
||||
() => formHooks.handleSubmit(saveSubmitSuccess, saveSubmitError)(),
|
||||
[formHooks, saveSubmitError, saveSubmitSuccess]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.onbeforeunload = (e) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = '内容已修改,确认离开页面吗?';
|
||||
};
|
||||
|
||||
return () => {
|
||||
window.onbeforeunload = null;
|
||||
};
|
||||
}, [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'}>
|
||||
{modelDetail.name}
|
||||
</Box>
|
||||
<Box flex={1} />
|
||||
<Button variant={'outline'} onClick={handlePreviewChat}>
|
||||
开始对话
|
||||
</Button>
|
||||
{isOwner && (
|
||||
<Button
|
||||
isLoading={btnLoading}
|
||||
ml={4}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await saveUpdateModel();
|
||||
toast({
|
||||
title: '更新成功',
|
||||
status: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
error;
|
||||
}
|
||||
}}
|
||||
>
|
||||
保存修改
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
<Flex alignItems={'center'}>
|
||||
<Box as={'h3'} fontSize={'xl'} fontWeight={'bold'} flex={1}>
|
||||
{modelDetail.name}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box mt={4} textAlign={'right'}>
|
||||
<Button variant={'outline'} size={'sm'} onClick={handlePreviewChat}>
|
||||
开始对话
|
||||
</Button>
|
||||
{isOwner && (
|
||||
<Button
|
||||
ml={4}
|
||||
size={'sm'}
|
||||
isLoading={btnLoading}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await saveUpdateModel();
|
||||
toast({
|
||||
title: '更新成功',
|
||||
status: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
error;
|
||||
}
|
||||
}}
|
||||
>
|
||||
保存修改
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<Grid mt={5} gridTemplateColumns={['1fr', '1fr 1fr']} gridGap={5}>
|
||||
<ModelEditForm
|
||||
formHooks={formHooks}
|
||||
handleDelModel={handleDelModel}
|
||||
isOwner={isOwner}
|
||||
canRead={canRead}
|
||||
/>
|
||||
</Grid>
|
||||
<Loading loading={isLoading} fixed={false} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelDetail;
|
||||
49
client/src/pages/model/index.tsx
Normal file
49
client/src/pages/model/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import { useRouter } from 'next/router';
|
||||
import ModelList from './components/ModelList';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import Loading from '@/components/Loading';
|
||||
import SideBar from '@/components/SideBar';
|
||||
|
||||
const ModelDetail = dynamic(() => import('./components/detail/index'), {
|
||||
loading: () => <Loading fixed={false} />,
|
||||
ssr: false
|
||||
});
|
||||
|
||||
const Model = ({ modelId }: { modelId: string }) => {
|
||||
const router = useRouter();
|
||||
const { isPc } = useGlobalStore();
|
||||
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'} overflow={'hidden'}>
|
||||
{/* 模型列表 */}
|
||||
{(isPc || !modelId) && (
|
||||
<SideBar w={['100%', '0 0 250px', '0 0 270px', '0 0 290px']}>
|
||||
<ModelList modelId={modelId} />
|
||||
</SideBar>
|
||||
)}
|
||||
<Box flex={1} h={'100%'} position={'relative'}>
|
||||
{modelId && <ModelDetail modelId={modelId} isPc={isPc} />}
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default Model;
|
||||
|
||||
Model.getInitialProps = ({ query, req }: any) => {
|
||||
return {
|
||||
modelId: query?.modelId || ''
|
||||
};
|
||||
};
|
||||
85
client/src/pages/model/share/components/list.tsx
Normal file
85
client/src/pages/model/share/components/list.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { Box, Flex, Button, Tooltip } from '@chakra-ui/react';
|
||||
import type { ShareModelItem } from '@/types/model';
|
||||
import { useRouter } from 'next/router';
|
||||
import MyIcon from '@/components/Icon';
|
||||
import styles from '../index.module.scss';
|
||||
import Avatar from '@/components/Avatar';
|
||||
|
||||
const ShareModelList = ({
|
||||
models = [],
|
||||
onclickCollection
|
||||
}: {
|
||||
models: ShareModelItem[];
|
||||
onclickCollection: (modelId: string) => void;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
{models.map((model) => (
|
||||
<Flex
|
||||
w={'100%'}
|
||||
flexDirection={'column'}
|
||||
key={model._id}
|
||||
p={4}
|
||||
border={'1px solid'}
|
||||
borderColor={'gray.200'}
|
||||
borderRadius={'md'}
|
||||
>
|
||||
<Flex alignItems={'center'}>
|
||||
<Avatar
|
||||
src={model.avatar}
|
||||
w={['28px', '36px']}
|
||||
h={['28px', '36px']}
|
||||
borderRadius={'50%'}
|
||||
/>
|
||||
<Box fontWeight={'bold'} fontSize={'lg'} ml={5}>
|
||||
{model.name}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Tooltip label={model.share.intro}>
|
||||
<Box
|
||||
className={styles.intro}
|
||||
flex={1}
|
||||
my={4}
|
||||
fontSize={'sm'}
|
||||
wordBreak={'break-all'}
|
||||
color={'blackAlpha.600'}
|
||||
>
|
||||
{model.share.intro || '这个 应用 还没有介绍~'}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Flex justifyContent={'space-between'}>
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
cursor={'pointer'}
|
||||
color={model.isCollection ? 'myBlue.700' : 'blackAlpha.700'}
|
||||
onClick={() => onclickCollection(model._id)}
|
||||
>
|
||||
<MyIcon
|
||||
mr={1}
|
||||
name={model.isCollection ? 'collectionSolid' : 'collectionLight'}
|
||||
w={'16px'}
|
||||
/>
|
||||
{model.share.collection}
|
||||
</Flex>
|
||||
<Box>
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'outline'}
|
||||
w={['60px', '70px']}
|
||||
onClick={() => router.push(`/chat?modelId=${model._id}`)}
|
||||
>
|
||||
体验
|
||||
</Button>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareModelList;
|
||||
7
client/src/pages/model/share/index.module.scss
Normal file
7
client/src/pages/model/share/index.module.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.intro {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
96
client/src/pages/model/share/index.tsx
Normal file
96
client/src/pages/model/share/index.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { Box, Flex, Card, Grid, Input } from '@chakra-ui/react';
|
||||
import { useLoading } from '@/hooks/useLoading';
|
||||
import { getShareModelList, triggerModelCollection } from '@/api/model';
|
||||
import { usePagination } from '@/hooks/usePagination';
|
||||
import type { ShareModelItem } from '@/types/model';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import ShareModelList from './components/list';
|
||||
|
||||
const modelList = () => {
|
||||
const { Loading } = useLoading();
|
||||
const lastSearch = useRef('');
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const { refreshModel } = useUserStore();
|
||||
|
||||
/* 加载模型 */
|
||||
const {
|
||||
data: models,
|
||||
isLoading,
|
||||
Pagination,
|
||||
getData,
|
||||
pageNum
|
||||
} = usePagination<ShareModelItem>({
|
||||
api: getShareModelList,
|
||||
pageSize: 24,
|
||||
params: {
|
||||
searchText
|
||||
}
|
||||
});
|
||||
|
||||
const onclickCollection = useCallback(
|
||||
async (modelId: string) => {
|
||||
try {
|
||||
await triggerModelCollection(modelId);
|
||||
getData(pageNum);
|
||||
refreshModel.removeModelDetail(modelId);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
},
|
||||
[getData, pageNum, refreshModel]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box py={[5, 10]} px={'5vw'}>
|
||||
<Card px={6} py={3}>
|
||||
<Box display={['block', 'flex']} alignItems={'center'} justifyContent={'space-between'}>
|
||||
<Box fontWeight={'bold'} flex={1} fontSize={'xl'}>
|
||||
应用市场
|
||||
</Box>
|
||||
<Box mt={[2, 0]} textAlign={'right'}>
|
||||
<Input
|
||||
w={['200px', '250px']}
|
||||
size={'sm'}
|
||||
value={searchText}
|
||||
placeholder="搜索应用,回车确认"
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (searchText === lastSearch.current) return;
|
||||
getData(1);
|
||||
lastSearch.current = searchText;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (searchText === lastSearch.current) return;
|
||||
if (e.key === 'Enter') {
|
||||
getData(1);
|
||||
lastSearch.current = searchText;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Grid
|
||||
templateColumns={[
|
||||
'repeat(1,1fr)',
|
||||
'repeat(2,1fr)',
|
||||
'repeat(3,1fr)',
|
||||
'repeat(4,1fr)',
|
||||
'repeat(5,1fr)'
|
||||
]}
|
||||
gridGap={4}
|
||||
mt={4}
|
||||
>
|
||||
<ShareModelList models={models} onclickCollection={onclickCollection} />
|
||||
</Grid>
|
||||
<Flex mt={4} justifyContent={'flex-end'}>
|
||||
<Pagination />
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
<Loading loading={isLoading} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default modelList;
|
||||
72
client/src/pages/number/components/BillTable.tsx
Normal file
72
client/src/pages/number/components/BillTable.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Table, Thead, Tbody, Tr, Th, Td, TableContainer, Flex, Box } from '@chakra-ui/react';
|
||||
import { BillTypeMap } from '@/constants/user';
|
||||
import { getUserBills } from '@/api/user';
|
||||
import type { UserBillType } from '@/types/user';
|
||||
import { usePagination } from '@/hooks/usePagination';
|
||||
import { useLoading } from '@/hooks/useLoading';
|
||||
import dayjs from 'dayjs';
|
||||
import MyIcon from '@/components/Icon';
|
||||
|
||||
const BillTable = () => {
|
||||
const { Loading } = useLoading();
|
||||
|
||||
const {
|
||||
data: bills,
|
||||
isLoading,
|
||||
Pagination,
|
||||
pageSize,
|
||||
total
|
||||
} = usePagination<UserBillType>({
|
||||
api: getUserBills
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer position={'relative'} minH={'200px'}>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>时间</Th>
|
||||
<Th>类型</Th>
|
||||
<Th>底层模型</Th>
|
||||
<Th>内容长度</Th>
|
||||
<Th>Tokens 长度</Th>
|
||||
<Th>金额</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody fontSize={'sm'}>
|
||||
{bills.map((item) => (
|
||||
<Tr key={item.id}>
|
||||
<Td>{dayjs(item.time).format('YYYY/MM/DD HH:mm:ss')}</Td>
|
||||
<Td>{BillTypeMap[item.type] || '-'}</Td>
|
||||
<Td>{item.modelName}</Td>
|
||||
<Td>{item.textLen}</Td>
|
||||
<Td>{item.tokenLen}</Td>
|
||||
<Td>{item.price}元</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
|
||||
<Loading loading={isLoading} fixed={false} />
|
||||
</TableContainer>
|
||||
|
||||
{!isLoading && bills.length === 0 && (
|
||||
<Flex h={'100%'} flexDirection={'column'} alignItems={'center'} pt={'200px'}>
|
||||
<MyIcon name="empty" w={'48px'} h={'48px'} color={'transparent'} />
|
||||
<Box mt={2} color={'myGray.500'}>
|
||||
无使用记录~
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
{total > pageSize && (
|
||||
<Flex w={'100%'} mt={4} justifyContent={'flex-end'}>
|
||||
<Pagination />
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BillTable;
|
||||
91
client/src/pages/number/components/InformTable.tsx
Normal file
91
client/src/pages/number/components/InformTable.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionButton,
|
||||
AccordionPanel,
|
||||
AccordionIcon
|
||||
} from '@chakra-ui/react';
|
||||
import { getInforms, readInform } from '@/api/user';
|
||||
import { usePagination } from '@/hooks/usePagination';
|
||||
import { useLoading } from '@/hooks/useLoading';
|
||||
import type { informSchema } from '@/types/mongoSchema';
|
||||
import { formatTimeToChatTime } from '@/utils/tools';
|
||||
import MyIcon from '@/components/Icon';
|
||||
|
||||
const BillTable = () => {
|
||||
const { Loading } = useLoading();
|
||||
|
||||
const {
|
||||
data: informs,
|
||||
isLoading,
|
||||
total,
|
||||
pageSize,
|
||||
Pagination,
|
||||
getData,
|
||||
pageNum
|
||||
} = usePagination<informSchema>({
|
||||
api: getInforms
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Accordion defaultIndex={[0, 1, 2]} allowMultiple>
|
||||
{informs.map((item) => (
|
||||
<AccordionItem
|
||||
key={item._id}
|
||||
onClick={async () => {
|
||||
if (!item.read) {
|
||||
await readInform(item._id);
|
||||
getData(pageNum);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AccordionButton>
|
||||
<Flex alignItems={'center'} flex="1" textAlign="left">
|
||||
<Box fontWeight={'bold'} position={'relative'}>
|
||||
{!item.read && (
|
||||
<Box
|
||||
w={'5px'}
|
||||
h={'5px'}
|
||||
borderRadius={'10px'}
|
||||
bg={'myRead.600'}
|
||||
position={'absolute'}
|
||||
top={1}
|
||||
left={'-5px'}
|
||||
></Box>
|
||||
)}
|
||||
{item.title}
|
||||
</Box>
|
||||
<Box ml={2} color={'myGray.500'}>
|
||||
{formatTimeToChatTime(item.time)}
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4}>{item.content}</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
{!isLoading && informs.length === 0 && (
|
||||
<Flex h={'100%'} flexDirection={'column'} alignItems={'center'} pt={'200px'}>
|
||||
<MyIcon name="empty" w={'48px'} h={'48px'} color={'transparent'} />
|
||||
<Box mt={2} color={'myGray.500'}>
|
||||
暂无通知~
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
{total > pageSize && (
|
||||
<Flex w={'100%'} mt={4} justifyContent={'flex-end'}>
|
||||
<Pagination />
|
||||
</Flex>
|
||||
)}
|
||||
<Loading loading={isLoading && informs.length === 0} fixed={false} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BillTable;
|
||||
153
client/src/pages/number/components/PayModal.tsx
Normal file
153
client/src/pages/number/components/PayModal.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Button,
|
||||
Input,
|
||||
Box,
|
||||
Grid
|
||||
} from '@chakra-ui/react';
|
||||
import { getPayCode, checkPayResult } from '@/api/user';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getErrText } from '@/utils/tools';
|
||||
import Markdown from '@/components/Markdown';
|
||||
|
||||
const PayModal = ({ onClose }: { onClose: () => void }) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [inputVal, setInputVal] = useState<number | ''>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [payId, setPayId] = useState('');
|
||||
|
||||
const handleClickPay = useCallback(async () => {
|
||||
if (!inputVal || inputVal <= 0 || isNaN(+inputVal)) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
// 获取支付二维码
|
||||
const res = await getPayCode(inputVal);
|
||||
new QRCode(document.getElementById('payQRCode'), {
|
||||
text: res.codeUrl,
|
||||
width: 128,
|
||||
height: 128,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.H
|
||||
});
|
||||
setPayId(res.payId);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: getErrText(err),
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
}, [inputVal, toast]);
|
||||
|
||||
useQuery(
|
||||
[payId],
|
||||
() => {
|
||||
if (!payId) return null;
|
||||
return checkPayResult(payId);
|
||||
},
|
||||
{
|
||||
enabled: !!payId,
|
||||
refetchInterval: 2000,
|
||||
onSuccess(res) {
|
||||
if (!res) return;
|
||||
toast({
|
||||
title: '充值成功',
|
||||
status: 'success'
|
||||
});
|
||||
router.reload();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={true}
|
||||
onClose={() => {
|
||||
if (payId) return;
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent minW={'auto'}>
|
||||
<ModalHeader>充值</ModalHeader>
|
||||
{!payId && <ModalCloseButton />}
|
||||
|
||||
<ModalBody py={0}>
|
||||
{!payId && (
|
||||
<>
|
||||
<Grid gridTemplateColumns={'repeat(4,1fr)'} gridGap={5} mb={4}>
|
||||
{[10, 20, 50, 100].map((item) => (
|
||||
<Button
|
||||
key={item}
|
||||
variant={item === inputVal ? 'solid' : 'outline'}
|
||||
onClick={() => setInputVal(item)}
|
||||
>
|
||||
{item}元
|
||||
</Button>
|
||||
))}
|
||||
</Grid>
|
||||
<Box mb={4}>
|
||||
<Input
|
||||
value={inputVal}
|
||||
type={'number'}
|
||||
step={1}
|
||||
placeholder={'其他金额,请取整数'}
|
||||
onChange={(e) => {
|
||||
setInputVal(Math.floor(+e.target.value));
|
||||
}}
|
||||
></Input>
|
||||
</Box>
|
||||
<Markdown
|
||||
source={`
|
||||
| 计费项 | 价格: 元/ 1K tokens(包含上下文)|
|
||||
| --- | --- |
|
||||
| 知识库 - 索引 | 0.001 |
|
||||
| chatgpt - 对话 | 0.025 |
|
||||
| gpt4 - 对话 | 0.5 |
|
||||
| 文件拆分 | 0.025 |`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* 付费二维码 */}
|
||||
<Box textAlign={'center'}>
|
||||
{payId && <Box mb={3}>请微信扫码支付: {inputVal}元,请勿关闭页面</Box>}
|
||||
<Box id={'payQRCode'} display={'inline-block'}></Box>
|
||||
</Box>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{!payId && (
|
||||
<>
|
||||
<Button variant={'outline'} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
ml={3}
|
||||
isLoading={loading}
|
||||
isDisabled={!inputVal || inputVal === 0}
|
||||
onClick={handleClickPay}
|
||||
>
|
||||
获取充值二维码
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PayModal;
|
||||
107
client/src/pages/number/components/PayRecordTable.tsx
Normal file
107
client/src/pages/number/components/PayRecordTable.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Flex,
|
||||
Box
|
||||
} from '@chakra-ui/react';
|
||||
import { getPayOrders, checkPayResult } from '@/api/user';
|
||||
import { PaySchema } from '@/types/mongoSchema';
|
||||
import dayjs from 'dayjs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { formatPrice } from '@/utils/user';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useLoading } from '@/hooks/useLoading';
|
||||
import MyIcon from '@/components/Icon';
|
||||
|
||||
const PayRecordTable = () => {
|
||||
const { Loading, setIsLoading } = useLoading();
|
||||
const [payOrders, setPayOrders] = useState<PaySchema[]>([]);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleRefreshPayOrder = useCallback(
|
||||
async (payId: string) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const data = await checkPayResult(payId);
|
||||
toast({
|
||||
title: data,
|
||||
status: 'info'
|
||||
});
|
||||
const res = await getPayOrders();
|
||||
setPayOrders(res);
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: error?.message,
|
||||
status: 'warning'
|
||||
});
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
},
|
||||
[setIsLoading, toast]
|
||||
);
|
||||
|
||||
const { isInitialLoading } = useQuery(['initPayOrder'], getPayOrders, {
|
||||
onSuccess(res) {
|
||||
setPayOrders(res);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>订单号</Th>
|
||||
<Th>时间</Th>
|
||||
<Th>金额</Th>
|
||||
<Th>状态</Th>
|
||||
<Th></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody fontSize={'sm'}>
|
||||
{payOrders.map((item) => (
|
||||
<Tr key={item._id}>
|
||||
<Td>{item.orderId}</Td>
|
||||
<Td>
|
||||
{item.createTime ? dayjs(item.createTime).format('YYYY/MM/DD HH:mm:ss') : '-'}
|
||||
</Td>
|
||||
<Td>{formatPrice(item.price)}元</Td>
|
||||
<Td>{item.status}</Td>
|
||||
<Td>
|
||||
{item.status === 'NOTPAY' && (
|
||||
<Button onClick={() => handleRefreshPayOrder(item._id)} size={'sm'}>
|
||||
更新
|
||||
</Button>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{!isInitialLoading && payOrders.length === 0 && (
|
||||
<Flex h={'100%'} flexDirection={'column'} alignItems={'center'} pt={'200px'}>
|
||||
<MyIcon name="empty" w={'48px'} h={'48px'} color={'transparent'} />
|
||||
<Box mt={2} color={'myGray.500'}>
|
||||
无支付记录~
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
<Loading loading={isInitialLoading} fixed={false} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PayRecordTable;
|
||||
68
client/src/pages/number/components/PromotionTable.tsx
Normal file
68
client/src/pages/number/components/PromotionTable.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { Flex, Table, Thead, Tbody, Tr, Th, Td, TableContainer, Box } from '@chakra-ui/react';
|
||||
import { useLoading } from '@/hooks/useLoading';
|
||||
import dayjs from 'dayjs';
|
||||
import { getPromotionRecords } from '@/api/user';
|
||||
import { usePagination } from '@/hooks/usePagination';
|
||||
import { PromotionRecordType } from '@/api/response/user';
|
||||
import { PromotionTypeMap } from '@/constants/user';
|
||||
import MyIcon from '@/components/Icon';
|
||||
|
||||
const OpenApi = () => {
|
||||
const { Loading } = useLoading();
|
||||
|
||||
const {
|
||||
data: promotionRecords,
|
||||
isLoading,
|
||||
total,
|
||||
pageSize,
|
||||
Pagination
|
||||
} = usePagination<PromotionRecordType>({
|
||||
api: getPromotionRecords
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer position={'relative'} overflow={'hidden'} minH={'200px'}>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>时间</Th>
|
||||
<Th>类型</Th>
|
||||
<Th>金额</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody fontSize={'sm'}>
|
||||
{promotionRecords.map((item) => (
|
||||
<Tr key={item._id}>
|
||||
<Td>
|
||||
{item.createTime ? dayjs(item.createTime).format('YYYY/MM/DD HH:mm:ss') : '-'}
|
||||
</Td>
|
||||
<Td>{PromotionTypeMap[item.type]}</Td>
|
||||
<Td>{item.amount}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
|
||||
<Loading loading={isLoading} fixed={false} />
|
||||
</TableContainer>
|
||||
|
||||
{!isLoading && promotionRecords.length === 0 && (
|
||||
<Flex flexDirection={'column'} alignItems={'center'}>
|
||||
<MyIcon name="empty" w={'48px'} h={'48px'} color={'transparent'} />
|
||||
<Box mt={2} color={'myGray.500'}>
|
||||
无佣金记录~
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
{total > pageSize && (
|
||||
<Flex mt={4} justifyContent={'flex-end'}>
|
||||
<Pagination />
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenApi;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user