chat log soft delete (#6110)

* chat log soft delete

* perf: history api

* add history test

* Update packages/web/i18n/en/app.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* zod parse error

* fix: ts

---------

Co-authored-by: archer <545436317@qq.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
heheer 2025-12-18 10:17:10 +08:00 committed by GitHub
parent 463b02d127
commit 09b9fa517b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 1830 additions and 251 deletions

View File

@ -6,7 +6,7 @@ description: 'FastGPT V4.14.5 更新说明'
## 🚀 新增内容
1. 对话记录使用侧改成软删除,增加从日志管理里删除对话记录。
## ⚙️ 优化

View File

@ -102,7 +102,7 @@
"document/content/docs/protocol/terms.en.mdx": "2025-12-15T23:36:54+08:00",
"document/content/docs/protocol/terms.mdx": "2025-12-15T23:36:54+08:00",
"document/content/docs/toc.en.mdx": "2025-08-04T13:42:36+08:00",
"document/content/docs/toc.mdx": "2025-12-09T23:33:32+08:00",
"document/content/docs/toc.mdx": "2025-12-17T17:44:38+08:00",
"document/content/docs/upgrading/4-10/4100.mdx": "2025-08-02T19:38:37+08:00",
"document/content/docs/upgrading/4-10/4101.mdx": "2025-09-08T20:07:20+08:00",
"document/content/docs/upgrading/4-11/4110.mdx": "2025-08-05T23:20:39+08:00",
@ -120,6 +120,7 @@
"document/content/docs/upgrading/4-14/4142.mdx": "2025-11-18T19:27:14+08:00",
"document/content/docs/upgrading/4-14/4143.mdx": "2025-11-26T20:52:05+08:00",
"document/content/docs/upgrading/4-14/4144.mdx": "2025-12-16T14:56:04+08:00",
"document/content/docs/upgrading/4-14/4145.mdx": "2025-12-17T17:44:38+08:00",
"document/content/docs/upgrading/4-8/40.mdx": "2025-08-02T19:38:37+08:00",
"document/content/docs/upgrading/4-8/41.mdx": "2025-08-02T19:38:37+08:00",
"document/content/docs/upgrading/4-8/42.mdx": "2025-08-02T19:38:37+08:00",

View File

@ -1,4 +1,4 @@
import type { OutLinkChatAuthProps } from '../../support/permission/chat.d';
import type { OutLinkChatAuthProps } from '../../support/permission/chat';
export type preUploadImgProps = OutLinkChatAuthProps & {
// expiredTime?: Date;

View File

@ -37,7 +37,7 @@ export const AppLogKeysEnumMap = {
};
export const DefaultAppLogKeys = [
{ key: AppLogKeysEnum.SOURCE, enable: true },
{ key: AppLogKeysEnum.SOURCE, enable: false },
{ key: AppLogKeysEnum.USER, enable: true },
{ key: AppLogKeysEnum.TITLE, enable: true },
{ key: AppLogKeysEnum.SESSION_ID, enable: false },

View File

@ -51,6 +51,8 @@ export type ChatSchemaType = {
hasBadFeedback?: boolean;
hasUnreadGoodFeedback?: boolean;
hasUnreadBadFeedback?: boolean;
deleteTime?: Date | null;
};
export type ChatWithAppSchema = Omit<ChatSchemaType, 'appId'> & {
@ -197,7 +199,7 @@ export type HistoryItemType = {
};
export type ChatHistoryItemType = HistoryItemType & {
appId: string;
top: boolean;
top?: boolean;
};
/* ------- response data ------------ */

View File

@ -1,7 +1,17 @@
import { z } from 'zod';
export const PaginationSchema = z.object({
pageSize: z.union([z.number(), z.string()]),
pageSize: z.union([z.number(), z.string()]).optional(),
offset: z.union([z.number(), z.string()]).optional(),
pageNum: z.union([z.number(), z.string()]).optional()
});
export type PaginationType = z.infer<typeof PaginationSchema>;
export const PaginationResponseSchema = <T extends z.ZodTypeAny>(itemSchema: T) =>
z.object({
total: z.number().optional().default(0),
list: z.array(itemSchema).optional().default([])
});
export type PaginationResponseType<T extends z.ZodTypeAny> = z.infer<
ReturnType<typeof PaginationResponseSchema<T>>
>;

View File

@ -34,7 +34,7 @@ export const ChatLogItemSchema = z.object({
title: z.string().optional().meta({ example: '用户对话', description: '对话标题' }),
customTitle: z.string().nullish().meta({ example: '自定义标题', description: '自定义对话标题' }),
source: z.enum(ChatSourceEnum).meta({ example: ChatSourceEnum.api, description: '对话来源' }),
sourceName: z.string().optional().meta({ example: 'API调用', description: '来源名称' }),
sourceName: z.string().nullish().meta({ example: 'API调用', description: '来源名称' }),
updateTime: z.date().meta({ example: '2024-01-01T00:30:00.000Z', description: '更新时间' }),
createTime: z.date().meta({ example: '2024-01-01T00:00:00.000Z', description: '创建时间' }),
messageCount: z.int().nullish().meta({ example: 10, description: '消息数量' }),
@ -50,7 +50,7 @@ export const ChatLogItemSchema = z.object({
totalPoints: z.number().nullish().meta({ example: 150.5, description: '总积分消耗' }),
outLinkUid: z.string().nullish().meta({ example: 'outLink123', description: '外链用户 ID' }),
tmbId: z.string().nullish().meta({ example: 'tmb123', description: '团队成员 ID' }),
sourceMember: SourceMemberSchema.optional().meta({ description: '来源成员信息' }),
sourceMember: SourceMemberSchema.nullish().meta({ description: '来源成员信息' }),
versionName: z.string().nullish().meta({ example: 'v1.0.0', description: '版本名称' }),
region: z.string().nullish().meta({ example: '中国', description: '区域' })
});

View File

@ -105,11 +105,11 @@ export const UpdateUserFeedbackBodySchema = z.object({
example: 'data123',
description: '消息数据 ID'
}),
userGoodFeedback: z.string().optional().nullable().meta({
userGoodFeedback: z.string().nullish().meta({
example: '回答很好',
description: '用户好评反馈内容'
}),
userBadFeedback: z.string().optional().nullable().meta({
userBadFeedback: z.string().nullish().meta({
example: '回答不准确',
description: '用户差评反馈内容'
})

View File

@ -0,0 +1,73 @@
import z from 'zod';
import { ObjectIdSchema } from '../../../../common/type/mongo';
import { OutLinkChatAuthSchema } from '../../../../support/permission/chat';
import { ChatSourceEnum } from '../../../../core/chat/constants';
import { PaginationSchema, PaginationResponseSchema } from '../../../api';
// Get chat histories schema
export const GetHistoriesBodySchema = PaginationSchema.and(
OutLinkChatAuthSchema.and(
z.object({
appId: ObjectIdSchema.optional().describe('应用ID'),
source: z.enum(ChatSourceEnum).optional().describe('对话来源'),
startCreateTime: z.string().optional().describe('创建时间开始'),
endCreateTime: z.string().optional().describe('创建时间结束'),
startUpdateTime: z.string().optional().describe('更新时间开始'),
endUpdateTime: z.string().optional().describe('更新时间结束')
})
)
);
export type GetHistoriesBodyType = z.infer<typeof GetHistoriesBodySchema>;
export const GetHistoriesResponseSchema = PaginationResponseSchema(
z.object({
chatId: z.string(),
updateTime: z.date(),
appId: z.string(),
customTitle: z.string().optional(),
title: z.string(),
top: z.boolean().optional()
})
);
export type GetHistoriesResponseType = z.infer<typeof GetHistoriesResponseSchema>;
// Update chat history schema
export const UpdateHistoryBodySchema = OutLinkChatAuthSchema.and(
z.object({
appId: ObjectIdSchema.describe('应用ID'),
chatId: z.string().min(1).describe('对话ID'),
title: z.string().optional().describe('标题'),
customTitle: z.string().optional().describe('自定义标题'),
top: z.boolean().optional().describe('是否置顶')
})
);
export type UpdateHistoryBodyType = z.infer<typeof UpdateHistoryBodySchema>;
// Delete single chat history schema
export const DelChatHistorySchema = OutLinkChatAuthSchema.and(
z.object({
appId: ObjectIdSchema.describe('应用ID'),
chatId: z.string().min(1).describe('对话ID')
})
);
export type DelChatHistoryType = z.infer<typeof DelChatHistorySchema>;
// Clear all chat histories schema
export const ClearChatHistoriesSchema = OutLinkChatAuthSchema.and(
z.object({
appId: ObjectIdSchema.describe('应用ID')
})
);
export type ClearChatHistoriesType = z.infer<typeof ClearChatHistoriesSchema>;
// Batch delete chat histories schema (for log manager)
export const ChatBatchDeleteBodySchema = z.object({
appId: ObjectIdSchema,
chatIds: z
.array(z.string().min(1))
.min(1)
.meta({
description: '对话ID列表',
example: ['chat_123456', 'chat_789012']
})
});
export type ChatBatchDeleteBodyType = z.infer<typeof ChatBatchDeleteBodySchema>;

View File

@ -0,0 +1,109 @@
import type { OpenAPIPath } from '../../../type';
import { TagsMap } from '../../../tag';
import {
GetHistoriesBodySchema,
GetHistoriesResponseSchema,
UpdateHistoryBodySchema,
ChatBatchDeleteBodySchema,
DelChatHistorySchema,
ClearChatHistoriesSchema
} from './api';
export const ChatHistoryPath: OpenAPIPath = {
'/core/chat/history/getHistories': {
post: {
summary: '获取对话历史列表',
description: '分页获取指定应用的对话历史记录',
tags: [TagsMap.chatHistory],
requestBody: {
content: {
'application/json': {
schema: GetHistoriesBodySchema
}
}
},
responses: {
200: {
description: '成功获取对话历史列表',
content: {
'application/json': {
schema: GetHistoriesResponseSchema
}
}
}
}
}
},
'/core/chat/history/updateHistory': {
put: {
summary: '修改对话历史',
description: '修改对话历史的标题、自定义标题或置顶状态',
tags: [TagsMap.chatHistory],
requestBody: {
content: {
'application/json': {
schema: UpdateHistoryBodySchema
}
}
},
responses: {
200: {
description: '成功修改对话历史'
}
}
}
},
'/core/chat/history/delHistory': {
delete: {
summary: '删除单个对话历史',
description: '软删除指定的单个对话记录',
tags: [TagsMap.chatHistory],
requestBody: {
content: {
'application/json': {
schema: DelChatHistorySchema
}
}
},
responses: {
200: {
description: '成功删除对话'
}
}
}
},
'/core/chat/history/clearHistories': {
delete: {
summary: '清空应用对话历史',
description: '清空指定应用的所有对话记录(软删除)',
tags: [TagsMap.chatHistory],
requestParams: {
query: ClearChatHistoriesSchema
},
responses: {
200: {
description: '成功清空对话历史'
}
}
}
},
'/core/chat/history/batchDelete': {
post: {
summary: '批量删除对话历史',
description: '批量删除指定应用的多个对话记录(真实删除),需应用日志权限。',
tags: [TagsMap.chatHistory],
requestBody: {
content: {
'application/json': {
schema: ChatBatchDeleteBodySchema
}
}
},
responses: {
200: {
description: '成功删除对话'
}
}
}
}
};

View File

@ -2,6 +2,7 @@ import type { OpenAPIPath } from '../../type';
import { ChatSettingPath } from './setting';
import { ChatFavouriteAppPath } from './favourite/index';
import { ChatFeedbackPath } from './feedback/index';
import { ChatHistoryPath } from './history/index';
import { z } from 'zod';
import { CreatePostPresignedUrlResultSchema } from '../../../../service/common/s3/type';
import { PresignChatFileGetUrlSchema, PresignChatFilePostUrlSchema } from '../../../core/chat/api';
@ -11,6 +12,7 @@ export const ChatPath: OpenAPIPath = {
...ChatSettingPath,
...ChatFavouriteAppPath,
...ChatFeedbackPath,
...ChatHistoryPath,
'/core/chat/presignChatFileGetUrl': {
post: {

View File

@ -30,7 +30,7 @@ export const openAPIDocument = createDocument({
},
{
name: '对话管理',
tags: [TagsMap.chatSetting, TagsMap.chatPage, TagsMap.chatFeedback]
tags: [TagsMap.chatHistory, TagsMap.chatPage, TagsMap.chatFeedback, TagsMap.chatSetting]
},
{
name: '插件系统',

View File

@ -4,7 +4,8 @@ export const TagsMap = {
appLog: 'Agent 日志',
// Chat - home
chatPage: '对话页',
chatPage: '对话页操作',
chatHistory: '对话历史管理',
chatSetting: '门户页配置',
chatFeedback: '对话反馈',

View File

@ -1,9 +0,0 @@
type ShareChatAuthProps = {
shareId?: string;
outLinkUid?: string;
};
type TeamChatAuthProps = {
teamId?: string;
teamToken?: string;
};
export type OutLinkChatAuthProps = ShareChatAuthProps & TeamChatAuthProps;

View File

@ -0,0 +1,16 @@
import z from 'zod';
export const ShareChatAuthSchema = z.object({
shareId: z.string().optional().describe('分享链接ID'),
outLinkUid: z.string().optional().describe('外链用户ID')
});
export type ShareChatAuthProps = z.infer<typeof ShareChatAuthSchema>;
export const TeamChatAuthSchema = z.object({
teamId: z.string().optional().describe('团队ID'),
teamToken: z.string().optional().describe('团队Token')
});
export type TeamChatAuthProps = z.infer<typeof TeamChatAuthSchema>;
export const OutLinkChatAuthSchema = ShareChatAuthSchema.and(TeamChatAuthSchema);
export type OutLinkChatAuthProps = z.infer<typeof OutLinkChatAuthSchema>;

View File

@ -38,9 +38,7 @@ export type UserType = {
export const SourceMemberSchema = z.object({
name: z.string().meta({ example: '张三', description: '成员名称' }),
avatar: z
.string()
.meta({ example: 'https://cloud.fastgpt.cn/avatar.png', description: '成员头像' }),
avatar: z.string().nullish().meta({ description: '成员头像' }),
status: z
.enum(TeamMemberStatusEnum)
.meta({ example: TeamMemberStatusEnum.active, description: '成员状态' })

View File

@ -54,10 +54,8 @@ export const NextEntry = ({
if (error instanceof ZodError) {
return jsonRes(res, {
code: 400,
error: {
message: 'Validation error',
details: error.message
},
message: 'Data validation error',
error,
url: req.url
});
}

View File

@ -5,6 +5,7 @@ import { addLog } from '../system/log';
import { replaceSensitiveText } from '@fastgpt/global/common/string/tools';
import { UserError } from '@fastgpt/global/common/error/utils';
import { clearCookie } from '../../support/permission/auth/common';
import { ZodError } from 'zod';
export interface ResponseType<T = any> {
code: number;
@ -65,6 +66,9 @@ export function processError(params: {
// 3. 根据错误类型记录不同级别的日志
if (error instanceof UserError) {
addLog.info(`Request error: ${url}, ${msg}`);
} else if (error instanceof ZodError) {
addLog.error(`[Zod] Error in ${url}`, error.message);
msg = error.message;
} else {
addLog.error(`System unexpected error: ${url}, ${msg}`, error);
}

View File

@ -233,7 +233,6 @@ export const onDelOneApp = async ({
await MongoChat.deleteMany({
appId
});
await getS3ChatSource().deleteChatFilesByPrefix({ appId });
}
for await (const app of apps) {

View File

@ -95,7 +95,13 @@ const ChatSchema = new Schema({
hasGoodFeedback: Boolean,
hasBadFeedback: Boolean,
hasUnreadGoodFeedback: Boolean,
hasUnreadBadFeedback: Boolean
hasUnreadBadFeedback: Boolean,
deleteTime: {
type: Date,
default: null,
select: false
}
});
try {
@ -103,13 +109,13 @@ try {
ChatSchema.index({ chatId: 1 });
// get user history
ChatSchema.index({ tmbId: 1, appId: 1, top: -1, updateTime: -1 });
ChatSchema.index({ tmbId: 1, appId: 1, deleteTime: 1, top: -1, updateTime: -1 });
// delete by appid; clear history; init chat; update chat; auth chat; get chat;
ChatSchema.index({ appId: 1, chatId: 1 });
/* get chat logs */
// 1. No feedback filter
ChatSchema.index({ teamId: 1, appId: 1, source: 1, tmbId: 1, updateTime: -1 });
ChatSchema.index({ teamId: 1, appId: 1, source: 1, tmbId: 1, deleteTime: 1, updateTime: -1 });
/* 反馈过滤的索引 */
// 2. Has good feedback filter
@ -120,11 +126,13 @@ try {
source: 1,
tmbId: 1,
hasGoodFeedback: 1,
deleteTime: 1,
updateTime: -1
},
{
partialFilterExpression: {
hasGoodFeedback: true
hasGoodFeedback: true,
deleteTime: null
}
}
);
@ -136,11 +144,13 @@ try {
source: 1,
tmbId: 1,
hasBadFeedback: 1,
deleteTime: 1,
updateTime: -1
},
{
partialFilterExpression: {
hasBadFeedback: true
hasBadFeedback: true,
deleteTime: null
}
}
);
@ -152,11 +162,13 @@ try {
source: 1,
tmbId: 1,
hasUnreadGoodFeedback: 1,
deleteTime: 1,
updateTime: -1
},
{
partialFilterExpression: {
hasUnreadGoodFeedback: true
hasUnreadGoodFeedback: true,
deleteTime: null
}
}
);
@ -168,11 +180,13 @@ try {
source: 1,
tmbId: 1,
hasUnreadBadFeedback: 1,
deleteTime: 1,
updateTime: -1
},
{
partialFilterExpression: {
hasUnreadBadFeedback: true
hasUnreadBadFeedback: true,
deleteTime: null
}
}
);

View File

@ -6,7 +6,11 @@ import MyIcon from '../Icon';
import { iconPaths } from '../Icon/constants';
import MyImage from '../Image/MyImage';
const Avatar = ({ w = '30px', src, ...props }: ImageProps) => {
const Avatar = ({
w = '30px',
src,
...props
}: Omit<ImageProps, 'src'> & { src?: string | null }) => {
// @ts-ignore
const isIcon = !!iconPaths[src as any];

View File

@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React from 'react';
import { useTranslation } from 'next-i18next';
import MyIcon from '../Icon';
import { useRequest2 } from '../../../hooks/useRequest';
@ -11,9 +11,9 @@ import {
HStack,
Box,
Button,
PopoverArrow,
Portal
PopoverArrow
} from '@chakra-ui/react';
import { useMemoEnhance } from '../../../hooks/useMemoEnhance';
const PopoverConfirm = ({
content,
@ -32,13 +32,13 @@ const PopoverConfirm = ({
Trigger: React.ReactNode;
placement?: PlacementWithLogical;
offset?: [number, number];
onConfirm: () => any;
onConfirm: () => Promise<any> | any;
confirmText?: string;
cancelText?: string;
}) => {
const { t } = useTranslation();
const map = useMemo(() => {
const map = useMemoEnhance(() => {
const map = {
info: {
variant: 'primary',
@ -56,7 +56,7 @@ const PopoverConfirm = ({
const firstFieldRef = React.useRef(null);
const { onOpen, onClose, isOpen } = useDisclosure();
const { runAsync: onclickConfirm, loading } = useRequest2(onConfirm, {
const { runAsync: onclickConfirm, loading } = useRequest2(async () => onConfirm(), {
onSuccess: onClose
});
@ -90,7 +90,14 @@ const PopoverConfirm = ({
</HStack>
<HStack mt={2} justifyContent={'flex-end'}>
{showCancel && (
<Button variant={'whiteBase'} size="sm" onClick={onClose}>
<Button
variant={'whiteBase'}
size="sm"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
{cancelText || t('common:Cancel')}
</Button>
)}

View File

@ -64,15 +64,20 @@ export const useTableMultipleSelect = <T = any,>({
children,
Controler,
activedStyles,
activeBg,
...props
}: { children?: ReactNode; activedStyles?: FlexProps; Controler: ReactNode } & FlexProps) => {
}: {
children?: ReactNode;
activeBg?: string;
activedStyles?: FlexProps;
Controler: ReactNode;
} & FlexProps) => {
return hasSelections || !!children ? (
<Flex
w={'100%'}
bg="white"
bg={selectedCount > 0 ? activeBg : 'transparent'}
px={6}
pt={4}
pb={2}
py={2}
alignItems="center"
{...props}
{...activedStyles}

View File

@ -71,6 +71,8 @@
"confirm_copy_app_tip": "The system will create an app with the same configuration for you, but permissions will not be copied. Please confirm!",
"confirm_del_app_tip": "Confirm to delete this Agent? \nDeleting an Agent will delete its associated conversation records as well.",
"confirm_del_tool_tip": "Confirm to delete this tool? \nDeleting a tool will delete its associated conversation records, and the Agent that uses the tool may not function properly.",
"confirm_delete_chat_content": "Are you sure you want to delete this chat? This action cannot be undone!",
"confirm_delete_chats": "Are you sure you want to delete {{n}} conversation records? \nThe records will be permanently deleted!",
"confirm_delete_folder_tip": "When you delete this folder, all applications and corresponding chat records under it will be deleted.",
"confirm_delete_tool": "Confirm to delete this tool?",
"copilot_config_message": "Current Node Configuration Information: \n Code Type: {{codeType}} \n Current Code: \\\\`\\\\`\\\\`{{codeType}} \n{{code}} \\\\`\\\\`\\\\` \n Input Parameters: {{inputs}} \n Output Parameters: {{outputs}}",

View File

@ -73,6 +73,8 @@
"confirm_copy_app_tip": "系统将为您创建一个相同配置应用,但权限不会进行复制,请确认!",
"confirm_del_app_tip": "确认删除该 Agent删除 Agent 会将其关联的对话记录一并删除。",
"confirm_del_tool_tip": "确认删除该工具?删除工具会将其关联的对话记录一并删除,并且使用到该工具的 Agent 可能无法正常运行。",
"confirm_delete_chat_content": "确定要删除这个对话吗?此操作不可恢复!",
"confirm_delete_chats": "确认删除 {{n}} 组对话记录?记录将会永久删除!",
"confirm_delete_folder_tip": "删除该文件夹时,将会删除它下面所有应用及对应的聊天记录。",
"confirm_delete_tool": "确认删除该工具?",
"copilot_config_message": "`当前节点配置信息: \n代码类型{{codeType}} \n当前代码 \\`\\`\\`{{codeType}} \n{{code}} \\`\\`\\` \n输入参数 {{inputs}} \n输出参数 {{outputs}}`",

View File

@ -71,6 +71,8 @@
"confirm_copy_app_tip": "系統將為您建立一個相同設定的應用程式,但權限不會複製,請確認!",
"confirm_del_app_tip": "確認刪除該 Agent\n刪除 Agent 會將其關聯的對話記錄一併刪除。",
"confirm_del_tool_tip": "確認刪除該工具?\n刪除工具會將其關聯的對話記錄一併刪除並且使用到該工具的 Agent 可能無法正常運行。",
"confirm_delete_chat_content": "確定要刪除這個對話嗎?\n此操作不可恢復",
"confirm_delete_chats": "確認刪除 {{n}} 組對話記錄?\n記錄將會永久刪除",
"confirm_delete_folder_tip": "刪除該文件夾時,將會刪除它下面所有應用及對應的聊天記錄。",
"confirm_delete_tool": "確認刪除該工具?",
"copilot_config_message": "當前節點配置信息: \n代碼類型{{codeType}} \n當前代碼 \\\\`\\\\`\\\\`{{codeType}} \n{{code}} \\\\`\\\\`\\\\` \n輸入參數 {{inputs}} \n輸出參數 {{outputs}}",

View File

@ -1,11 +1,12 @@
import type { AppChatConfigType, AppTTSConfigType } from '@fastgpt/global/core/app/type.d';
import type { AdminFbkType } from '@fastgpt/global/core/chat/type';
import { ChatItemType } from '@fastgpt/global/core/chat/type';
import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat.d';
import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import type { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { RequestPaging } from '@/types';
import type { GetChatTypeEnum } from '@/global/core/chat/constants';
import type { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
export type GetChatSpeechProps = OutLinkChatAuthProps & {
appId: string;
ttsConfig: AppTTSConfigType;
@ -56,33 +57,6 @@ export type InitChatResponse = {
};
};
/* ---------- history ----------- */
export type GetHistoriesProps = OutLinkChatAuthProps & {
appId?: string;
source?: `${ChatSourceEnum}`;
startCreateTime?: string;
endCreateTime?: string;
startUpdateTime?: string;
endUpdateTime?: string;
};
export type UpdateHistoryProps = OutLinkChatAuthProps & {
appId: string;
chatId: string;
title?: string;
customTitle?: string;
top?: boolean;
};
export type DelHistoryProps = OutLinkChatAuthProps & {
appId: string;
chatId: string;
};
export type ClearHistoriesProps = OutLinkChatAuthProps & {
appId: string;
};
/* -------- chat item ---------- */
export type DeleteChatItemProps = OutLinkChatAuthProps & {
appId: string;
@ -90,16 +64,3 @@ export type DeleteChatItemProps = OutLinkChatAuthProps & {
contentId?: string;
delFile?: boolean;
};
export type AdminUpdateFeedbackParams = AdminFbkType & {
appId: string;
chatId: string;
dataId: string;
};
export type CloseCustomFeedbackParams = {
appId: string;
chatId: string;
dataId: string;
index: number;
};

View File

@ -10,7 +10,8 @@ import {
Td,
Th,
Thead,
Tr
Tr,
Checkbox
} from '@chakra-ui/react';
import type { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { ChatSourceMap } from '@fastgpt/global/core/chat/constants';
@ -53,6 +54,10 @@ import MyBox from '@fastgpt/web/components/common/MyBox';
import { useContextSelector } from 'use-context-selector';
import { AppContext } from '../context';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
import { batchDeleteChatHistories } from '@/web/core/chat/history/api';
import { useTableMultipleSelect } from '@fastgpt/web/hooks/useTableMultipleSelect';
import MyIconButton from '@fastgpt/web/components/common/Icon/button';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
const DetailLogsModal = dynamic(() => import('./DetailLogsModal'));
@ -213,7 +218,34 @@ const LogTable = ({
refreshDeps: [params]
});
const HeaderRenderMap = useMemo(
const {
selectedItems,
toggleSelect,
isSelected,
FloatingActionBar,
isSelecteAll,
selectAllTrigger
} = useTableMultipleSelect({
list: logs,
getItemId: (item) => item._id
});
const chatIds = useMemoEnhance(() => selectedItems.map((item) => item.chatId), [selectedItems]);
const { openConfirm: openConfirmDelete, ConfirmModal: ConfirmDeleteModal } = useConfirm({
type: 'delete'
});
const { runAsync: handleDelete } = useRequest2(
async (chatIds: string[]) => {
await batchDeleteChatHistories({ appId, chatIds });
await getData(pageNum);
},
{
successToast: t('common:delete_success')
}
);
const HeaderRenderMap = useMemoEnhance(
() => ({
[AppLogKeysEnum.SOURCE]: <Th key={AppLogKeysEnum.SOURCE}>{t('app:logs_keys_source')}</Th>,
[AppLogKeysEnum.CREATED_TIME]: (
@ -503,9 +535,13 @@ const LogTable = ({
<Table variant={'simple'} fontSize={'sm'}>
<Thead>
<Tr>
<Th>
<Checkbox isChecked={isSelecteAll} onChange={selectAllTrigger} />
</Th>
{logKeys
.filter((logKey) => logKey.enable)
.map((logKey) => HeaderRenderMap[logKey.key])}
<Th>{t('common:Action')}</Th>
</Tr>
</Thead>
<Tbody fontSize={'xs'}>
@ -516,12 +552,28 @@ const LogTable = ({
key={item._id}
_hover={{ bg: 'myWhite.600' }}
cursor={'pointer'}
title={t('common:core.view_chat_detail')}
onClick={() => setDetailLogsId(item.chatId)}
>
<Td>
<HStack onClick={(e) => e.stopPropagation()}>
<Checkbox isChecked={isSelected(item)} onChange={() => toggleSelect(item)} />
</HStack>
</Td>
{logKeys
.filter((logKey) => logKey.enable)
.map((logKey) => cellRenderMap[logKey.key as AppLogKeysEnum])}
<Td onClick={(e) => e.stopPropagation()}>
<PopoverConfirm
content={t('app:confirm_delete_chat_content')}
type="delete"
onConfirm={() => handleDelete([item.chatId])}
Trigger={
<Flex>
<MyIconButton icon={'delete'} hoverColor={'red.600'} hoverBg="red.100" />
</Flex>
}
/>
</Td>
</Tr>
);
})}
@ -530,11 +582,32 @@ const LogTable = ({
{logs.length === 0 && !isLoading && <EmptyTip text={t('app:logs_empty')}></EmptyTip>}
</TableContainer>
{total >= pageSize && (
<Flex mt={3} justifyContent={'center'}>
<Pagination />
</Flex>
)}
<FloatingActionBar
pb={0}
Controler={
<HStack>
<Button
variant={'whiteDanger'}
onClick={() =>
openConfirmDelete({
onConfirm: () => handleDelete(chatIds),
customContent: t('app:confirm_delete_chats', {
n: chatIds.length
})
})()
}
>
{t('common:Delete')} ({chatIds.length})
</Button>
</HStack>
}
>
{total > pageSize && (
<Flex justifyContent={'center'}>
<Pagination />
</Flex>
)}
</FloatingActionBar>
{!!detailLogsId && (
<DetailLogsModal
@ -546,6 +619,8 @@ const LogTable = ({
}}
/>
)}
<ConfirmDeleteModal />
</MyBox>
);
};

View File

@ -450,6 +450,7 @@ const CollectionCard = () => {
</TableContainer>
<FloatingActionBar
pt={4}
Controler={
<HStack>
<Button

View File

@ -1,5 +1,3 @@
import '@scalar/api-reference-react/style.css';
import type { AppProps } from 'next/app';
import Script from 'next/script';

View File

@ -340,7 +340,6 @@ async function handler(
});
// 获取没有 tmbId 的人员
const listWithoutTmbId = listWithRegion.filter((item) => !item.tmbId);
return GetAppChatLogsResponseSchema.parse({
list: listWithSourceMember.concat(listWithoutTmbId),
total

View File

@ -1,48 +0,0 @@
import type { NextApiResponse } from 'next';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema';
import { type DelHistoryProps } from '@/global/core/chat/api';
import { authChatCrud } from '@/service/support/permission/auth/chat';
import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun';
import { NextAPI } from '@/service/middleware/entry';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
import { MongoChatItemResponse } from '@fastgpt/service/core/chat/chatItemResponseSchema';
import { getS3ChatSource } from '@fastgpt/service/common/s3/sources/chat';
/* clear chat history */
async function handler(req: ApiRequestProps<{}, DelHistoryProps>, res: NextApiResponse) {
const { appId, chatId } = req.query;
const { uid: uId } = await authChatCrud({
req,
authToken: true,
authApiKey: true,
...req.query
});
await mongoSessionRun(async (session) => {
await MongoChatItemResponse.deleteMany({
appId,
chatId
});
await MongoChatItem.deleteMany(
{
appId,
chatId
},
{ session }
);
await MongoChat.deleteOne(
{
appId,
chatId
},
{ session }
);
await getS3ChatSource().deleteChatFilesByPrefix({ appId, chatId, uId });
});
return;
}
export default NextAPI(handler);

View File

@ -20,10 +20,6 @@ async function handler(
const { appId, chatId, dataId, userBadFeedback, userGoodFeedback } =
UpdateUserFeedbackBodySchema.parse(req.body);
if (!chatId || !dataId) {
return Promise.reject('chatId or dataId is empty');
}
const { teamId } = await authChatCrud({
req,
authToken: true,

View File

@ -0,0 +1,68 @@
import type { NextApiResponse } from 'next';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema';
import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun';
import { NextAPI } from '@/service/middleware/entry';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
import { MongoChatItemResponse } from '@fastgpt/service/core/chat/chatItemResponseSchema';
import { getS3ChatSource } from '@fastgpt/service/common/s3/sources/chat';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { AppReadChatLogPerVal } from '@fastgpt/global/support/permission/app/constant';
import { ChatBatchDeleteBodySchema } from '@fastgpt/global/openapi/core/chat/history/api';
import { UserError } from '@fastgpt/global/common/error/utils';
async function handler(req: ApiRequestProps, res: NextApiResponse) {
const { appId, chatIds } = ChatBatchDeleteBodySchema.parse(req.body);
await authApp({
req,
authToken: true,
authApiKey: true,
appId,
per: AppReadChatLogPerVal
});
await MongoChatItemResponse.deleteMany({
appId,
chatId: { $in: chatIds }
});
await mongoSessionRun(async (session) => {
const chatList = await MongoChat.find(
{
appId,
chatId: { $in: chatIds }
},
'chatId tmbId outLinkUid'
)
.lean()
.session(session);
await MongoChatItem.deleteMany(
{
appId,
chatId: { $in: chatIds }
},
{ session }
);
await MongoChat.deleteMany(
{
appId,
chatId: { $in: chatIds }
},
{ session }
);
await Promise.all(
chatList.map((item) => {
return getS3ChatSource().deleteChatFilesByPrefix({
appId,
chatId: item.chatId,
uId: String(item.outLinkUid || item.tmbId)
});
})
);
});
return;
}
export default NextAPI(handler);

View File

@ -1,17 +1,16 @@
import type { NextApiResponse } from 'next';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema';
import { type ClearHistoriesProps } from '@/global/core/chat/api';
import { ClearChatHistoriesSchema } from '@fastgpt/global/openapi/core/chat/history/api';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { NextAPI } from '@/service/middleware/entry';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
import { authChatCrud } from '@/service/support/permission/auth/chat';
import { MongoChatItemResponse } from '@fastgpt/service/core/chat/chatItemResponseSchema';
import { getS3ChatSource } from '@fastgpt/service/common/s3/sources/chat';
/* clear chat history */
async function handler(req: ApiRequestProps<{}, ClearHistoriesProps>, res: NextApiResponse) {
const { appId, shareId, outLinkUid, teamId, teamToken } = req.query;
/* clear all chat histories of an app */
async function handler(req: ApiRequestProps, res: NextApiResponse) {
const { appId, shareId, outLinkUid, teamId, teamToken } = ClearChatHistoriesSchema.parse(
req.query
);
const {
teamId: chatTeamId,
@ -61,22 +60,18 @@ async function handler(req: ApiRequestProps<{}, ClearHistoriesProps>, res: NextA
// find chatIds
const list = await MongoChat.find(match, 'chatId').lean();
const idList = list.map((item) => item.chatId);
await getS3ChatSource().deleteChatFilesByPrefix({ appId, uId: uid });
await MongoChatItemResponse.deleteMany({
appId,
chatId: { $in: idList }
});
await MongoChatItem.deleteMany({
appId,
chatId: { $in: idList }
});
await MongoChat.deleteMany({
appId,
chatId: { $in: idList }
});
await MongoChat.updateMany(
{
appId,
chatId: { $in: list.map((item) => item.chatId) }
},
{
$set: {
deleteTime: new Date()
}
}
);
}
export default NextAPI(handler);

View File

@ -0,0 +1,34 @@
import type { NextApiResponse } from 'next';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { DelChatHistorySchema } from '@fastgpt/global/openapi/core/chat/history/api';
import { authChatCrud } from '@/service/support/permission/auth/chat';
import { NextAPI } from '@/service/middleware/entry';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
/* delete single chat history (soft delete) */
async function handler(req: ApiRequestProps, res: NextApiResponse) {
const { appId, chatId } = DelChatHistorySchema.parse(req.query);
await authChatCrud({
req,
authToken: true,
authApiKey: true,
...req.query
});
await MongoChat.updateOne(
{
appId,
chatId
},
{
$set: {
deleteTime: new Date()
}
}
);
return;
}
export default NextAPI(handler);

View File

@ -5,21 +5,19 @@ import { authCert } from '@fastgpt/service/support/permission/auth/common';
import { authTeamSpaceToken } from '@/service/support/permission/auth/team';
import { NextAPI } from '@/service/middleware/entry';
import { type ApiRequestProps, type ApiResponseType } from '@fastgpt/service/type/next';
import { type PaginationProps, type PaginationResponse } from '@fastgpt/web/common/fetch/type';
import { type GetHistoriesProps } from '@/global/core/chat/api';
import {
GetHistoriesBodySchema,
GetHistoriesResponseSchema,
type GetHistoriesResponseType
} from '@fastgpt/global/openapi/core/chat/history/api';
import { parsePaginationRequest } from '@fastgpt/service/common/api/pagination';
import { addMonths } from 'date-fns';
export type getHistoriesQuery = {};
export type getHistoriesBody = PaginationProps<GetHistoriesProps>;
export type getHistoriesResponse = {};
/* get chat histories list */
async function handler(
req: ApiRequestProps<getHistoriesBody, getHistoriesQuery>,
_res: ApiResponseType<any>
): Promise<PaginationResponse<getHistoriesResponse>> {
req: ApiRequestProps,
_res: ApiResponseType
): Promise<GetHistoriesResponseType> {
const {
appId,
shareId,
@ -31,7 +29,7 @@ async function handler(
endCreateTime,
startUpdateTime,
endUpdateTime
} = req.body;
} = GetHistoriesBodySchema.parse(req.body);
const { offset, pageSize } = parsePaginationRequest(req);
const match = await (async () => {
@ -86,7 +84,7 @@ async function handler(
};
}
const mergeMatch = { ...match, ...timeMatch };
const mergeMatch = { ...match, ...timeMatch, deleteTime: null };
const [data, total] = await Promise.all([
await MongoChat.find(mergeMatch, 'chatId title top customTitle appId updateTime')
@ -97,7 +95,7 @@ async function handler(
MongoChat.countDocuments(mergeMatch)
]);
return {
return GetHistoriesResponseSchema.parse({
list: data.map((item) => ({
chatId: item.chatId,
updateTime: item.updateTime,
@ -107,7 +105,7 @@ async function handler(
top: item.top
})),
total
};
});
}
export default NextAPI(handler);

View File

@ -1,15 +1,14 @@
import type { NextApiResponse } from 'next';
import { jsonRes } from '@fastgpt/service/common/response';
import { type UpdateHistoryProps } from '@/global/core/chat/api.d';
import { UpdateHistoryBodySchema } from '@fastgpt/global/openapi/core/chat/history/api';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { authChatCrud } from '@/service/support/permission/auth/chat';
import { NextAPI } from '@/service/middleware/entry';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
/* update chat top, custom title */
async function handler(req: ApiRequestProps<UpdateHistoryProps>, res: NextApiResponse) {
const { appId, chatId, title, customTitle, top } = req.body;
/* update chat history: title, customTitle, top */
async function handler(req: ApiRequestProps, res: NextApiResponse) {
const { appId, chatId, title, customTitle, top } = UpdateHistoryBodySchema.parse(req.body);
await authChatCrud({
req,
authToken: true,
@ -18,7 +17,7 @@ async function handler(req: ApiRequestProps<UpdateHistoryProps>, res: NextApiRes
per: WritePermissionVal
});
await MongoChat.findOneAndUpdate(
await MongoChat.updateOne(
{ appId, chatId },
{
updateTime: new Date(),
@ -27,7 +26,6 @@ async function handler(req: ApiRequestProps<UpdateHistoryProps>, res: NextApiRes
...(top !== undefined && { top })
}
);
jsonRes(res);
}
export default NextAPI(handler);

View File

@ -6,7 +6,7 @@ import { getErrText } from '@fastgpt/global/common/error/utils';
import type { AppTTSConfigType } from '@fastgpt/global/core/app/type.d';
import { TTSTypeEnum } from '@/web/core/app/constants';
import { useTranslation } from 'next-i18next';
import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat.d';
import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import { useMount } from 'ahooks';
import { getWebReqUrl } from '@fastgpt/web/common/system/utils';

View File

@ -1,23 +1,14 @@
import { GET, POST, DELETE, PUT } from '@/web/common/api/request';
import type { ChatHistoryItemType, ChatHistoryItemResType } from '@fastgpt/global/core/chat/type.d';
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type.d';
import type { getResDataQuery } from '@/pages/api/core/chat/getResData';
import type {
InitChatProps,
InitChatResponse,
InitOutLinkChatProps,
GetHistoriesProps,
InitTeamChatProps
} from '@/global/core/chat/api.d';
import type {
ClearHistoriesProps,
DelHistoryProps,
DeleteChatItemProps,
UpdateHistoryProps
} from '@/global/core/chat/api.d';
import type { AuthTeamTagTokenProps } from '@fastgpt/global/support/user/team/tag';
import type { AppListItemType } from '@fastgpt/global/core/app/type';
import type { PaginationProps, PaginationResponse } from '@fastgpt/web/common/fetch/type';
import type { DeleteChatItemProps } from '@/global/core/chat/api.d';
import type {
getChatRecordsBody,
getChatRecordsResponse
@ -44,11 +35,6 @@ export const getInitOutLinkChatInfo = (data: InitOutLinkChatProps) =>
export const getTeamChatInfo = (data: InitTeamChatProps) =>
GET<InitChatResponse>(`/core/chat/team/init`, data);
/**
* get current window history(appid or shareId)
*/
export const getChatHistories = (data: PaginationProps<GetHistoriesProps>) =>
POST<PaginationResponse<ChatHistoryItemType>>('/core/chat/getHistories', data);
/**
* get detail responseData by dataId appId chatId
*/
@ -58,42 +44,12 @@ export const getChatResData = (data: getResDataQuery) =>
export const getChatRecords = (data: getChatRecordsBody) =>
POST<getChatRecordsResponse>('/core/chat/getRecords_v2', data);
/**
* delete one history
*/
export const delChatHistoryById = (data: DelHistoryProps) => DELETE(`/core/chat/delHistory`, data);
/**
* clear all history by appid
*/
export const delClearChatHistories = (data: ClearHistoriesProps) =>
DELETE(`/core/chat/clearHistories`, data);
/**
* delete one chat record
*/
export const delChatRecordById = (data: DeleteChatItemProps) =>
POST(`/core/chat/item/delete`, data);
/**
* 修改历史记录: 标题/
*/
export const putChatHistory = (data: UpdateHistoryProps) => PUT('/core/chat/updateHistory', data);
/* team chat */
/**
* Get the app that can be used with this token
*/
export const getMyTokensApps = (data: AuthTeamTagTokenProps) =>
GET<AppListItemType[]>(`/proApi/support/user/team/tag/getAppsByTeamTokens`, data);
/**
* initTeamChat
* @param data
* @returns
*/
export const getinitTeamChat = (data: { teamId: string; authToken: string; appId: string }) =>
GET(`/proApi/core/chat/initTeamChat`, data);
export const getQuoteDataList = (data: GetQuoteProps) =>
POST<GetQuotesRes>(`/core/chat/quote/getQuote`, data);

View File

@ -7,19 +7,15 @@ import {
delChatHistoryById,
putChatHistory,
getChatHistories
} from '../api';
} from '../history/api';
import { type ChatHistoryItemType } from '@fastgpt/global/core/chat/type';
import { type UpdateHistoryProps } from '@/global/core/chat/api';
import { type BoxProps, useDisclosure } from '@chakra-ui/react';
import { useChatStore } from './useChatStore';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination';
import type { UpdateHistoryBodyType } from '@fastgpt/global/openapi/core/chat/history/api';
type UpdateHistoryParams = {
chatId: UpdateHistoryProps['chatId'];
customTitle?: UpdateHistoryProps['customTitle'];
top?: UpdateHistoryProps['top'];
};
type UpdateHistoryParams = Pick<UpdateHistoryBodyType, 'chatId' | 'customTitle' | 'top'>;
type ChatContextValueType = {
params: Record<string, string | number | boolean>;

View File

@ -0,0 +1,28 @@
import { POST, PUT, DELETE } from '@/web/common/api/request';
import type {
ChatBatchDeleteBodyType,
DelChatHistoryType,
ClearChatHistoriesType,
GetHistoriesBodyType,
GetHistoriesResponseType,
UpdateHistoryBodyType
} from '@fastgpt/global/openapi/core/chat/history/api';
export const getChatHistories = (data: GetHistoriesBodyType) =>
POST<GetHistoriesResponseType>('/core/chat/history/getHistories', data);
// 修改历史记录: 标题/置顶
export const putChatHistory = (data: UpdateHistoryBodyType) =>
PUT('/core/chat/history/updateHistory', data);
// delete one history (soft delete)
export const delChatHistoryById = (data: DelChatHistoryType) =>
DELETE(`/core/chat/history/delHistory`, data);
// clear all history by appId
export const delClearChatHistories = (data: ClearChatHistoriesType) =>
DELETE(`/core/chat/history/clearHistories`, data);
// Log manger
export const batchDeleteChatHistories = (data: ChatBatchDeleteBodyType) =>
POST<void>(`/core/chat/history/batchDelete`, data);

View File

@ -0,0 +1,348 @@
import handler from '@/pages/api/core/chat/history/batchDelete';
import type { ChatBatchDeleteBodyType } from '@fastgpt/global/openapi/core/chat/history/api';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { ChatRoleEnum, ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema';
import { MongoChatItemResponse } from '@fastgpt/service/core/chat/chatItemResponseSchema';
import { getUser } from '@test/datas/users';
import { Call } from '@test/utils/request';
import { describe, expect, it, beforeEach } from 'vitest';
import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema';
import { AppReadChatLogPerVal } from '@fastgpt/global/support/permission/app/constant';
import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant';
describe('batchDelete api test', () => {
let testUser: Awaited<ReturnType<typeof getUser>>;
let appId: string;
let chatIds: string[];
beforeEach(async () => {
testUser = await getUser('test-user-batch-delete');
// Create test app
const app = await MongoApp.create({
name: 'Test App',
type: AppTypeEnum.simple,
teamId: testUser.teamId,
tmbId: testUser.tmbId,
modules: []
});
appId = String(app._id);
// Create log permission
await MongoResourcePermission.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
resourceId: appId,
permission: AppReadChatLogPerVal,
resourceType: PerResourceTypeEnum.app
});
// Create multiple chats
chatIds = [getNanoid(), getNanoid(), getNanoid()];
await Promise.all(
chatIds.map((chatId) =>
MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
appId,
chatId,
source: ChatSourceEnum.test,
title: `Test Chat ${chatId}`
})
)
);
// Create chat items for each chat
await Promise.all(
chatIds.map((chatId) =>
MongoChatItem.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
appId,
chatId,
dataId: getNanoid(),
obj: ChatRoleEnum.AI,
value: [
{
type: 'text',
text: {
content: `Response for ${chatId}`
}
}
]
})
)
);
// Create chat item responses for each chat
await Promise.all(
chatIds.map((chatId) =>
MongoChatItemResponse.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
appId,
chatId,
dataId: getNanoid(),
text: `Response text for ${chatId}`
})
)
);
});
it('should batch delete multiple chats successfully', async () => {
const deleteIds = [chatIds[0], chatIds[1]];
const res = await Call<ChatBatchDeleteBodyType, {}>(handler, {
auth: testUser,
body: {
appId,
chatIds: deleteIds
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
// Verify that chats were deleted
const remainingChats = await MongoChat.find({
appId,
chatId: { $in: deleteIds }
});
expect(remainingChats).toHaveLength(0);
// Verify that chat items were deleted
const remainingChatItems = await MongoChatItem.find({
appId,
chatId: { $in: deleteIds }
});
expect(remainingChatItems).toHaveLength(0);
// Verify that chat item responses were deleted
const remainingChatItemResponses = await MongoChatItemResponse.find({
appId,
chatId: { $in: deleteIds }
});
expect(remainingChatItemResponses).toHaveLength(0);
// Verify that non-deleted chat still exists
const nonDeletedChat = await MongoChat.findOne({
appId,
chatId: chatIds[2]
});
expect(nonDeletedChat).toBeDefined();
});
it('should delete single chat', async () => {
const deleteIds = [chatIds[0]];
const res = await Call<ChatBatchDeleteBodyType, {}>(handler, {
auth: testUser,
body: {
appId,
chatIds: deleteIds
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
// Verify that chat was deleted
const deletedChat = await MongoChat.findOne({
appId,
chatId: chatIds[0]
});
expect(deletedChat).toBeNull();
// Verify that other chats still exist
const remainingChats = await MongoChat.find({
appId,
chatId: { $in: [chatIds[1], chatIds[2]] }
});
expect(remainingChats).toHaveLength(2);
});
it('should delete all chats when all chatIds are provided', async () => {
const res = await Call<ChatBatchDeleteBodyType, {}>(handler, {
auth: testUser,
body: {
appId,
chatIds
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
// Verify that all chats were deleted
const remainingChats = await MongoChat.find({
appId,
chatId: { $in: chatIds }
});
expect(remainingChats).toHaveLength(0);
});
it('should fail when chatIds is empty array', async () => {
const res = await Call<ChatBatchDeleteBodyType, {}>(handler, {
auth: testUser,
body: {
appId,
chatIds: []
}
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
});
it('should fail when chatIds is not an array', async () => {
const res = await Call<any, {}, any>(handler, {
auth: testUser,
body: {
appId,
chatIds: 'not-an-array'
}
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
});
it('should fail when appId is missing', async () => {
const res = await Call<ChatBatchDeleteBodyType, {}>(handler, {
auth: testUser,
body: {
appId: '',
chatIds: [chatIds[0]]
}
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
});
it('should fail when user does not have permission', async () => {
const unauthorizedUser = await getUser('unauthorized-user-batch-delete');
const res = await Call<ChatBatchDeleteBodyType, {}>(handler, {
auth: unauthorizedUser,
body: {
appId,
chatIds: [chatIds[0]]
}
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
});
it('should succeed even when some chatIds do not exist', async () => {
const nonExistentChatId = getNanoid();
const deleteIds = [chatIds[0], nonExistentChatId];
const res = await Call<ChatBatchDeleteBodyType, {}>(handler, {
auth: testUser,
body: {
appId,
chatIds: deleteIds
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
// Verify that existing chat was deleted
const deletedChat = await MongoChat.findOne({
appId,
chatId: chatIds[0]
});
expect(deletedChat).toBeNull();
});
it('should only delete chats belonging to the specified app', async () => {
// Create another app with a chat
const otherApp = await MongoApp.create({
name: 'Other App',
type: AppTypeEnum.simple,
teamId: testUser.teamId,
tmbId: testUser.tmbId,
modules: []
});
const otherAppId = String(otherApp._id);
const otherChatId = getNanoid();
await MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
appId: otherAppId,
chatId: otherChatId,
source: ChatSourceEnum.test,
title: 'Other App Chat'
});
// Try to delete a chat from the first app
const res = await Call<ChatBatchDeleteBodyType, {}>(handler, {
auth: testUser,
body: {
appId,
chatIds: [chatIds[0], otherChatId]
}
});
expect(res.code).toBe(200);
// Verify that only the chat from the specified app was deleted
const deletedChat = await MongoChat.findOne({
appId,
chatId: chatIds[0]
});
expect(deletedChat).toBeNull();
// Verify that the other app's chat still exists
const otherChat = await MongoChat.findOne({
appId: otherAppId,
chatId: otherChatId
});
expect(otherChat).toBeDefined();
});
it('should handle large batch of chatIds', async () => {
// Create 20 more chats
const largeBatch = Array.from({ length: 20 }, () => getNanoid());
await Promise.all(
largeBatch.map((chatId) =>
MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
appId,
chatId,
source: ChatSourceEnum.test,
title: `Test Chat ${chatId}`
})
)
);
const res = await Call<ChatBatchDeleteBodyType, {}>(handler, {
auth: testUser,
body: {
appId,
chatIds: largeBatch
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
// Verify that all chats were deleted
const remainingChats = await MongoChat.find({
appId,
chatId: { $in: largeBatch }
});
expect(remainingChats).toHaveLength(0);
});
});

View File

@ -0,0 +1,222 @@
import handler from '@/pages/api/core/chat/history/clearHistories';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { getUser } from '@test/datas/users';
import { Call } from '@test/utils/request';
import { describe, expect, it, beforeEach } from 'vitest';
import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema';
import { AppReadChatLogPerVal } from '@fastgpt/global/support/permission/app/constant';
import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant';
describe('clearHistories api test', () => {
let testUser: Awaited<ReturnType<typeof getUser>>;
let appId: string;
let chatIds: string[];
beforeEach(async () => {
testUser = await getUser('test-user-clear-histories');
// Create test app
const app = await MongoApp.create({
name: 'Test App',
type: AppTypeEnum.simple,
teamId: testUser.teamId,
tmbId: testUser.tmbId,
modules: []
});
appId = String(app._id);
// Create log permission
await MongoResourcePermission.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
resourceId: appId,
permission: AppReadChatLogPerVal,
resourceType: PerResourceTypeEnum.app
});
// Create multiple chats
chatIds = [getNanoid(), getNanoid(), getNanoid()];
await Promise.all(
chatIds.map((chatId) =>
MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
appId,
chatId,
source: ChatSourceEnum.online
})
)
);
});
it('should clear all chat histories with token auth successfully', async () => {
const res = await Call<any, any, any>(handler, {
auth: testUser,
query: {
appId
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
// Verify that all chats were soft deleted
const chats = await MongoChat.find(
{
appId,
chatId: { $in: chatIds }
},
{ deleteTime: 1 }
);
chats.forEach((chat) => {
expect(chat.deleteTime).toBeDefined();
expect(chat.deleteTime).toBeInstanceOf(Date);
});
});
it('should only clear chats for the specific user', async () => {
// Create another user's chat
const otherUser = await getUser('other-user-clear-histories');
const otherChatId = getNanoid();
await MongoChat.create({
teamId: otherUser.teamId,
tmbId: otherUser.tmbId,
appId,
chatId: otherChatId,
source: ChatSourceEnum.online
});
// Clear current user's chats
const res = await Call<any, any, any>(handler, {
auth: testUser,
query: {
appId
}
});
expect(res.code).toBe(200);
// Verify that current user's chats were deleted
const userChats = await MongoChat.find(
{
appId,
chatId: { $in: chatIds }
},
{ deleteTime: 1 }
);
userChats.forEach((chat) => {
expect(chat.deleteTime).toBeDefined();
});
// Verify that other user's chat was NOT deleted
const otherChat = await MongoChat.findOne(
{
appId,
chatId: otherChatId
},
{ deleteTime: 1 }
);
expect(otherChat?.deleteTime).toBeNull();
});
it('should filter by source when clearing API chats', async () => {
// Create API source chat
const apiChatId = getNanoid();
await MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
appId,
chatId: apiChatId,
source: ChatSourceEnum.api
});
// Clear with API key (simulated by authType in query)
const res = await Call<any, any, any>(handler, {
auth: { ...testUser, authType: 'apikey' },
query: {
appId
}
});
expect(res.code).toBe(200);
// Verify API chat was cleared
const apiChat = await MongoChat.findOne(
{
appId,
chatId: apiChatId
},
{ deleteTime: 1 }
);
expect(apiChat?.deleteTime).toBeDefined();
// Online chats should not be affected (different source)
const onlineChats = await MongoChat.find(
{
appId,
chatId: { $in: chatIds }
},
{ deleteTime: 1 }
);
// Since we're using apikey auth, it will clear chats with api source
// The online chats should not be affected
onlineChats.forEach((chat) => {
expect(chat.deleteTime).toBeNull();
});
});
it('should fail when appId is missing', async () => {
const res = await Call<any, any, any>(handler, {
auth: testUser,
query: {}
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
});
it('should fail when user does not have permission', async () => {
const unauthorizedUser = await getUser('unauthorized-user-clear-histories');
const res = await Call<any, any, any>(handler, {
auth: unauthorizedUser,
query: {
appId
}
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
});
it('should succeed even when there are no chats to clear', async () => {
// Create a new app with no chats
const newApp = await MongoApp.create({
name: 'Empty App',
type: AppTypeEnum.simple,
teamId: testUser.teamId,
tmbId: testUser.tmbId,
modules: []
});
const emptyAppId = String(newApp._id);
const res = await Call<any, any, any>(handler, {
auth: testUser,
query: {
appId: emptyAppId
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
});
});

View File

@ -0,0 +1,143 @@
import handler from '@/pages/api/core/chat/history/delHistory';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { getUser } from '@test/datas/users';
import { Call } from '@test/utils/request';
import { describe, expect, it, beforeEach } from 'vitest';
import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema';
import { AppReadChatLogPerVal } from '@fastgpt/global/support/permission/app/constant';
import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant';
describe('delHistory api test', () => {
let testUser: Awaited<ReturnType<typeof getUser>>;
let appId: string;
let chatId: string;
beforeEach(async () => {
testUser = await getUser('test-user-del-history');
// Create test app
const app = await MongoApp.create({
name: 'Test App',
type: AppTypeEnum.simple,
teamId: testUser.teamId,
tmbId: testUser.tmbId,
modules: []
});
appId = String(app._id);
// Create log permission
await MongoResourcePermission.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
resourceId: appId,
permission: AppReadChatLogPerVal,
resourceType: PerResourceTypeEnum.app
});
chatId = getNanoid();
// Create chat
await MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
appId,
chatId,
source: ChatSourceEnum.test
});
});
it('should soft delete chat history successfully', async () => {
// Verify chat exists before deletion
const chatBefore = await MongoChat.findOne(
{
appId,
chatId
},
{
deleteTime: 1
}
).lean();
expect(chatBefore).toBeDefined();
expect(chatBefore?.deleteTime).toBeNull();
const res = await Call<any, { appId: string; chatId: string }, any>(handler, {
auth: testUser,
query: {
appId,
chatId
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
// Verify that chat was soft deleted (deleteTime is set)
const deletedChat = await MongoChat.findOne(
{
appId,
chatId
},
{ deleteTime: 1 }
).lean();
expect(deletedChat).toBeDefined();
expect(deletedChat?.deleteTime).not.toBeNull();
});
it('should fail when chatId is missing', async () => {
const res = await Call<any, { appId: string; chatId?: string }, any>(handler, {
auth: testUser,
query: {
appId
}
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
});
it('should fail when appId is missing', async () => {
const res = await Call<any, { appId?: string; chatId: string }, any>(handler, {
auth: testUser,
query: {
chatId
}
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
});
it('should fail when user does not have permission', async () => {
const unauthorizedUser = await getUser('unauthorized-user-del-history');
const res = await Call<any, { appId: string; chatId: string }, any>(handler, {
auth: unauthorizedUser,
query: {
appId,
chatId
}
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
});
it('should succeed even if chat does not exist', async () => {
const nonExistentChatId = getNanoid();
const res = await Call<any, { appId: string; chatId: string }, any>(handler, {
auth: testUser,
query: {
appId,
chatId: nonExistentChatId
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
});
});

View File

@ -0,0 +1,344 @@
import handler from '@/pages/api/core/chat/history/getHistories';
import type {
GetHistoriesBodyType,
GetHistoriesResponseType
} from '@fastgpt/global/openapi/core/chat/history/api';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { getUser } from '@test/datas/users';
import { Call } from '@test/utils/request';
import { describe, expect, it, beforeEach } from 'vitest';
import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema';
import { AppReadChatLogPerVal } from '@fastgpt/global/support/permission/app/constant';
import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant';
describe('getHistories api test', () => {
let testUser: Awaited<ReturnType<typeof getUser>>;
let appId: string;
let chatIds: string[];
beforeEach(async () => {
testUser = await getUser('test-user-get-histories');
// Create test app
const app = await MongoApp.create({
name: 'Test App',
type: AppTypeEnum.simple,
teamId: testUser.teamId,
tmbId: testUser.tmbId,
modules: []
});
appId = String(app._id);
// Create log permission
await MongoResourcePermission.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
resourceId: appId,
permission: AppReadChatLogPerVal,
resourceType: PerResourceTypeEnum.app
});
// Create multiple chats with different attributes
chatIds = [getNanoid(), getNanoid(), getNanoid()];
await Promise.all([
MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
appId,
chatId: chatIds[0],
source: ChatSourceEnum.online,
title: 'Chat 1',
top: true
}),
MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
appId,
chatId: chatIds[1],
source: ChatSourceEnum.online,
title: 'Chat 2',
customTitle: 'Custom Chat 2'
}),
MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
appId,
chatId: chatIds[2],
source: ChatSourceEnum.online,
title: 'Chat 3'
})
]);
});
it('should get chat histories successfully', async () => {
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
auth: testUser,
body: {
appId
},
query: {
offset: 0,
pageSize: 10
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
expect(res.data).toBeDefined();
expect(res.data.list).toHaveLength(3);
expect(res.data.total).toBe(3);
// Verify top chat is first
expect(res.data.list[0].top).toBe(true);
});
it('should return paginated results', async () => {
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
auth: testUser,
body: {
appId
},
query: {
offset: 0,
pageSize: 2
}
});
expect(res.code).toBe(200);
expect(res.data.list).toHaveLength(2);
expect(res.data.total).toBe(3);
// Get second page
const res2 = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
auth: testUser,
body: {
appId
},
query: {
offset: 2,
pageSize: 2
}
});
expect(res2.code).toBe(200);
expect(res2.data.list).toHaveLength(1);
expect(res2.data.total).toBe(3);
});
it('should filter by source', async () => {
// Create API source chat
const apiChatId = getNanoid();
await MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
appId,
chatId: apiChatId,
source: ChatSourceEnum.api,
title: 'API Chat'
});
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
auth: testUser,
body: {
appId,
source: ChatSourceEnum.api
},
query: {
offset: 0,
pageSize: 10
}
});
expect(res.code).toBe(200);
expect(res.data.list).toHaveLength(1);
expect(res.data.list[0].chatId).toBe(apiChatId);
});
it('should filter by createTime range', async () => {
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
auth: testUser,
body: {
appId,
startCreateTime: yesterday.toISOString(),
endCreateTime: tomorrow.toISOString()
},
query: {
offset: 0,
pageSize: 10
}
});
expect(res.code).toBe(200);
expect(res.data.list).toHaveLength(3);
});
it('should filter by updateTime range', async () => {
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
auth: testUser,
body: {
appId,
startUpdateTime: yesterday.toISOString(),
endUpdateTime: tomorrow.toISOString()
},
query: {
offset: 0,
pageSize: 10
}
});
expect(res.code).toBe(200);
expect(res.data.list).toHaveLength(3);
});
it('should exclude soft-deleted chats', async () => {
// Soft delete one chat
await MongoChat.updateOne({ appId, chatId: chatIds[0] }, { deleteTime: new Date() });
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
auth: testUser,
body: {
appId
},
query: {
offset: 0,
pageSize: 10
}
});
expect(res.code).toBe(200);
expect(res.data.list).toHaveLength(2);
expect(res.data.total).toBe(2);
expect(res.data.list.find((chat) => chat.chatId === chatIds[0])).toBeUndefined();
});
it('should only return chats for the specific user', async () => {
// Create another user's chat
const otherUser = await getUser('other-user-get-histories');
const otherChatId = getNanoid();
// Create app for other user
const otherApp = await MongoApp.create({
name: 'Other App',
type: AppTypeEnum.simple,
teamId: otherUser.teamId,
tmbId: otherUser.tmbId,
modules: []
});
await MongoChat.create({
teamId: otherUser.teamId,
tmbId: otherUser.tmbId,
appId: String(otherApp._id),
chatId: otherChatId,
source: ChatSourceEnum.online,
title: 'Other User Chat'
});
// Get current user's chats
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
auth: testUser,
body: {
appId
},
query: {
offset: 0,
pageSize: 10
}
});
expect(res.code).toBe(200);
expect(res.data.list).toHaveLength(3);
expect(res.data.list.find((chat) => chat.chatId === otherChatId)).toBeUndefined();
});
it('should return empty list when appId does not exist', async () => {
const nonExistentAppId = getNanoid(24);
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
auth: testUser,
body: {
appId: nonExistentAppId
},
query: {
offset: 0,
pageSize: 10
}
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
expect(res.error?.name).toBe('ZodError');
});
it('should fail when appId is missing', async () => {
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
auth: testUser,
body: {},
query: {
offset: 0,
pageSize: 10
}
});
expect(res.code).toBe(200);
expect(res.data.list).toHaveLength(0);
});
it('should include all required fields in response', async () => {
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
auth: testUser,
body: {
appId
},
query: {
offset: 0,
pageSize: 10
}
});
expect(res.code).toBe(200);
const firstChat = res.data.list[0];
expect(firstChat).toHaveProperty('chatId');
expect(firstChat).toHaveProperty('updateTime');
expect(firstChat).toHaveProperty('appId');
expect(firstChat).toHaveProperty('title');
expect(firstChat.appId).toBe(appId);
});
it('should order by top and updateTime', async () => {
// Update one chat to be newer
await new Promise((resolve) => setTimeout(resolve, 10));
await MongoChat.updateOne({ appId, chatId: chatIds[2] }, { updateTime: new Date() });
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
auth: testUser,
body: {
appId
},
query: {
offset: 0,
pageSize: 10
}
});
expect(res.code).toBe(200);
// First should be the top chat
expect(res.data.list[0].chatId).toBe(chatIds[0]);
// Second should be the most recently updated non-top chat
expect(res.data.list[1].chatId).toBe(chatIds[2]);
});
});

View File

@ -0,0 +1,218 @@
import handler from '@/pages/api/core/chat/history/updateHistory';
import type { UpdateHistoryBodyType } from '@fastgpt/global/openapi/core/chat/history/api';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { getUser } from '@test/datas/users';
import { Call } from '@test/utils/request';
import { describe, expect, it, beforeEach } from 'vitest';
import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema';
import { AppReadChatLogPerVal } from '@fastgpt/global/support/permission/app/constant';
import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant';
describe('updateHistory api test', () => {
let testUser: Awaited<ReturnType<typeof getUser>>;
let appId: string;
let chatId: string;
beforeEach(async () => {
testUser = await getUser('test-user-update-history');
// Create test app
const app = await MongoApp.create({
name: 'Test App',
type: AppTypeEnum.simple,
teamId: testUser.teamId,
tmbId: testUser.tmbId,
modules: []
});
appId = String(app._id);
// Create log permission
await MongoResourcePermission.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
resourceId: appId,
permission: AppReadChatLogPerVal,
resourceType: PerResourceTypeEnum.app
});
chatId = getNanoid();
// Create chat
await MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
appId,
chatId,
source: ChatSourceEnum.test,
title: 'Original Title'
});
});
it('should update chat title successfully', async () => {
const newTitle = 'Updated Title';
const res = await Call<UpdateHistoryBodyType, {}>(handler, {
auth: testUser,
body: {
appId,
chatId,
title: newTitle
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
// Verify that chat title was updated
const updatedChat = await MongoChat.findOne({
appId,
chatId
});
expect(updatedChat?.title).toBe(newTitle);
});
it('should update customTitle successfully', async () => {
const customTitle = 'Custom Title';
const res = await Call<UpdateHistoryBodyType, {}>(handler, {
auth: testUser,
body: {
appId,
chatId,
customTitle
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
// Verify that customTitle was updated
const updatedChat = await MongoChat.findOne({
appId,
chatId
});
expect(updatedChat?.customTitle).toBe(customTitle);
});
it('should update top status successfully', async () => {
const res = await Call<UpdateHistoryBodyType, {}>(handler, {
auth: testUser,
body: {
appId,
chatId,
top: true
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
// Verify that top was updated
const updatedChat = await MongoChat.findOne({
appId,
chatId
});
expect(updatedChat?.top).toBe(true);
});
it('should update multiple fields at once', async () => {
const newTitle = 'New Title';
const customTitle = 'New Custom Title';
const top = true;
const res = await Call<UpdateHistoryBodyType, {}>(handler, {
auth: testUser,
body: {
appId,
chatId,
title: newTitle,
customTitle,
top
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
// Verify that all fields were updated
const updatedChat = await MongoChat.findOne({
appId,
chatId
});
expect(updatedChat?.title).toBe(newTitle);
expect(updatedChat?.customTitle).toBe(customTitle);
expect(updatedChat?.top).toBe(top);
});
it('should update updateTime when updating', async () => {
const originalChat = await MongoChat.findOne({ appId, chatId });
const originalUpdateTime = originalChat?.updateTime;
// Wait a bit to ensure time difference
await new Promise((resolve) => setTimeout(resolve, 10));
const res = await Call<UpdateHistoryBodyType, {}>(handler, {
auth: testUser,
body: {
appId,
chatId,
title: 'New Title'
}
});
expect(res.code).toBe(200);
const updatedChat = await MongoChat.findOne({ appId, chatId });
expect(updatedChat?.updateTime.getTime()).toBeGreaterThan(originalUpdateTime?.getTime() || 0);
});
it('should fail when chatId is missing', async () => {
const res = await Call<UpdateHistoryBodyType, {}>(handler, {
auth: testUser,
body: {
appId,
chatId: '',
title: 'New Title'
}
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
});
it('should fail when appId is missing', async () => {
const res = await Call<UpdateHistoryBodyType, {}>(handler, {
auth: testUser,
body: {
appId: '',
chatId,
title: 'New Title'
}
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
});
it('should fail when user does not have permission', async () => {
const unauthorizedUser = await getUser('unauthorized-user-update-history');
const res = await Call<UpdateHistoryBodyType, {}>(handler, {
auth: unauthorizedUser,
body: {
appId,
chatId,
title: 'New Title'
}
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
});
});

View File

@ -151,7 +151,16 @@ vi.mock('@fastgpt/service/common/s3/sources/dataset/index', () => ({
}));
vi.mock('@fastgpt/service/common/s3/sources/chat/index', () => ({
S3ChatSource: vi.fn()
S3ChatSource: vi.fn(),
getS3ChatSource: vi.fn(() => ({
createUploadChatFileURL: vi.fn().mockResolvedValue({
url: 'http://localhost:9000/mock-bucket',
fields: { key: 'mock-key' },
maxSize: 5 * 1024 * 1024
}),
deleteChatFilesByPrefix: vi.fn().mockResolvedValue(undefined),
deleteChatFile: vi.fn().mockResolvedValue(undefined)
}))
}));
// Mock S3 initialization