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

@@ -81,7 +81,10 @@
"llm_use_vision_tip": "After clicking on the model selection, you can see whether the model supports image recognition and the ability to control whether to start image recognition. \nAfter starting image recognition, the model will read the image content in the file link, and if the user question is less than 500 words, it will automatically parse the image in the user question.",
"logs_chat_user": "user",
"logs_empty": "No logs yet~",
"logs_export_confirm_tip": "There are a total of {{total}} dialogue records, confirm the export?",
"logs_export_title": "Time, source, user, title, total number of messages, user feedback, custom feedback, number of labeled answers, conversation details",
"logs_message_total": "Total Messages",
"logs_source": "source",
"logs_title": "Title",
"look_ai_point_price": "View all model billing standards",
"mark_count": "Number of Marked Answers",

View File

@@ -81,7 +81,10 @@
"llm_use_vision_tip": "点击模型选择后,可以看到模型是否支持图片识别以及控制是否启动图片识别的能力。启动图片识别后,模型会读取文件链接里图片内容,并且如果用户问题少于 500 字,会自动解析用户问题中的图片。",
"logs_chat_user": "使用者",
"logs_empty": "还没有日志噢~",
"logs_export_confirm_tip": "当前共 {{total}} 条对话记录,确认导出?",
"logs_export_title": "时间,来源,使用者,标题,消息总数,用户反馈,自定义反馈,标注答案数量,对话详情",
"logs_message_total": "消息总数",
"logs_source": "来源",
"logs_title": "标题",
"look_ai_point_price": "查看所有模型计费标准",
"mark_count": "标注答案数量",

View File

@@ -81,7 +81,10 @@
"llm_use_vision_tip": "點選模型選擇後,可以看到模型是否支援圖片辨識以及控制是否啟用圖片辨識的功能。啟用圖片辨識後,模型會讀取檔案連結中的圖片內容,並且如果使用者問題少於 500 字,會自動解析使用者問題中的圖片。",
"logs_chat_user": "使用者",
"logs_empty": "還沒有紀錄喔~",
"logs_export_confirm_tip": "當前共 {{total}} 條對話記錄,確認導出?",
"logs_export_title": "時間,來源,使用者,標題,消息總數,用戶反饋,自定義反饋,標註答案數量,對話詳情",
"logs_message_total": "訊息總數",
"logs_source": "来源",
"logs_title": "標題",
"look_ai_point_price": "查看所有模型計費標準",
"mark_count": "標記答案數量",

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;