chat logs filter & export (#3737)

* chat logs filter & export

* export chat detail
This commit is contained in:
heheer
2025-02-11 16:32:47 +08:00
committed by GitHub
parent 8738c32fb0
commit f002896a24
8 changed files with 505 additions and 153 deletions

View File

@@ -1,7 +1,13 @@
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants';
import { PaginationProps } from '@fastgpt/web/common/fetch/type';
export type GetAppChatLogsParams = PaginationProps<{
export type GetAppChatLogsProps = {
appId: string;
dateStart: Date;
dateEnd: Date;
}>;
sources?: ChatSourceEnum[];
logTitle?: string;
};
export type GetAppChatLogsParams = PaginationProps<GetAppChatLogsProps>;

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import {
Flex,
Box,
@@ -9,17 +9,15 @@ import {
Th,
Td,
Tbody,
useDisclosure,
ModalBody,
HStack
HStack,
Button
} from '@chakra-ui/react';
import UserBox from '@fastgpt/web/components/common/UserBox';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useTranslation } from 'next-i18next';
import { getAppChatLogs } from '@/web/core/app/api';
import dayjs from 'dayjs';
import { ChatSourceMap } from '@fastgpt/global/core/chat/constants';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { ChatSourceEnum, ChatSourceMap } from '@fastgpt/global/core/chat/constants';
import { addDays } from 'date-fns';
import { usePagination } from '@fastgpt/web/hooks/usePagination';
import DateRangePicker, { DateRangeType } from '@fastgpt/web/components/common/DateRangePicker';
@@ -29,13 +27,19 @@ import { AppContext } from '../context';
import { cardStyles } from '../constants';
import dynamic from 'next/dynamic';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import MultipleSelect, {
useMultipleSelect
} from '@fastgpt/web/components/common/MySelect/MultipleSelect';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import PopoverConfirm from '@fastgpt/web/components/common/MyPopover/PopoverConfirm';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { downloadFetch } from '@/web/common/system/utils';
const DetailLogsModal = dynamic(() => import('./DetailLogsModal'));
const Logs = () => {
const { t } = useTranslation();
const { isPc } = useSystem();
const appId = useContextSelector(AppContext, (v) => v.appId);
@@ -43,165 +47,236 @@ const Logs = () => {
from: addDays(new Date(), -7),
to: new Date()
});
const [detailLogsId, setDetailLogsId] = useState<string>();
const [logTitle, setLogTitle] = useState<string>();
const {
isOpen: isOpenMarkDesc,
onOpen: onOpenMarkDesc,
onClose: onCloseMarkDesc
} = useDisclosure();
value: chatSources,
setValue: setChatSources,
isSelectAll: isSelectAllSource,
setIsSelectAll: setIsSelectAllSource
} = useMultipleSelect<ChatSourceEnum>(Object.values(ChatSourceEnum), true);
const sourceList = useMemo(
() =>
Object.entries(ChatSourceMap).map(([key, value]) => ({
label: t(value.name as any),
value: key as ChatSourceEnum
})),
[t]
);
const {
data: logs,
isLoading,
Pagination,
getData,
pageNum
pageNum,
total
} = usePagination(getAppChatLogs, {
pageSize: 20,
params: {
appId,
dateStart: dateRange.from || new Date(),
dateEnd: addDays(dateRange.to || new Date(), 1)
}
dateEnd: addDays(dateRange.to || new Date(), 1),
sources: isSelectAllSource ? undefined : chatSources,
logTitle
},
refreshDeps: [chatSources, logTitle]
});
const [detailLogsId, setDetailLogsId] = useState<string>();
const { runAsync: exportLogs } = useRequest2(
async () => {
await downloadFetch({
url: '/api/core/app/exportChatLogs',
filename: 'chat_logs.csv',
body: {
appId,
dateStart: dateRange.from || new Date(),
dateEnd: addDays(dateRange.to || new Date(), 1),
sources: isSelectAllSource ? undefined : chatSources,
logTitle,
title: t('app:logs_export_title'),
sourcesMap: Object.fromEntries(
Object.entries(ChatSourceMap).map(([key, config]) => [
key,
{
label: t(config.name as any)
}
])
)
}
});
},
{
refreshDeps: [chatSources, logTitle]
}
);
return (
<Flex flexDirection={'column'} h={'100%'}>
{isPc && (
<Box {...cardStyles} boxShadow={2} px={[4, 8]} py={[4, 6]}>
<Box fontWeight={'bold'} fontSize={['md', 'lg']} mb={2}>
{t('app:chat_logs')}
<Flex
flexDirection={'column'}
h={'100%'}
{...cardStyles}
boxShadow={3.5}
px={[4, 8]}
py={[4, 6]}
flex={'1 0 0'}
>
<Flex flexDir={['column', 'row']} alignItems={['flex-start', 'center']} gap={3}>
<Flex alignItems={'center'} gap={2}>
<Box fontSize={'mini'} fontWeight={'medium'} color={'myGray.900'}>
{t('app:logs_source')}
</Box>
<Box color={'myGray.500'} fontSize={'sm'}>
{t('app:chat_logs_tips')},{' '}
<Box
as={'span'}
mr={2}
textDecoration={'underline'}
cursor={'pointer'}
onClick={onOpenMarkDesc}
>
{t('common:core.chat.Read Mark Description')}
</Box>
<Box>
<MultipleSelect<ChatSourceEnum>
list={sourceList}
value={chatSources}
onSelect={setChatSources}
isSelectAll={isSelectAllSource}
setIsSelectAll={setIsSelectAllSource}
itemWrap={false}
height={'32px'}
bg={'myGray.50'}
w={'160px'}
/>
</Box>
</Flex>
<Flex alignItems={'center'} gap={2}>
<Box fontSize={'mini'} fontWeight={'medium'} color={'myGray.900'}>
{t('common:user.Time')}
</Box>
</Box>
)}
{/* table */}
<Flex
flexDirection={'column'}
{...cardStyles}
boxShadow={3.5}
mt={[0, 4]}
px={[4, 8]}
py={[4, 6]}
flex={'1 0 0'}
>
<TableContainer mt={[0, 3]} flex={'1 0 0'} h={0} overflowY={'auto'}>
<Table variant={'simple'} fontSize={'sm'}>
<Thead>
<Tr>
<Th>{t('common:core.app.logs.Source And Time')}</Th>
<Th>{t('app:logs_chat_user')}</Th>
<Th>{t('app:logs_title')}</Th>
<Th>{t('app:logs_message_total')}</Th>
<Th>{t('app:feedback_count')}</Th>
<Th>{t('common:core.app.feedback.Custom feedback')}</Th>
<Th>{t('app:mark_count')}</Th>
</Tr>
</Thead>
<Tbody fontSize={'xs'}>
{logs.map((item) => (
<Tr
key={item._id}
_hover={{ bg: 'myWhite.600' }}
cursor={'pointer'}
title={t('common:core.view_chat_detail')}
onClick={() => setDetailLogsId(item.id)}
>
<Td>
{/* @ts-ignore */}
<Box>{t(ChatSourceMap[item.source]?.name) || item.source}</Box>
<Box color={'myGray.500'}>{dayjs(item.time).format('YYYY/MM/DD HH:mm')}</Box>
</Td>
<Td>
<Box>
{!!item.outLinkUid ? (
item.outLinkUid
) : (
<UserBox sourceMember={item.sourceMember} />
)}
</Box>
</Td>
<Td className="textEllipsis" maxW={'250px'}>
{item.title}
</Td>
<Td>{item.messageCount}</Td>
<Td w={'100px'}>
{!!item?.userGoodFeedbackCount && (
<Flex
mb={item?.userGoodFeedbackCount ? 1 : 0}
bg={'green.100'}
color={'green.600'}
px={3}
py={1}
alignItems={'center'}
justifyContent={'center'}
borderRadius={'md'}
fontWeight={'bold'}
>
<MyIcon
mr={1}
name={'core/chat/feedback/goodLight'}
color={'green.600'}
w={'14px'}
/>
{item.userGoodFeedbackCount}
</Flex>
)}
{!!item?.userBadFeedbackCount && (
<Flex
bg={'#FFF2EC'}
color={'#C96330'}
px={3}
py={1}
alignItems={'center'}
justifyContent={'center'}
borderRadius={'md'}
fontWeight={'bold'}
>
<MyIcon
mr={1}
name={'core/chat/feedback/badLight'}
color={'#C96330'}
w={'14px'}
/>
{item.userBadFeedbackCount}
</Flex>
)}
{!item?.userGoodFeedbackCount && !item?.userBadFeedbackCount && <>-</>}
</Td>
<Td>{item.customFeedbacksCount || '-'}</Td>
<Td>{item.markCount}</Td>
</Tr>
))}
</Tbody>
</Table>
{logs.length === 0 && !isLoading && <EmptyTip text={t('app:logs_empty')}></EmptyTip>}
</TableContainer>
<HStack w={'100%'} mt={3} justifyContent={'flex-end'}>
<DateRangePicker
defaultDate={dateRange}
position="top"
position="bottom"
onChange={setDateRange}
onSuccess={() => getData(1)}
/>
<Pagination />
</HStack>
</Flex>
<Flex alignItems={'center'} gap={2}>
<Box fontSize={'mini'} fontWeight={'medium'} color={'myGray.900'} whiteSpace={'nowrap'}>
{t('app:logs_title')}
</Box>
<SearchInput
placeholder={t('app:logs_title')}
w={'240px'}
value={logTitle}
onChange={(e) => setLogTitle(e.target.value)}
/>
</Flex>
<Box flex={'1'} />
<PopoverConfirm
Trigger={<Button size={'md'}>{t('common:Export')}</Button>}
showCancel
content={t('app:logs_export_confirm_tip', { total })}
onConfirm={exportLogs}
/>
</Flex>
<TableContainer mt={[2, 4]} flex={'1 0 0'} h={0} overflowY={'auto'}>
<Table variant={'simple'} fontSize={'sm'}>
<Thead>
<Tr>
<Th>{t('common:core.app.logs.Source And Time')}</Th>
<Th>{t('app:logs_chat_user')}</Th>
<Th>{t('app:logs_title')}</Th>
<Th>{t('app:logs_message_total')}</Th>
<Th>{t('app:feedback_count')}</Th>
<Th>{t('common:core.app.feedback.Custom feedback')}</Th>
<Th>
<Flex gap={1} alignItems={'center'}>
{t('app:mark_count')}
<QuestionTip label={t('common:core.chat.Mark Description')} />
</Flex>
</Th>
</Tr>
</Thead>
<Tbody fontSize={'xs'}>
{logs.map((item) => (
<Tr
key={item._id}
_hover={{ bg: 'myWhite.600' }}
cursor={'pointer'}
title={t('common:core.view_chat_detail')}
onClick={() => setDetailLogsId(item.id)}
>
<Td>
{/* @ts-ignore */}
<Box>{t(ChatSourceMap[item.source]?.name) || item.source}</Box>
<Box color={'myGray.500'}>{dayjs(item.time).format('YYYY/MM/DD HH:mm')}</Box>
</Td>
<Td>
<Box>
{!!item.outLinkUid ? (
item.outLinkUid
) : (
<UserBox sourceMember={item.sourceMember} />
)}
</Box>
</Td>
<Td className="textEllipsis" maxW={'250px'}>
{item.customTitle || item.title}
</Td>
<Td>{item.messageCount}</Td>
<Td w={'100px'}>
{!!item?.userGoodFeedbackCount && (
<Flex
mb={item?.userGoodFeedbackCount ? 1 : 0}
bg={'green.100'}
color={'green.600'}
px={3}
py={1}
alignItems={'center'}
justifyContent={'center'}
borderRadius={'md'}
fontWeight={'bold'}
>
<MyIcon
mr={1}
name={'core/chat/feedback/goodLight'}
color={'green.600'}
w={'14px'}
/>
{item.userGoodFeedbackCount}
</Flex>
)}
{!!item?.userBadFeedbackCount && (
<Flex
bg={'#FFF2EC'}
color={'#C96330'}
px={3}
py={1}
alignItems={'center'}
justifyContent={'center'}
borderRadius={'md'}
fontWeight={'bold'}
>
<MyIcon
mr={1}
name={'core/chat/feedback/badLight'}
color={'#C96330'}
w={'14px'}
/>
{item.userBadFeedbackCount}
</Flex>
)}
{!item?.userGoodFeedbackCount && !item?.userBadFeedbackCount && <>-</>}
</Td>
<Td>{item.customFeedbacksCount || '-'}</Td>
<Td>{item.markCount}</Td>
</Tr>
))}
</Tbody>
</Table>
{logs.length === 0 && !isLoading && <EmptyTip text={t('app:logs_empty')}></EmptyTip>}
</TableContainer>
<HStack w={'100%'} mt={3} justifyContent={'center'}>
<Pagination />
</HStack>
{!!detailLogsId && (
<DetailLogsModal
appId={appId}
@@ -212,13 +287,6 @@ const Logs = () => {
}}
/>
)}
<MyModal
isOpen={isOpenMarkDesc}
onClose={onCloseMarkDesc}
title={t('common:core.chat.Mark Description Title')}
>
<ModalBody whiteSpace={'pre-wrap'}>{t('common:core.chat.Mark Description')}</ModalBody>
</MyModal>
</Flex>
);
};

View File

@@ -0,0 +1,257 @@
import type { NextApiResponse } from 'next';
import { responseWriteController } from '@fastgpt/service/common/response';
import { addDays } from 'date-fns';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { readFromSecondary } from '@fastgpt/service/common/mongo/utils';
import { addLog } from '@fastgpt/service/common/system/log';
import dayjs from 'dayjs';
import { ApiRequestProps } from '@fastgpt/service/type/next';
import { replaceRegChars } from '@fastgpt/global/common/string/tools';
import { NextAPI } from '@/service/middleware/entry';
import { useIPFrequencyLimit } from '@fastgpt/service/common/middle/reqFrequencyLimit';
import { GetAppChatLogsProps } from '@/global/core/api/appReq';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { Types } from 'mongoose';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { ChatItemCollectionName } from '@fastgpt/service/core/chat/chatItemSchema';
import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema';
import { ChatItemValueTypeEnum, ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { AIChatItemValueItemType } from '@fastgpt/global/core/chat/type';
export type ExportChatLogsBody = GetAppChatLogsProps & {
title: string;
sourcesMap: Record<string, { label: string }>;
};
async function handler(req: ApiRequestProps<ExportChatLogsBody, {}>, res: NextApiResponse) {
let {
appId,
dateStart = addDays(new Date(), -7),
dateEnd = new Date(),
sources,
logTitle,
title,
sourcesMap
} = req.body;
if (!appId) {
throw new Error('缺少参数');
}
const { teamId } = await authApp({ req, authToken: true, appId, per: WritePermissionVal });
const teamMembers = await MongoTeamMember.find({ teamId });
const where = {
teamId: new Types.ObjectId(teamId),
appId: new Types.ObjectId(appId),
updateTime: {
$gte: new Date(dateStart),
$lte: new Date(dateEnd)
},
...(sources && { source: { $in: sources } }),
...(logTitle && {
$or: [
{ title: { $regex: new RegExp(`${replaceRegChars(logTitle)}`, 'i') } },
{ customTitle: { $regex: new RegExp(`${replaceRegChars(logTitle)}`, 'i') } }
]
})
};
res.setHeader('Content-Type', 'text/csv; charset=utf-8;');
res.setHeader('Content-Disposition', 'attachment; filename=usage.csv; ');
const cursor = MongoChat.aggregate(
[
{ $match: where },
{
$sort: {
userBadFeedbackCount: -1,
userGoodFeedbackCount: -1,
customFeedbacksCount: -1,
updateTime: -1
}
},
{ $limit: 50000 },
{
$lookup: {
from: ChatItemCollectionName,
let: { chatId: '$chatId' },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ['$appId', new Types.ObjectId(appId)] },
{ $eq: ['$chatId', '$$chatId'] }
]
}
}
},
{
$project: {
value: 1,
userGoodFeedback: 1,
userBadFeedback: 1,
customFeedbacks: 1,
adminFeedback: 1
}
}
],
as: 'chatitems'
}
},
{
$addFields: {
userGoodFeedbackCount: {
$size: {
$filter: {
input: '$chatitems',
as: 'item',
cond: { $ifNull: ['$$item.userGoodFeedback', false] }
}
}
},
userBadFeedbackCount: {
$size: {
$filter: {
input: '$chatitems',
as: 'item',
cond: { $ifNull: ['$$item.userBadFeedback', false] }
}
}
},
customFeedbacksCount: {
$size: {
$filter: {
input: '$chatitems',
as: 'item',
cond: { $gt: [{ $size: { $ifNull: ['$$item.customFeedbacks', []] } }, 0] }
}
}
},
markCount: {
$size: {
$filter: {
input: '$chatitems',
as: 'item',
cond: { $ifNull: ['$$item.adminFeedback', false] }
}
}
},
chatDetails: {
$map: {
input: '$chatitems',
as: 'item',
in: {
id: '$$item._id',
value: '$$item.value'
}
}
}
}
},
{
$project: {
_id: 1,
id: '$chatId',
title: 1,
customTitle: 1,
source: 1,
time: '$updateTime',
messageCount: { $size: '$chatitems' },
userGoodFeedbackCount: 1,
userBadFeedbackCount: 1,
customFeedbacksCount: 1,
markCount: 1,
outLinkUid: 1,
tmbId: 1,
chatDetails: 1
}
}
],
{
...readFromSecondary
}
).cursor({ batchSize: 1000 });
const write = responseWriteController({
res,
readStream: cursor
});
write(`\uFEFF${title}`);
cursor.on('data', (doc) => {
const time = dayjs(doc.time.toISOString()).format('YYYY-MM-DD HH:mm:ss');
const source = sourcesMap[doc.source as ChatSourceEnum]?.label || doc.source;
const title = doc.customTitle || doc.title;
const tmb = doc.outLinkUid
? doc.outLinkUid
: teamMembers.find((member) => String(member._id) === String(doc.tmbId))?.name;
const messageCount = doc.messageCount;
const userFeedbackCount = doc.userGoodFeedbackCount || doc.userBadFeedbackCount || '-';
const customFeedbacksCount = doc.customFeedbacksCount || '-';
const markCount = doc.markCount;
const chatDetails = doc.chatDetails.map(
(chat: { id: string; value: AIChatItemValueItemType[] }) => {
return chat.value.map((item) => {
if ([ChatItemValueTypeEnum.text, ChatItemValueTypeEnum.reasoning].includes(item.type)) {
return item;
}
if (item.type === ChatItemValueTypeEnum.tool) {
const newTools = item.tools?.map((tool) => {
const { functionName, toolAvatar, ...rest } = tool;
return {
...rest
};
});
return {
...item,
tools: newTools
};
}
if (item.type === ChatItemValueTypeEnum.interactive) {
const newInteractive = {
type: item.interactive?.type,
params: item.interactive?.params
};
return {
...item,
interactive: newInteractive
};
}
});
}
);
let chatDetailsStr = '';
try {
chatDetailsStr = JSON.stringify(chatDetails);
} catch (e) {
addLog.error(`export chat logs error`, e);
}
const res = `\n"${time}","${source}","${tmb}","${title}","${messageCount}","${userFeedbackCount}","${customFeedbacksCount}","${markCount}","${chatDetailsStr}"`;
write(res);
});
cursor.on('end', () => {
cursor.close();
res.end();
});
cursor.on('error', (err) => {
addLog.error(`export chat logs error`, err);
res.status(500);
res.end();
});
}
export default NextAPI(
useIPFrequencyLimit({ id: 'export-chat-logs', seconds: 2, limit: 1, force: true }),
handler
);

View File

@@ -12,6 +12,7 @@ import { readFromSecondary } from '@fastgpt/service/common/mongo/utils';
import { parsePaginationRequest } from '@fastgpt/service/common/api/pagination';
import { PaginationResponse } from '@fastgpt/web/common/fetch/type';
import { addSourceMember } from '@fastgpt/service/support/user/utils';
import { replaceRegChars } from '@fastgpt/global/common/string/tools';
async function handler(
req: NextApiRequest,
@@ -20,7 +21,9 @@ async function handler(
const {
appId,
dateStart = addDays(new Date(), -7),
dateEnd = new Date()
dateEnd = new Date(),
sources,
logTitle
} = req.body as GetAppChatLogsParams;
const { pageSize = 20, offset } = parsePaginationRequest(req);
@@ -38,7 +41,14 @@ async function handler(
updateTime: {
$gte: new Date(dateStart),
$lte: new Date(dateEnd)
}
},
...(sources && { source: { $in: sources } }),
...(logTitle && {
$or: [
{ title: { $regex: new RegExp(`${replaceRegChars(logTitle)}`, 'i') } },
{ customTitle: { $regex: new RegExp(`${replaceRegChars(logTitle)}`, 'i') } }
]
})
};
const [list, total] = await Promise.all([
@@ -127,6 +137,7 @@ async function handler(
_id: 1,
id: '$chatId',
title: 1,
customTitle: 1,
source: 1,
time: '$updateTime',
messageCount: { $size: '$chatitems' },

View File

@@ -40,6 +40,7 @@ export type AppLogsListItemType = {
source: string;
time: Date;
title: string;
customTitle: string;
messageCount: number;
userGoodFeedbackCount: number;
userBadFeedbackCount: number;