diff --git a/SECURITY.md b/SECURITY.md index 562cf092e..161783fb6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,7 +5,7 @@ 如果您发现了 FastGPT 的安全漏洞,请按照以下步骤进行报告: 1. **报告方式** - 发送邮件至:yujinlong@sealos.io + 发送邮件至:archer@fastgpt.io 请备注版本以及您的 GitHub 账号 3. **响应时间** diff --git a/document/content/docs/protocol/privacy.en.mdx b/document/content/docs/protocol/privacy.en.mdx index 3c1e5a5fd..a312675bb 100644 --- a/document/content/docs/protocol/privacy.en.mdx +++ b/document/content/docs/protocol/privacy.en.mdx @@ -58,5 +58,5 @@ Due to servers potentially being located in different countries/regions, you agr **Contact Us** -1. For any questions, suggestions, or complaints about this policy, contact us at: yujinlong@sealos.io. +1. For any questions, suggestions, or complaints about this policy, contact us at: archer@fastgpt.io. 2. We will respond promptly and address your concerns. diff --git a/document/content/docs/protocol/privacy.mdx b/document/content/docs/protocol/privacy.mdx index 4ad911d79..8cff8f9d8 100644 --- a/document/content/docs/protocol/privacy.mdx +++ b/document/content/docs/protocol/privacy.mdx @@ -58,5 +58,5 @@ description: ' FastGPT 隐私政策' **联系我们** -1. 如您对本隐私政策有任何疑问、建议或投诉,请通过以下方式与我们联系:yujinlong@sealos.io。 +1. 如您对本隐私政策有任何疑问、建议或投诉,请通过以下方式与我们联系:archer@fastgpt.io。 2. 我们将尽快回复并解决您提出的问题。 diff --git a/document/content/docs/protocol/terms.en.mdx b/document/content/docs/protocol/terms.en.mdx index 463b63a92..c52a14bfb 100644 --- a/document/content/docs/protocol/terms.en.mdx +++ b/document/content/docs/protocol/terms.en.mdx @@ -72,4 +72,4 @@ This FastGPT Service Agreement constitutes the terms and conditions agreed betwe **Article 7 Additional Provisions** 1. If any clause is deemed unlawful or invalid, the remaining provisions shall remain enforceable. -2. Sealos retains final authority in interpreting this Agreement and privacy policies. For any inquiries, please contact us at yujinlong@sealos.io. +2. Sealos retains final authority in interpreting this Agreement and privacy policies. For any inquiries, please contact us at archer@fastgpt.io. diff --git a/document/content/docs/protocol/terms.mdx b/document/content/docs/protocol/terms.mdx index 43366c47d..f87cac275 100644 --- a/document/content/docs/protocol/terms.mdx +++ b/document/content/docs/protocol/terms.mdx @@ -67,4 +67,4 @@ FastGPT 服务协议是您与珠海环界云计算有限公司(以下简称“ **第7条 其他条款** 1. 如本协议中部分条款因违反法律法规而被视为无效,不影响其他条款的效力。 -2. 本公司保留对本协议及隐私政策的最终解释权。如您对本协议或隐私政策有任何疑问,请联系我们:yujinlong@sealos.io。 +2. 本公司保留对本协议及隐私政策的最终解释权。如您对本协议或隐私政策有任何疑问,请联系我们:archer@fastgpt.io。 diff --git a/document/content/docs/upgrading/4-14/4144.mdx b/document/content/docs/upgrading/4-14/4144.mdx index 13f618f4a..6cf014a0a 100644 --- a/document/content/docs/upgrading/4-14/4144.mdx +++ b/document/content/docs/upgrading/4-14/4144.mdx @@ -27,13 +27,15 @@ curl --location --request POST 'https://{{host}}/api/admin/initv4144' \ 1. 工具调用支持配置流输出 2. AI 积分告警通知。 3. 对话日志支持展示 IP 地址归属地。 -4. 通过 API 上传本地文件至知识库,保存至 S3。同时将旧版 Gridfs 代码全部移除。 -5. 新版订阅套餐逻辑。 -6. 支持配置对话文件白名单。 -7. S3 支持 pathStyle 和 region 配置。 -8. 支持通过 Sealos 来进行多租户自定义域名配置。 -9. 工作流中引用工具时,文件输入支持手动填写(原本只支持变量引用)。 -10. 支持网络代理(HTTP_PROXY,HTTPS_PROXY) +4. 对话日志支持展示应用版本名(如果对话中途修改成最新版本,则会被修改成最新版本) +5. 对话日志支持按点赞点踩过滤,并在对话详情里可以快速定位到赞/踩的记录。 +6. 通过 API 上传本地文件至知识库,保存至 S3。同时将旧版 Gridfs 代码全部移除。 +7. 新版订阅套餐逻辑。 +8. 支持配置对话文件白名单。 +9. S3 支持 pathStyle 和 region 配置。 +10. 支持通过 Sealos 来进行多租户自定义域名配置。 +11. 工作流中引用工具时,文件输入支持手动填写(原本只支持变量引用)。 +12. 支持网络代理(HTTP_PROXY,HTTPS_PROXY) ## ⚙️ 优化 @@ -44,6 +46,9 @@ curl --location --request POST 'https://{{host}}/api/admin/initv4144' \ 5. LLM 请求时,图片无效报错提示。 6. completions 接口,非 stream 模式, detail=false 时,增加返回 reason_content。 7. 增加对于无效的 S3 key 检测。 +8. 删除应用和知识库时,强制要求输入名称校验。 +9. Mongo 慢操作日志,可以准确打印集合名和操作内容。 +10. 分享链接,自定义鉴权返回的 uid,强制要求长度小于 200(太长会影响文件上传)。 ## 🐛 修复 @@ -64,6 +69,9 @@ curl --location --request POST 'https://{{host}}/api/admin/initv4144' \ 15. 模型头像缺失情况下,默认 huggingface.svg 图标显示错误。 16. 日志导出时,结束时间会多出一天。 17. 表单输入,前端默认值未传递到实体值。 +18. 工具调用时,未传递 max_tokens 参数。 +19. 工作流判断器 value 值,未结合 condition 来综合获取数据类型。 +20. 非直接分块模式的知识库数据,引用阅读器导航顺序异常。引用阅读器只会加载同一页。 ## 插件 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 33fedac3b..e2dc23886 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -97,10 +97,10 @@ "document/content/docs/protocol/index.mdx": "2025-07-30T15:38:30+08:00", "document/content/docs/protocol/open-source.en.mdx": "2025-08-05T23:20:39+08:00", "document/content/docs/protocol/open-source.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/protocol/privacy.en.mdx": "2025-08-03T22:37:45+08:00", - "document/content/docs/protocol/privacy.mdx": "2025-08-03T22:37:45+08:00", - "document/content/docs/protocol/terms.en.mdx": "2025-08-03T22:37:45+08:00", - "document/content/docs/protocol/terms.mdx": "2025-08-03T22:37:45+08:00", + "document/content/docs/protocol/privacy.en.mdx": "2025-12-12T21:30:11+08:00", + "document/content/docs/protocol/privacy.mdx": "2025-12-12T21:30:11+08:00", + "document/content/docs/protocol/terms.en.mdx": "2025-12-12T21:30:11+08:00", + "document/content/docs/protocol/terms.mdx": "2025-12-12T21:30:11+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/upgrading/4-10/4100.mdx": "2025-08-02T19:38:37+08:00", @@ -119,7 +119,7 @@ "document/content/docs/upgrading/4-14/4141.mdx": "2025-11-19T10:15:27+08:00", "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-12T15:28:03+08:00", + "document/content/docs/upgrading/4-14/4144.mdx": "2025-12-15T15:09:13+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", diff --git a/packages/global/core/app/logs/api.d.ts b/packages/global/core/app/logs/api.d.ts deleted file mode 100644 index 63541399c..000000000 --- a/packages/global/core/app/logs/api.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { AppLogTimespanEnum } from './constants'; -import type { AppChatLogAppData, AppChatLogChatData, AppChatLogUserData } from './type'; - -export type getChartDataBody = { - appId: string; - dateStart: Date; - dateEnd: Date; - source?: ChatSourceEnum[]; - offset: number; - userTimespan: AppLogTimespanEnum; - chatTimespan: AppLogTimespanEnum; - appTimespan: AppLogTimespanEnum; -}; - -export type getChartDataResponse = { - userData: AppChatLogUserData; - chatData: AppChatLogChatData; - appData: AppChatLogAppData; -}; - -export type getTotalDataQuery = { - appId: string; -}; - -export type getTotalDataResponse = { - totalUsers: number; - totalChats: number; - totalPoints: number; -}; diff --git a/packages/global/core/app/logs/constants.ts b/packages/global/core/app/logs/constants.ts index 176e73c15..e980b18a2 100644 --- a/packages/global/core/app/logs/constants.ts +++ b/packages/global/core/app/logs/constants.ts @@ -14,7 +14,8 @@ export enum AppLogKeysEnum { POINTS = 'points', RESPONSE_TIME = 'responseTime', ERROR_COUNT = 'errorCount', - REGION = 'region' + REGION = 'region', + VERSION_NAME = 'versionName' } export const AppLogKeysEnumMap = { @@ -31,7 +32,8 @@ export const AppLogKeysEnumMap = { [AppLogKeysEnum.POINTS]: i18nT('app:logs_keys_points'), [AppLogKeysEnum.RESPONSE_TIME]: i18nT('app:logs_keys_responseTime'), [AppLogKeysEnum.ERROR_COUNT]: i18nT('app:logs_keys_errorCount'), - [AppLogKeysEnum.REGION]: i18nT('app:logs_keys_region') + [AppLogKeysEnum.REGION]: i18nT('app:logs_keys_region'), + [AppLogKeysEnum.VERSION_NAME]: i18nT('app:logs_keys_versionName') }; export const DefaultAppLogKeys = [ @@ -48,7 +50,8 @@ export const DefaultAppLogKeys = [ { key: AppLogKeysEnum.POINTS, enable: false }, { key: AppLogKeysEnum.RESPONSE_TIME, enable: false }, { key: AppLogKeysEnum.ERROR_COUNT, enable: false }, - { key: AppLogKeysEnum.REGION, enable: true } + { key: AppLogKeysEnum.REGION, enable: true }, + { key: AppLogKeysEnum.VERSION_NAME, enable: false } ]; export enum AppLogTimespanEnum { diff --git a/packages/global/core/app/logs/type.d.ts b/packages/global/core/app/logs/type.d.ts deleted file mode 100644 index d02eb1361..000000000 --- a/packages/global/core/app/logs/type.d.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { ChatSourceEnum } from '../../core/chat/constants'; -import type { AppLogKeysEnum } from './constants'; - -export type AppLogKeysType = { - key: AppLogKeysEnum; - enable: boolean; -}; - -export type AppLogKeysSchemaType = { - teamId: string; - appId: string; - logKeys: AppLogKeysType[]; -}; - -export type AppChatLogSchema = { - _id: string; - appId: string; - teamId: string; - chatId: string; - userId: string; - source: string; - sourceName?: string; - createTime: Date; - updateTime: Date; - - chatItemCount: number; - errorCount: number; - totalPoints: number; - goodFeedbackCount: number; - badFeedbackCount: number; - totalResponseTime: number; - - isFirstChat: boolean; // whether this is the user's first session in the app -}; - -export type AppChatLogUserData = { - timestamp: number; - summary: { - userCount: number; - newUserCount: number; - retentionUserCount: number; - points: number; - sourceCountMap: Record; - }; -}[]; - -export type AppChatLogChatData = { - timestamp: number; - summary: { - chatItemCount: number; - chatCount: number; - errorCount: number; - points: number; - }; -}[]; - -export type AppChatLogAppData = { - timestamp: number; - summary: { - goodFeedBackCount: number; - badFeedBackCount: number; - chatCount: number; - totalResponseTime: number; - }; -}[]; diff --git a/packages/global/core/app/logs/type.ts b/packages/global/core/app/logs/type.ts new file mode 100644 index 000000000..2bb9143bf --- /dev/null +++ b/packages/global/core/app/logs/type.ts @@ -0,0 +1,39 @@ +import { ObjectIdSchema } from '../../../common/type/mongo'; +import { ChatSourceEnum } from '../../chat/constants'; +import { AppLogKeysEnum } from './constants'; +import { z } from 'zod'; + +export const AppLogKeysSchema = z.object({ + key: z.enum(AppLogKeysEnum), + enable: z.boolean() +}); +export type AppLogKeysType = z.infer; + +export const AppLogKeysSchemaType = z.object({ + teamId: z.string(), + appId: z.string(), + logKeys: z.array(AppLogKeysSchema) +}); +export type AppLogKeysSchemaType = z.infer; + +export const AppChatLogSchema = z.object({ + _id: ObjectIdSchema, + appId: ObjectIdSchema, + teamId: ObjectIdSchema, + chatId: z.string(), + userId: z.string(), + source: z.enum(ChatSourceEnum), + sourceName: z.string().optional(), + createTime: z.date(), + updateTime: z.date(), + + chatItemCount: z.number(), + errorCount: z.number(), + totalPoints: z.number(), + goodFeedbackCount: z.number(), + badFeedbackCount: z.number(), + totalResponseTime: z.number(), + + isFirstChat: z.boolean() // whether this is the user's first session in the app +}); +export type AppChatLogSchema = z.infer; diff --git a/packages/global/core/chat/type.d.ts b/packages/global/core/chat/type.d.ts index bf8eea596..7392d97b2 100644 --- a/packages/global/core/chat/type.d.ts +++ b/packages/global/core/chat/type.d.ts @@ -28,6 +28,7 @@ export type ChatSchemaType = { teamId: string; tmbId: string; appId: string; + appVersionId?: string; createTime: Date; updateTime: Date; title: string; @@ -44,6 +45,12 @@ export type ChatSchemaType = { variables: Record; pluginInputs?: FlowNodeInputItemType[]; metadata?: Record; + + // Boolean flags for efficient filtering + hasGoodFeedback?: boolean; + hasBadFeedback?: boolean; + hasUnreadGoodFeedback?: boolean; + hasUnreadBadFeedback?: boolean; }; export type ChatWithAppSchema = Omit & { @@ -105,6 +112,7 @@ export type AIChatItemType = { userBadFeedback?: string; customFeedbacks?: string[]; adminFeedback?: AdminFbkType; + isFeedbackRead?: boolean; durationSeconds?: number; errorMsg?: string; @@ -152,6 +160,7 @@ export type ChatItemType = ChatItemMergeType & { // Frontend type export type ChatSiteItemType = ChatItemMergeType & { _id?: string; + id: string; dataId: string; status: `${ChatStatusEnum}`; moduleName?: string; diff --git a/packages/global/openapi/core/app/index.ts b/packages/global/openapi/core/app/index.ts new file mode 100644 index 000000000..77c353c68 --- /dev/null +++ b/packages/global/openapi/core/app/index.ts @@ -0,0 +1,6 @@ +import type { OpenAPIPath } from '../../type'; +import { AppLogPath } from './log'; + +export const AppPath: OpenAPIPath = { + ...AppLogPath +}; diff --git a/packages/global/openapi/core/app/log/api.ts b/packages/global/openapi/core/app/log/api.ts new file mode 100644 index 000000000..22c1a7f72 --- /dev/null +++ b/packages/global/openapi/core/app/log/api.ts @@ -0,0 +1,248 @@ +import { z } from 'zod'; +import { PaginationSchema } from '../../../api'; +import { AppLogKeysEnum, AppLogTimespanEnum } from '../../../../core/app/logs/constants'; +import { ChatSourceEnum } from '../../../../core/chat/constants'; +import { AppLogKeysSchema } from '../../../../core/app/logs/type'; +import { SourceMemberSchema } from '../../../../support/user/type'; + +/* Log key mange */ +export const GetLogKeysQuerySchema = z.object({ + appId: z.string().meta({ example: '68ad85a7463006c963799a05', description: '应用 ID' }) +}); +export type getLogKeysQuery = z.infer; + +export const GetLogKeysResponseSchema = z.object({ + logKeys: z + .array(AppLogKeysSchema) + .default([]) + .meta({ example: [AppLogKeysEnum.SOURCE, AppLogKeysEnum.CREATED_TIME], description: '日志键' }) +}); +export type getLogKeysResponseType = z.infer; + +export const UpdateLogKeysBodySchema = z.object({ + appId: z.string().meta({ example: '68ad85a7463006c963799a05', description: '应用 ID' }), + logKeys: z + .array(AppLogKeysSchema) + .meta({ example: [AppLogKeysEnum.SOURCE, AppLogKeysEnum.CREATED_TIME], description: '日志键' }) +}); +export type updateLogKeysBody = z.infer; + +// Chat Log Item Schema (based on AppChatLogSchema) +export const ChatLogItemSchema = z.object({ + _id: z.string().meta({ example: '68ad85a7463006c963799a05', description: '对话日志 ID' }), + chatId: z.string().meta({ example: 'chat123', description: '对话 ID' }), + 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: '来源名称' }), + 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: '消息数量' }), + userGoodFeedbackCount: z.int().nullish().meta({ example: 3, description: '好评反馈数量' }), + userBadFeedbackCount: z.int().nullish().meta({ example: 1, description: '差评反馈数量' }), + customFeedbacksCount: z.int().nullish().meta({ example: 2, description: '自定义反馈数量' }), + markCount: z.int().nullish().meta({ example: 0, description: '标记数量' }), + averageResponseTime: z + .number() + .nullish() + .meta({ example: 1500, description: '平均响应时间(毫秒)' }), + errorCount: z.int().nullish().meta({ example: 0, description: '错误次数' }), + 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: '来源成员信息' }), + versionName: z.string().nullish().meta({ example: 'v1.0.0', description: '版本名称' }), + region: z.string().nullish().meta({ example: '中国', description: '区域' }) +}); +export type AppLogsListItemType = z.infer; + +/* Get chat logs */ +const FeedbackLogParamSchema = z.object({ + feedbackType: z.enum(['all', 'has_feedback', 'good', 'bad']).optional().meta({ + example: 'good', + description: '反馈类型:all-全部记录,has_feedback-包含反馈,good-包含赞,bad-包含踩' + }), + unreadOnly: z.boolean().optional().meta({ + example: false, + description: '是否仅显示未读反馈(当 feedbackType 为 all 时忽略)' + }) +}); +// Get App Chat Logs Query Parameters (based on GetAppChatLogsProps) +export const GetAppChatLogsBodySchema = PaginationSchema.extend( + FeedbackLogParamSchema.shape +).extend({ + appId: z.string().meta({ + example: '68ad85a7463006c963799a05', + description: '应用 ID' + }), + dateStart: z.union([z.string(), z.date()]).meta({ + example: '2024-01-01T00:00:00.000Z', + description: '开始时间' + }), + dateEnd: z.union([z.string(), z.date()]).meta({ + example: '2024-12-31T23:59:59.999Z', + description: '结束时间' + }), + sources: z + .array(z.nativeEnum(ChatSourceEnum)) + .optional() + .meta({ + example: [ChatSourceEnum.api, ChatSourceEnum.online], + description: '对话来源筛选' + }), + tmbIds: z + .array(z.string()) + .optional() + .meta({ + example: ['tmb123', 'tmb456'], + description: '团队成员 ID 列表' + }), + chatSearch: z.string().optional().meta({ + example: 'hello', + description: '对话内容搜索关键词' + }) +}); +export type getAppChatLogsBody = z.infer; +// Get App Chat Logs Response +export const GetAppChatLogsResponseSchema = z + .object({ + total: z.number().meta({ example: 100, description: '总记录数' }), + list: z.array(ChatLogItemSchema) + }) + .meta({ example: { total: 100, list: [] }, description: '应用对话日志列表' }); +export type getAppChatLogsResponseType = z.infer; + +/* Export chat log */ +export const ExportChatLogsBodySchema = GetAppChatLogsBodySchema.omit({ + pageSize: true, + offset: true, + pageNum: true +}).safeExtend({ + title: z.string().meta({ + example: 'chat logs', + description: '标题' + }), + sourcesMap: z.record(z.string(), z.object({ label: z.string() })).meta({ + example: { api: { label: 'API' }, online: { label: '在线' } }, + description: '来源映射' + }), + logKeys: z.array(z.enum(AppLogKeysEnum)).meta({ + example: [AppLogKeysEnum.SOURCE, AppLogKeysEnum.CREATED_TIME], + description: '日志键' + }) +}); + +/* Get chart data */ +// Get Chart Data Request Body (based on getChartDataBody) +export const GetChartDataBodySchema = z.object({ + appId: z.string().meta({ + example: '68ad85a7463006c963799a05', + description: '应用 ID' + }), + dateStart: z.date().meta({ + example: '2024-01-01T00:00:00.000Z', + description: '开始日期' + }), + dateEnd: z.date().meta({ + example: '2024-12-31T23:59:59.999Z', + description: '结束日期' + }), + source: z + .array(z.nativeEnum(ChatSourceEnum)) + .optional() + .meta({ + example: [ChatSourceEnum.api, ChatSourceEnum.online], + description: '对话来源筛选' + }), + offset: z.number().meta({ + example: 1, + description: '时区偏移量' + }), + userTimespan: z.nativeEnum(AppLogTimespanEnum).meta({ + example: AppLogTimespanEnum.day, + description: '用户数据时间跨度' + }), + chatTimespan: z.nativeEnum(AppLogTimespanEnum).meta({ + example: AppLogTimespanEnum.day, + description: '对话数据时间跨度' + }), + appTimespan: z.nativeEnum(AppLogTimespanEnum).meta({ + example: AppLogTimespanEnum.day, + description: '应用数据时间跨度' + }) +}); +export type getChartDataBody = z.infer; + +// User Statistics Data Point (based on AppChatLogUserData) +export const UserStatsDataPointSchema = z.object({ + timestamp: z.number().meta({ example: 1704067200, description: '时间戳' }), + summary: z.object({ + userCount: z.number().meta({ example: 100, description: '用户总数' }), + newUserCount: z.number().meta({ example: 30, description: '新用户数' }), + retentionUserCount: z.number().meta({ example: 70, description: '留存用户数' }), + points: z.number().meta({ example: 1500, description: '积分消耗' }), + sourceCountMap: z.record(z.string(), z.number()).meta({ + example: { api: 50, web: 30, mobile: 20 }, + description: '各来源用户数量' + }) + }) +}); +export type userStatsDataPoint = z.infer; + +// Chat Statistics Data Point (based on AppChatLogChatData) +export const ChatStatsDataPointSchema = z.object({ + timestamp: z.number().meta({ example: 1704067200, description: '时间戳' }), + summary: z.object({ + chatItemCount: z.number().meta({ example: 500, description: '对话项目总数' }), + chatCount: z.number().meta({ example: 100, description: '对话会话总数' }), + errorCount: z.number().meta({ example: 5, description: '错误次数' }), + points: z.number().meta({ example: 800, description: '积分消耗' }) + }) +}); +export type chatStatsDataPoint = z.infer; + +// App Statistics Data Point (based on AppChatLogAppData) +export const AppStatsDataPointSchema = z.object({ + timestamp: z.number().meta({ example: 1704067200, description: '时间戳' }), + summary: z.object({ + goodFeedBackCount: z.number().meta({ example: 25, description: '好评反馈数量' }), + badFeedBackCount: z.number().meta({ example: 3, description: '差评反馈数量' }), + chatCount: z.number().meta({ example: 100, description: '对话数量' }), + totalResponseTime: z.number().meta({ example: 120000, description: '总响应时间(毫秒)' }) + }) +}); +export type appStatsDataPoint = z.infer; + +// Get Chart Data Response (based on getChartDataResponse) +export const GetChartDataResponseSchema = z.object({ + userData: z.array(UserStatsDataPointSchema).meta({ description: '用户统计数据' }), + chatData: z.array(ChatStatsDataPointSchema).meta({ description: '对话统计数据' }), + appData: z.array(AppStatsDataPointSchema).meta({ description: '应用统计数据' }) +}); +export type getChartDataResponse = z.infer; + +// Get Total Data Query Parameters (based on getTotalDataQuery) +export const GetTotalDataQuerySchema = z.object({ + appId: z.string().meta({ + example: '68ad85a7463006c963799a05', + description: '应用 ID' + }) +}); +export type getTotalDataQuery = z.infer; + +// Get Total Data Response (based on getTotalDataResponse) +export const GetTotalDataResponseSchema = z.object({ + totalUsers: z.number().meta({ + example: 1000, + description: '总用户数' + }), + totalChats: z.number().meta({ + example: 5000, + description: '总对话数' + }), + totalPoints: z.number().meta({ + example: 15000, + description: '总积分消耗' + }) +}); +export type getTotalDataResponse = z.infer; diff --git a/packages/global/openapi/core/app/log/index.ts b/packages/global/openapi/core/app/log/index.ts new file mode 100644 index 000000000..16e118ba3 --- /dev/null +++ b/packages/global/openapi/core/app/log/index.ts @@ -0,0 +1,154 @@ +import type { OpenAPIPath } from '../../../type'; +import { TagsMap } from '../../../tag'; +import { z } from 'zod'; +import { + GetAppChatLogsBodySchema, + GetAppChatLogsResponseSchema, + ExportChatLogsBodySchema, + GetChartDataBodySchema, + GetChartDataResponseSchema, + GetTotalDataQuerySchema, + GetTotalDataResponseSchema, + GetLogKeysQuerySchema, + GetLogKeysResponseSchema, + UpdateLogKeysBodySchema +} from './api'; + +export const AppLogPath: OpenAPIPath = { + '/core/app/logs/getLogKeys': { + get: { + summary: '获取应用日志键', + description: '获取应用的日志键列表', + tags: [TagsMap.appLog], + requestParams: { + query: GetLogKeysQuerySchema + }, + responses: { + 200: { + description: '成功获取应用日志键', + content: { + 'application/json': { + schema: GetLogKeysResponseSchema + } + } + } + } + } + }, + '/core/app/logs/updateLogKeys': { + post: { + summary: '更新应用日志键', + description: '更新应用的日志键列表', + tags: [TagsMap.appLog], + requestBody: { + content: { + 'application/json': { + schema: UpdateLogKeysBodySchema + } + } + }, + responses: { + 200: { + description: '成功更新应用日志键', + content: { + 'application/json': { + schema: z.object({}) + } + } + } + } + } + }, + '/core/app/logs/list': { + post: { + summary: '获取应用日志列表', + description: '分页获取应用的对话日志列表,支持按时间范围、来源、用户等条件筛选', + tags: [TagsMap.appLog], + requestBody: { + content: { + 'application/json': { + schema: GetAppChatLogsBodySchema + } + } + }, + responses: { + 200: { + description: '成功获取应用日志列表', + content: { + 'application/json': { + schema: GetAppChatLogsResponseSchema + } + } + } + } + } + }, + '/core/app/logs/exportLogs': { + post: { + summary: '导出应用日志', + description: '导出应用的对话日志为 CSV 文件,支持自定义导出字段和筛选条件', + tags: [TagsMap.appLog], + requestBody: { + content: { + 'application/json': { + schema: ExportChatLogsBodySchema + } + } + }, + responses: { + 200: { + description: '成功导出应用日志,返回 CSV 文件', + content: { + 'text/csv': { + schema: z.string() + } + } + } + } + } + }, + '/proApi/core/app/logs/getTotalData': { + get: { + summary: '获取应用总体数据统计', + description: '获取应用的总体数据统计,包括总用户数、总对话数、总积分消耗', + tags: [TagsMap.appLog], + requestParams: { + query: GetTotalDataQuerySchema + }, + responses: { + 200: { + description: '成功获取应用总体数据', + content: { + 'application/json': { + schema: GetTotalDataResponseSchema + } + } + } + } + } + }, + '/proApi/core/app/logs/getChartData': { + post: { + summary: '获取应用图表数据', + description: '获取应用的图表统计数据,包括用户数据、对话数据、应用数据的时序统计', + tags: [TagsMap.appLog], + requestBody: { + content: { + 'application/json': { + schema: GetChartDataBodySchema + } + } + }, + responses: { + 200: { + description: '成功获取应用图表数据', + content: { + 'application/json': { + schema: GetChartDataResponseSchema + } + } + } + } + } + } +}; diff --git a/packages/global/openapi/core/chat/feedback/api.ts b/packages/global/openapi/core/chat/feedback/api.ts new file mode 100644 index 000000000..4268bba5e --- /dev/null +++ b/packages/global/openapi/core/chat/feedback/api.ts @@ -0,0 +1,153 @@ +import { z } from 'zod'; + +/* =============== updateFeedbackReadStatus =============== */ +export const UpdateFeedbackReadStatusBodySchema = z.object({ + appId: z.string().min(1).meta({ + example: '68ad85a7463006c963799a05', + description: '应用 ID' + }), + chatId: z.string().min(1).meta({ + example: 'chat123', + description: '对话 ID' + }), + dataId: z.string().min(1).meta({ + example: 'data123', + description: '消息数据 ID' + }), + isRead: z.boolean().meta({ + example: true, + description: '是否已读' + }) +}); +export type UpdateFeedbackReadStatusBodyType = z.infer; + +export const UpdateFeedbackReadStatusResponseSchema = z.object({ + success: z.boolean().meta({ + example: true, + description: '操作是否成功' + }) +}); +export type UpdateFeedbackReadStatusResponseType = z.infer< + typeof UpdateFeedbackReadStatusResponseSchema +>; + +/* =============== adminUpdate =============== */ +export const AdminUpdateFeedbackBodySchema = z.object({ + appId: z.string().min(1).meta({ + example: '68ad85a7463006c963799a05', + description: '应用 ID' + }), + chatId: z.string().min(1).meta({ + example: 'chat123', + description: '对话 ID' + }), + dataId: z.string().min(1).meta({ + example: 'data123', + description: '消息数据 ID' + }), + datasetId: z.string().min(1).meta({ + example: 'dataset123', + description: '数据集 ID' + }), + feedbackDataId: z.string().min(1).meta({ + example: 'feedback123', + description: '反馈数据 ID' + }), + q: z.string().min(1).meta({ + example: '用户问题', + description: '问题内容' + }), + a: z.string().optional().meta({ + example: 'AI 回答', + description: '答案内容(可选)' + }) +}); +export type AdminUpdateFeedbackBodyType = z.infer; + +export const AdminUpdateFeedbackResponseSchema = z.object({}); +export type AdminUpdateFeedbackResponseType = z.infer; + +/* =============== closeCustom =============== */ +export const CloseCustomFeedbackBodySchema = z.object({ + appId: z.string().min(1).meta({ + example: '68ad85a7463006c963799a05', + description: '应用 ID' + }), + chatId: z.string().min(1).meta({ + example: 'chat123', + description: '对话 ID' + }), + dataId: z.string().min(1).meta({ + example: 'data123', + description: '消息数据 ID' + }), + index: z.number().int().nonnegative().meta({ + example: 0, + description: '自定义反馈的索引位置' + }) +}); +export type CloseCustomFeedbackBodyType = z.infer; + +export const CloseCustomFeedbackResponseSchema = z.object({}); +export type CloseCustomFeedbackResponseType = z.infer; + +/* =============== updateUserFeedback =============== */ +export const UpdateUserFeedbackBodySchema = z.object({ + appId: z.string().min(1).meta({ + example: '68ad85a7463006c963799a05', + description: '应用 ID' + }), + chatId: z.string().min(1).meta({ + example: 'chat123', + description: '对话 ID' + }), + dataId: z.string().min(1).meta({ + example: 'data123', + description: '消息数据 ID' + }), + userGoodFeedback: z.string().optional().nullable().meta({ + example: '回答很好', + description: '用户好评反馈内容' + }), + userBadFeedback: z.string().optional().nullable().meta({ + example: '回答不准确', + description: '用户差评反馈内容' + }) +}); +export type UpdateUserFeedbackBodyType = z.infer; + +export const UpdateUserFeedbackResponseSchema = z.object({}); +export type UpdateUserFeedbackResponseType = z.infer; + +/* =============== getFeedbackRecordIds =============== */ +export const GetFeedbackRecordIdsBodySchema = z.object({ + appId: z.string().meta({ + example: '68ad85a7463006c963799a05', + description: '应用 ID' + }), + chatId: z.string().meta({ + example: 'chat123', + description: '对话 ID' + }), + feedbackType: z.enum(['has_feedback', 'good', 'bad']).meta({ + example: 'has_feedback', + description: '反馈类型:has_feedback-所有反馈, good-好评, bad-差评' + }), + unreadOnly: z.boolean().optional().meta({ + example: false, + description: '是否只返回未读的反馈' + }) +}); +export type GetFeedbackRecordIdsBodyType = z.infer; + +export const GetFeedbackRecordIdsResponseSchema = z.object({ + total: z.number().int().nonnegative().meta({ + example: 10, + description: '符合条件的反馈总数' + }), + dataIds: z.array(z.string()).meta({ + example: ['data123', 'data456'], + description: '反馈记录的数据 ID 列表' + }) +}); +export type GetFeedbackRecordIdsResponseType = z.infer; diff --git a/packages/global/openapi/core/chat/feedback/index.ts b/packages/global/openapi/core/chat/feedback/index.ts new file mode 100644 index 000000000..d01622c85 --- /dev/null +++ b/packages/global/openapi/core/chat/feedback/index.ts @@ -0,0 +1,137 @@ +import type { OpenAPIPath } from '../../../type'; +import { TagsMap } from '../../../tag'; +import { + UpdateFeedbackReadStatusBodySchema, + UpdateFeedbackReadStatusResponseSchema, + AdminUpdateFeedbackBodySchema, + AdminUpdateFeedbackResponseSchema, + CloseCustomFeedbackBodySchema, + CloseCustomFeedbackResponseSchema, + UpdateUserFeedbackBodySchema, + UpdateUserFeedbackResponseSchema, + GetFeedbackRecordIdsBodySchema, + GetFeedbackRecordIdsResponseSchema +} from './api'; + +export const ChatFeedbackPath: OpenAPIPath = { + '/core/chat/feedback/updateFeedbackReadStatus': { + post: { + summary: '更新反馈阅读状态', + description: '标记指定消息的反馈为已读或未读状态', + tags: [TagsMap.chatFeedback], + requestBody: { + content: { + 'application/json': { + schema: UpdateFeedbackReadStatusBodySchema + } + } + }, + responses: { + 200: { + description: '成功更新反馈阅读状态', + content: { + 'application/json': { + schema: UpdateFeedbackReadStatusResponseSchema + } + } + } + } + } + }, + '/core/chat/feedback/adminUpdate': { + post: { + summary: '管理员标注反馈', + description: '管理员为指定消息添加或更新标注反馈,包含数据集关联信息', + tags: [TagsMap.chatFeedback], + requestBody: { + content: { + 'application/json': { + schema: AdminUpdateFeedbackBodySchema + } + } + }, + responses: { + 200: { + description: '成功更新管理员反馈标注', + content: { + 'application/json': { + schema: AdminUpdateFeedbackResponseSchema + } + } + } + } + } + }, + '/core/chat/feedback/closeCustom': { + post: { + summary: '关闭自定义反馈', + description: '删除或关闭指定索引位置的自定义反馈条目', + tags: [TagsMap.chatFeedback], + requestBody: { + content: { + 'application/json': { + schema: CloseCustomFeedbackBodySchema + } + } + }, + responses: { + 200: { + description: '成功关闭自定义反馈', + content: { + 'application/json': { + schema: CloseCustomFeedbackResponseSchema + } + } + } + } + } + }, + '/core/chat/feedback/updateUserFeedback': { + post: { + summary: '更新用户反馈', + description: '用户对消息添加或更新好评/差评反馈', + tags: [TagsMap.chatFeedback], + requestBody: { + content: { + 'application/json': { + schema: UpdateUserFeedbackBodySchema + } + } + }, + responses: { + 200: { + description: '成功更新用户反馈', + content: { + 'application/json': { + schema: UpdateUserFeedbackResponseSchema + } + } + } + } + } + }, + '/core/chat/feedback/getFeedbackRecordIds': { + post: { + summary: '获取反馈记录ID列表', + description: '根据反馈类型和已读状态,获取符合条件的消息ID列表', + tags: [TagsMap.chatFeedback], + requestBody: { + content: { + 'application/json': { + schema: GetFeedbackRecordIdsBodySchema + } + } + }, + responses: { + 200: { + description: '成功获取反馈记录ID列表', + content: { + 'application/json': { + schema: GetFeedbackRecordIdsResponseSchema + } + } + } + } + } + } +}; diff --git a/packages/global/openapi/core/chat/index.ts b/packages/global/openapi/core/chat/index.ts index b97786982..8bfb37ea2 100644 --- a/packages/global/openapi/core/chat/index.ts +++ b/packages/global/openapi/core/chat/index.ts @@ -1,6 +1,7 @@ import type { OpenAPIPath } from '../../type'; import { ChatSettingPath } from './setting'; import { ChatFavouriteAppPath } from './favourite/index'; +import { ChatFeedbackPath } from './feedback/index'; import { z } from 'zod'; import { CreatePostPresignedUrlResultSchema } from '../../../../service/common/s3/type'; import { PresignChatFileGetUrlSchema, PresignChatFilePostUrlSchema } from '../../../core/chat/api'; @@ -9,6 +10,7 @@ import { TagsMap } from '../../tag'; export const ChatPath: OpenAPIPath = { ...ChatSettingPath, ...ChatFavouriteAppPath, + ...ChatFeedbackPath, '/core/chat/presignChatFileGetUrl': { post: { diff --git a/packages/global/openapi/core/chat/setting/index.ts b/packages/global/openapi/core/chat/setting/index.ts index a3db7bbce..23a066b41 100644 --- a/packages/global/openapi/core/chat/setting/index.ts +++ b/packages/global/openapi/core/chat/setting/index.ts @@ -5,13 +5,13 @@ import { TagsMap } from '../../../tag'; export const ChatSettingPath: OpenAPIPath = { '/proApi/core/chat/setting/detail': { get: { - summary: '获取对话页设置', + summary: '获取门户页设置', description: - '获取当前团队的对话页设置,包括 slogan、对话提示、Logo、快捷应用、已选工具和精选应用标签等配置信息', + '获取当前团队的门户页设置,包括 slogan、对话提示、Logo、快捷应用、已选工具和精选应用标签等配置信息', tags: [TagsMap.chatSetting], responses: { 200: { - description: '成功返回对话页设置信息', + description: '成功返回门户页设置信息', content: { 'application/json': { schema: ChatSettingSchema @@ -23,9 +23,9 @@ export const ChatSettingPath: OpenAPIPath = { }, '/proApi/core/chat/setting/update': { post: { - summary: '更新对话页设置', + summary: '更新门户页设置', description: - '更新团队的对话页设置配置,包括 slogan、对话提示、Logo、快捷应用、已选工具和精选应用标签等信息', + '更新团队的门户页设置配置,包括 slogan、对话提示、Logo、快捷应用、已选工具和精选应用标签等信息', tags: [TagsMap.chatSetting], requestBody: { content: { @@ -36,7 +36,7 @@ export const ChatSettingPath: OpenAPIPath = { }, responses: { 200: { - description: '成功更新对话页设置', + description: '成功更新门户页设置', content: { 'application/json': { schema: ChatSettingSchema diff --git a/packages/global/openapi/index.ts b/packages/global/openapi/index.ts index 66b337f07..1a96c8b46 100644 --- a/packages/global/openapi/index.ts +++ b/packages/global/openapi/index.ts @@ -5,6 +5,7 @@ import { TagsMap } from './tag'; import { PluginPath } from './core/plugin'; import { WalletPath } from './support/wallet'; import { CustomDomainPath } from './support/customDomain'; +import { AppPath } from './core/app'; export const openAPIDocument = createDocument({ openapi: '3.1.0', @@ -14,6 +15,7 @@ export const openAPIDocument = createDocument({ description: 'FastGPT API 文档' }, paths: { + ...AppPath, ...ChatPath, ...ApiKeyPath, ...PluginPath, @@ -23,28 +25,28 @@ export const openAPIDocument = createDocument({ servers: [{ url: '/api' }], 'x-tagGroups': [ { - name: '对话', - tags: [TagsMap.chatSetting, TagsMap.chatPage] + name: 'Agent 应用', + tags: [TagsMap.appLog] }, { - name: '插件相关', + name: '对话管理', + tags: [TagsMap.chatSetting, TagsMap.chatPage, TagsMap.chatFeedback] + }, + { + name: '插件系统', tags: [TagsMap.pluginToolTag, TagsMap.pluginTeam] }, { - name: '插件-管理员', - tags: [TagsMap.pluginAdmin, TagsMap.pluginMarketplace, TagsMap.pluginToolAdmin] - }, - { - name: 'ApiKey', - tags: [TagsMap.apiKey] - }, - { - name: '支付', + name: '支付系统', tags: [TagsMap.walletBill, TagsMap.walletDiscountCoupon] }, { - name: '自定义域名', - tags: [TagsMap.customDomain] + name: '通用-辅助功能', + tags: [TagsMap.customDomain, TagsMap.apiKey] + }, + { + name: '管理员-插件管理', + tags: [TagsMap.pluginAdmin, TagsMap.pluginMarketplace, TagsMap.pluginToolAdmin] } ] }); diff --git a/packages/global/openapi/tag.ts b/packages/global/openapi/tag.ts index 708afe242..d43a031ab 100644 --- a/packages/global/openapi/tag.ts +++ b/packages/global/openapi/tag.ts @@ -1,15 +1,32 @@ export const TagsMap = { + /* Core */ + // Agent - log + appLog: 'Agent 日志', + + // Chat - home chatPage: '对话页', - chatSetting: '对话页配置', - pluginMarketplace: '插件市场(管理员视角)', + chatSetting: '门户页配置', + chatFeedback: '对话反馈', + + // Plugin pluginToolTag: '工具标签', - pluginAdmin: '管理员插件管理', - pluginToolAdmin: '管理员系统工具管理', pluginTeam: '团队插件管理', - apiKey: 'APIKey', + + /* Support */ + // Wallet walletBill: '订单', walletDiscountCoupon: '优惠券', customDomain: '自定义域名', + /* Common */ + // APIKey + apiKey: 'APIKey', + + /* Admin */ + // Plugin + pluginMarketplace: '插件市场', + pluginAdmin: '管理员插件管理', + pluginToolAdmin: '管理员系统工具管理', + // Data adminDashboard: '管理员仪表盘' }; diff --git a/packages/global/support/user/team/type.d.ts b/packages/global/support/user/team/type.d.ts index 77faf485e..1781466fb 100644 --- a/packages/global/support/user/team/type.d.ts +++ b/packages/global/support/user/team/type.d.ts @@ -45,7 +45,7 @@ export type TeamMemberSchema = { updateTime?: Date; name: string; role: `${TeamMemberRoleEnum}`; - status: `${TeamMemberStatusEnum}`; + status: TeamMemberStatusEnum; avatar: string; }; diff --git a/packages/global/support/user/type.d.ts b/packages/global/support/user/type.ts similarity index 60% rename from packages/global/support/user/type.d.ts rename to packages/global/support/user/type.ts index 96502dded..c3949453f 100644 --- a/packages/global/support/user/type.d.ts +++ b/packages/global/support/user/type.ts @@ -1,8 +1,9 @@ -import type { LangEnum } from '../common/i18n/type'; +import type { LangEnum } from '../../common/i18n/type'; import type { TeamPermission } from '../permission/user/controller'; import type { UserStatusEnum } from './constant'; -import type { TeamMemberStatusEnum } from './team/constant'; +import { TeamMemberStatusEnum } from './team/constant'; import type { TeamTmbItemType } from './team/type'; +import z from 'zod'; export type UserModelSchema = { _id: string; @@ -35,8 +36,13 @@ export type UserType = { contact?: string; }; -export type SourceMemberType = { - name: string; - avatar: string; - status: `${TeamMemberStatusEnum}`; -}; +export const SourceMemberSchema = z.object({ + name: z.string().meta({ example: '张三', description: '成员名称' }), + avatar: z + .string() + .meta({ example: 'https://cloud.fastgpt.cn/avatar.png', description: '成员头像' }), + status: z + .enum(TeamMemberStatusEnum) + .meta({ example: TeamMemberStatusEnum.active, description: '成员状态' }) +}); +export type SourceMemberType = z.infer; diff --git a/packages/service/common/mongo/index.ts b/packages/service/common/mongo/index.ts index 15475569a..8a5d190e9 100644 --- a/packages/service/common/mongo/index.ts +++ b/packages/service/common/mongo/index.ts @@ -49,17 +49,34 @@ const addCommonMiddleware = (schema: mongoose.Schema) => { schema.post(op, function (this: any, result: any, next) { if (this._startTime) { const duration = Date.now() - this._startTime; - const warnLogData = { - collectionName: this.collection?.name, - op: this.op, - ...(this._query && { query: this._query }), - ...(this._update && { update: this._update }), - ...(this._delete && { delete: this._delete }), - duration + + const getLogData = () => { + const collectionName = this.model?.collection?.name || this._model?.collection?.name; + const op = (() => { + if (this.op) return this.op; + if (this._pipeline) { + return 'aggregate'; + } + if (this.constructor?.name === 'model') { + return 'save/create'; + } + return this.constructor?.name || 'unknown'; + })(); + return { + duration, + collectionName, + op, + ...(this._query && { query: this._query }), + ...(this._pipeline && { pipeline: this._pipeline }), + ...(this._update && { update: this._update }), + ...(this._delete && { delete: this._delete }) + }; }; - if (duration > 1000) { - addLog.warn(`Slow operation ${duration}ms`, warnLogData); + if (duration > 2000) { + addLog.warn(`[Mongo Slow] Level2`, getLogData()); + } else if (duration > 500) { + addLog.warn(`[Mongo Slow] Level1`, getLogData()); } } next(); @@ -112,7 +129,6 @@ export const getMongoModel = (name: string, schema: mongoose.Schema) => { export const getMongoLogModel = (name: string, schema: mongoose.Schema) => { if (connectionLogMongo.models[name]) return connectionLogMongo.models[name] as Model; console.log('Load model======', name); - // addCommonMiddleware(schema); const model = connectionLogMongo.model(name, schema); diff --git a/packages/service/common/mongo/utils.ts b/packages/service/common/mongo/utils.ts index ac249cafa..491564685 100644 --- a/packages/service/common/mongo/utils.ts +++ b/packages/service/common/mongo/utils.ts @@ -2,7 +2,9 @@ import { ReadPreference } from './index'; export const readFromSecondary = { readPreference: ReadPreference.SECONDARY_PREFERRED, // primary | primaryPreferred | secondary | secondaryPreferred | nearest - readConcern: 'local' as any // local | majority | linearizable | available + readConcern: { + level: 'local' as any + } // local | majority | linearizable | available }; export const writePrimary = { diff --git a/packages/service/core/ai/llm/agentCall/index.ts b/packages/service/core/ai/llm/agentCall/index.ts index 219a64e16..f59affd9d 100644 --- a/packages/service/core/ai/llm/agentCall/index.ts +++ b/packages/service/core/ai/llm/agentCall/index.ts @@ -217,6 +217,7 @@ export const runAgentCall = async ({ } = await createLLMResponse({ body: { ...body, + max_tokens: maxTokens, model, messages: requestMessages, tool_choice: 'auto', diff --git a/packages/service/core/ai/llm/request.ts b/packages/service/core/ai/llm/request.ts index d9b317a5e..e7ce08a66 100644 --- a/packages/service/core/ai/llm/request.ts +++ b/packages/service/core/ai/llm/request.ts @@ -10,7 +10,12 @@ import type { StreamChatType, UnStreamChatType } from '@fastgpt/global/core/ai/type'; -import { computedTemperature, parseLLMStreamResponse, parseReasoningContent } from '../utils'; +import { + computedMaxToken, + computedTemperature, + parseLLMStreamResponse, + parseReasoningContent +} from '../utils'; import { removeDatasetCiteText } from '@fastgpt/global/core/ai/llm/utils'; import { getAIApi } from '../config'; import type { OpenaiAccountType } from '@fastgpt/global/support/user/team/type'; @@ -525,8 +530,14 @@ const llmCompletionsBodyFormat = async ({ })(); const stop = body.stop ?? undefined; + const maxTokens = computedMaxToken({ + model: modelData, + maxToken: body.max_tokens || undefined + }); + const requestBody = { ...body, + max_tokens: maxTokens, model: modelData.model, temperature: typeof body.temperature === 'number' @@ -567,7 +578,7 @@ const createChatCompletion = async ({ timeout, options }: { - modelData?: LLMModelItemType; + modelData: LLMModelItemType; body: ChatCompletionCreateParamsNonStreaming | ChatCompletionCreateParamsStreaming; userKey?: OpenaiAccountType; timeout?: number; @@ -587,13 +598,10 @@ const createChatCompletion = async ({ ) > => { try { - // Rewrite model - const modelConstantsData = modelData || getLLMModel(body.model); - - if (!modelConstantsData) { + if (!modelData) { return Promise.reject(`${body.model} not found`); } - body.model = modelConstantsData.model; + body.model = modelData.model; const formatTimeout = timeout ? timeout : 600000; const ai = getAIApi({ @@ -607,12 +615,10 @@ const createChatCompletion = async ({ const response = await ai.chat.completions.create(body, { ...options, - ...(modelConstantsData.requestUrl ? { path: modelConstantsData.requestUrl } : {}), + ...(modelData.requestUrl ? { path: modelData.requestUrl } : {}), headers: { ...options?.headers, - ...(modelConstantsData.requestAuth - ? { Authorization: `Bearer ${modelConstantsData.requestAuth}` } - : {}) + ...(modelData.requestAuth ? { Authorization: `Bearer ${modelData.requestAuth}` } : {}) } }); diff --git a/packages/service/core/chat/chatItemSchema.ts b/packages/service/core/chat/chatItemSchema.ts index 770d3637f..2d503b69d 100644 --- a/packages/service/core/chat/chatItemSchema.ts +++ b/packages/service/core/chat/chatItemSchema.ts @@ -64,6 +64,10 @@ const ChatItemSchema = new Schema({ // Field memory memories: Object, errorMsg: String, + durationSeconds: Number, + citeCollectionIds: [String], + + // Feedback userGoodFeedback: String, userBadFeedback: String, customFeedbacks: [String], @@ -76,8 +80,7 @@ const ChatItemSchema = new Schema({ a: String } }, - durationSeconds: Number, - citeCollectionIds: [String], + isFeedbackRead: Boolean, // @deprecated [DispatchNodeResponseKeyEnum.nodeResponse]: Array @@ -91,6 +94,8 @@ const ChatItemSchema = new Schema({ close custom feedback; */ ChatItemSchema.index({ appId: 1, chatId: 1, dataId: 1 }); +// Anchor filter +ChatItemSchema.index({ appId: 1, chatId: 1, _id: -1 }); // timer, clear history ChatItemSchema.index({ teamId: 1, time: -1 }); diff --git a/packages/service/core/chat/chatSchema.ts b/packages/service/core/chat/chatSchema.ts index 8ca948960..7077be6dc 100644 --- a/packages/service/core/chat/chatSchema.ts +++ b/packages/service/core/chat/chatSchema.ts @@ -8,6 +8,7 @@ import { } from '@fastgpt/global/support/user/team/constant'; import { AppCollectionName } from '../app/schema'; import { chatCollectionName } from './constants'; +import { AppVersionCollectionName } from '../app/version/schema'; const ChatSchema = new Schema({ chatId: { @@ -33,6 +34,10 @@ const ChatSchema = new Schema({ ref: AppCollectionName, required: true }, + appVersionId: { + type: Schema.Types.ObjectId, + ref: AppVersionCollectionName + }, createTime: { type: Date, default: () => new Date() @@ -84,12 +89,16 @@ const ChatSchema = new Schema({ default: {} }, - initStatistics: Boolean + // Feedback count statistics (redundant fields for performance) + + // Boolean flags for efficient filtering + hasGoodFeedback: Boolean, + hasBadFeedback: Boolean, + hasUnreadGoodFeedback: Boolean, + hasUnreadBadFeedback: Boolean }); try { - // Tmp - ChatSchema.index({ initStatistics: 1, _id: -1 }); ChatSchema.index({ appId: 1, tmbId: 1, outLinkUid: 1 }); ChatSchema.index({ chatId: 1 }); @@ -98,8 +107,76 @@ try { // delete by appid; clear history; init chat; update chat; auth chat; get chat; ChatSchema.index({ appId: 1, chatId: 1 }); - // get chat logs; - ChatSchema.index({ teamId: 1, appId: 1, sources: 1, tmbId: 1, updateTime: -1 }); + /* get chat logs */ + // 1. No feedback filter + ChatSchema.index({ teamId: 1, appId: 1, source: 1, tmbId: 1, updateTime: -1 }); + + /* 反馈过滤的索引 */ + // 2. Has good feedback filter + ChatSchema.index( + { + teamId: 1, + appId: 1, + source: 1, + tmbId: 1, + hasGoodFeedback: 1, + updateTime: -1 + }, + { + partialFilterExpression: { + hasGoodFeedback: true + } + } + ); + // 3. Has bad feedback filter + ChatSchema.index( + { + teamId: 1, + appId: 1, + source: 1, + tmbId: 1, + hasBadFeedback: 1, + updateTime: -1 + }, + { + partialFilterExpression: { + hasBadFeedback: true + } + } + ); + // 4. Has unread good feedback filter + ChatSchema.index( + { + teamId: 1, + appId: 1, + source: 1, + tmbId: 1, + hasUnreadGoodFeedback: 1, + updateTime: -1 + }, + { + partialFilterExpression: { + hasUnreadGoodFeedback: true + } + } + ); + // 5. Has unread bad feedback filter + ChatSchema.index( + { + teamId: 1, + appId: 1, + source: 1, + tmbId: 1, + hasUnreadBadFeedback: 1, + updateTime: -1 + }, + { + partialFilterExpression: { + hasUnreadBadFeedback: true + } + } + ); + // get share chat history ChatSchema.index({ shareId: 1, outLinkUid: 1, updateTime: -1 }); diff --git a/packages/service/core/chat/controller.ts b/packages/service/core/chat/controller.ts index 38062e95e..1ef39b10c 100644 --- a/packages/service/core/chat/controller.ts +++ b/packages/service/core/chat/controller.ts @@ -1,66 +1,197 @@ import type { ChatHistoryItemResType, ChatItemType } from '@fastgpt/global/core/chat/type'; import { MongoChatItem } from './chatItemSchema'; +import { MongoChat } from './chatSchema'; import { addLog } from '../../common/system/log'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { MongoChatItemResponse } from './chatItemResponseSchema'; +import type { ClientSession } from '../../common/mongo'; +import { Types } from '../../common/mongo'; +import { mongoSessionRun } from '../../common/mongo/sessionRun'; +import { UserError } from '@fastgpt/global/common/error/utils'; export async function getChatItems({ appId, chatId, - offset, + field, limit, - field + + offset, + initialId, + prevId, + nextId }: { appId: string; chatId?: string; - offset: number; - limit: number; field: string; -}): Promise<{ histories: ChatItemType[]; total: number }> { + limit: number; + + offset?: number; + initialId?: string; + prevId?: string; + nextId?: string; +}): Promise<{ + histories: ChatItemType[]; + total: number; + hasMorePrev: boolean; + hasMoreNext: boolean; +}> { if (!chatId) { - return { histories: [], total: 0 }; + return { histories: [], total: 0, hasMorePrev: false, hasMoreNext: false }; } // Extend dataId field = `dataId ${field}`; - const [histories, total] = await Promise.all([ - MongoChatItem.find({ appId, chatId }, field).sort({ _id: -1 }).skip(offset).limit(limit).lean(), - MongoChatItem.countDocuments({ appId, chatId }) - ]); - histories.reverse(); + const baseCondition = { appId, chatId }; + + const { histories, total, hasMorePrev, hasMoreNext } = await (async () => { + // Mode 1: offset pagination (original logic) + if (offset !== undefined) { + const [foundHistories, count] = await Promise.all([ + MongoChatItem.find(baseCondition, field).sort({ _id: -1 }).skip(offset).limit(limit).lean(), + MongoChatItem.countDocuments(baseCondition) + ]); + return { + histories: foundHistories.reverse(), + total: count, + hasMorePrev: count > limit, + hasMoreNext: offset > 0 + }; + } + // Mode 2: prevId - get records before the target + else if (prevId) { + const prevItem = await MongoChatItem.findOne( + { + ...baseCondition, + dataId: prevId + }, + { _id: 1 } + ).lean(); + if (!prevItem) return Promise.reject(new UserError('Prev item not found')); + + const [items, count] = await Promise.all([ + MongoChatItem.find({ ...baseCondition, _id: { $lt: prevItem._id } }, field) + .sort({ _id: -1 }) + .limit(limit + 1) + .lean(), + MongoChatItem.countDocuments({ ...baseCondition }) + ]); + + return { + histories: items.slice(0, limit).reverse(), + total: count, + hasMorePrev: items.length > limit, + hasMoreNext: true + }; + } + // Mode 3: nextId - get records after the target + else if (nextId) { + const nextItem = await MongoChatItem.findOne( + { + ...baseCondition, + dataId: nextId + }, + { _id: 1 } + ).lean(); + if (!nextItem) return Promise.reject(new UserError('Next item not found')); + + const [items, total] = await Promise.all([ + MongoChatItem.find({ ...baseCondition, _id: { $gt: nextItem._id } }, field) + .sort({ _id: 1 }) + .limit(limit + 1) + .lean(), + MongoChatItem.countDocuments({ ...baseCondition }) + ]); + + return { + histories: items.slice(0, limit), + total, + hasMorePrev: true, + hasMoreNext: items.length > limit + }; + } + // Mode 2: initialId - get records around the target + else { + if (!initialId) { + const [foundHistories, count] = await Promise.all([ + MongoChatItem.find(baseCondition, field).sort({ _id: -1 }).skip(0).limit(limit).lean(), + MongoChatItem.countDocuments(baseCondition) + ]); + return { + histories: foundHistories.reverse(), + total: count, + hasMorePrev: count > limit, + hasMoreNext: false + }; + } + + const halfLimit = Math.floor(limit / 2); + const ceilLimit = Math.ceil(limit / 2); + + const targetItem = await MongoChatItem.findOne( + { ...baseCondition, dataId: initialId }, + field + ).lean(); + if (!targetItem) return Promise.reject(new UserError('Target item not found')); + + const [prevItems, nextItems, count] = await Promise.all([ + MongoChatItem.find({ ...baseCondition, _id: { $lt: targetItem._id } }, field) + .sort({ _id: -1 }) + .limit(halfLimit + 1) + .lean(), + MongoChatItem.find({ ...baseCondition, _id: { $gt: targetItem._id } }, field) + .sort({ _id: 1 }) + .limit(ceilLimit + 1) + .lean(), + MongoChatItem.countDocuments(baseCondition) + ]); + + return { + histories: [ + ...prevItems.slice(0, halfLimit).reverse(), + targetItem, + ...nextItems.slice(0, ceilLimit) + ].filter(Boolean), + total: count, + hasMorePrev: prevItems.length > halfLimit, + hasMoreNext: nextItems.length > ceilLimit + }; + } + })(); // Add node responses field - if (field.includes(DispatchNodeResponseKeyEnum.nodeResponse)) { + if (field.includes(DispatchNodeResponseKeyEnum.nodeResponse) && histories.length > 0) { const chatItemDataIds = histories .filter((item) => item.obj === ChatRoleEnum.AI && !item.responseData?.length) .map((item) => item.dataId); - const chatItemResponsesMap = await MongoChatItemResponse.find( - { appId, chatId, chatItemDataId: { $in: chatItemDataIds } }, - { chatItemDataId: 1, data: 1 } - ) - .lean() - .then((res) => { - const map = new Map(); - res.forEach((item) => { - const val = map.get(item.chatItemDataId) || []; - val.push(item.data); - map.set(item.chatItemDataId, val); + if (chatItemDataIds.length > 0) { + const chatItemResponsesMap = await MongoChatItemResponse.find( + { appId, chatId, chatItemDataId: { $in: chatItemDataIds } }, + { chatItemDataId: 1, data: 1 } + ) + .lean() + .then((res) => { + const map = new Map(); + res.forEach((item) => { + const val = map.get(item.chatItemDataId) || []; + val.push(item.data); + map.set(item.chatItemDataId, val); + }); + return map; }); - return map; - }); - histories.forEach((item) => { - const val = chatItemResponsesMap.get(String(item.dataId)); - if (item.obj === ChatRoleEnum.AI && val) { - item.responseData = val; - } - }); + histories.forEach((item) => { + const val = chatItemResponsesMap.get(String(item.dataId)); + if (item.obj === ChatRoleEnum.AI && val) { + item.responseData = val; + } + }); + } } - return { histories, total }; + return { histories, total, hasMorePrev, hasMoreNext }; } export const addCustomFeedbacks = async ({ @@ -77,17 +208,183 @@ export const addCustomFeedbacks = async ({ if (!chatId || !dataId) return; try { - await MongoChatItem.findOneAndUpdate( - { + await mongoSessionRun(async (session) => { + // Add custom feedbacks to ChatItem + await MongoChatItem.updateOne( + { + appId, + chatId, + dataId + }, + { + $push: { customFeedbacks: { $each: feedbacks } } + }, + { session } + ); + + // Update ChatLog feedback statistics + await updateChatFeedbackCount({ appId, chatId, - dataId - }, - { - $push: { customFeedbacks: { $each: feedbacks } } - } - ); + session + }); + }); } catch (error) { addLog.error('addCustomFeedbacks error', error); + throw error; } }; + +/** + * Update feedback count statistics for a chat in Chat table + * This method aggregates feedback data from chatItems and updates the Chat table + * + * @param appId - Application ID + * @param chatId - Chat ID + * @param session - Optional MongoDB session for transaction support + */ +export async function updateChatFeedbackCount({ + appId, + chatId, + session +}: { + appId: string; + chatId: string; + session?: ClientSession; +}): Promise { + try { + // Aggregate feedback statistics from chatItems + const stats = await MongoChatItem.aggregate( + [ + { + $match: { + appId: new Types.ObjectId(appId), + chatId, + obj: ChatRoleEnum.AI + } + }, + { + $group: { + _id: null, + goodFeedbackCount: { + $sum: { + $cond: [{ $ifNull: ['$userGoodFeedback', false] }, 1, 0] + } + }, + badFeedbackCount: { + $sum: { + $cond: [{ $ifNull: ['$userBadFeedback', false] }, 1, 0] + } + }, + // Calculate unread good feedback count + unreadGoodFeedbackCount: { + $sum: { + $cond: [ + { + $and: [ + { $ne: [{ $ifNull: ['$isFeedbackRead', false] }, true] }, + { $ne: [{ $ifNull: ['$userGoodFeedback', null] }, null] } + ] + }, + 1, + 0 + ] + } + }, + // Calculate unread bad feedback count + unreadBadFeedbackCount: { + $sum: { + $cond: [ + { + $and: [ + { $ne: [{ $ifNull: ['$isFeedbackRead', false] }, true] }, + { $ne: [{ $ifNull: ['$userBadFeedback', null] }, null] } + ] + }, + 1, + 0 + ] + } + } + } + } + ], + { session } + ); + + const feedbackStats = stats[0] || { + goodFeedbackCount: 0, + badFeedbackCount: 0, + unreadGoodFeedbackCount: 0, + unreadBadFeedbackCount: 0 + }; + + // Calculate boolean flags + const hasGoodFeedback = feedbackStats.goodFeedbackCount > 0; + const hasBadFeedback = feedbackStats.badFeedbackCount > 0; + const hasUnreadGoodFeedback = feedbackStats.unreadGoodFeedbackCount > 0; + const hasUnreadBadFeedback = feedbackStats.unreadBadFeedbackCount > 0; + + // Build update object - only set fields that are true, unset fields that are false + const updateObj: Record = {}; + const unsetObj: Record = {}; + + if (hasGoodFeedback) { + updateObj.hasGoodFeedback = true; + } else { + unsetObj.hasGoodFeedback = ''; + } + + if (hasBadFeedback) { + updateObj.hasBadFeedback = true; + } else { + unsetObj.hasBadFeedback = ''; + } + + if (hasUnreadGoodFeedback) { + updateObj.hasUnreadGoodFeedback = true; + } else { + unsetObj.hasUnreadGoodFeedback = ''; + } + + if (hasUnreadBadFeedback) { + updateObj.hasUnreadBadFeedback = true; + } else { + unsetObj.hasUnreadBadFeedback = ''; + } + + // Build the final update query + const updateQuery: Record = {}; + if (Object.keys(updateObj).length > 0) { + updateQuery.$set = updateObj; + } + if (Object.keys(unsetObj).length > 0) { + updateQuery.$unset = unsetObj; + } + + // Update Chat table with aggregated statistics and boolean flags + await MongoChat.updateOne( + { + appId, + chatId + }, + updateQuery, + { + session + } + ); + + addLog.debug('updateChatFeedbackCount success', { + appId, + chatId, + stats: feedbackStats, + hasGoodFeedback, + hasBadFeedback, + hasUnreadGoodFeedback, + hasUnreadBadFeedback + }); + } catch (error) { + addLog.error('updateChatFeedbackCount error', error); + throw error; + } +} diff --git a/packages/service/core/chat/saveChat.ts b/packages/service/core/chat/saveChat.ts index ada9ef0af..38fff3ac4 100644 --- a/packages/service/core/chat/saveChat.ts +++ b/packages/service/core/chat/saveChat.ts @@ -26,9 +26,10 @@ import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants'; import { encryptSecretValue, anyValueDecrypt } from '../../common/secret/utils'; import type { SecretValueType } from '@fastgpt/global/common/secret/type'; -type Props = { +export type Props = { chatId: string; appId: string; + versionId?: string; teamId: string; tmbId: string; nodes: StoreNodeItemType[]; @@ -212,6 +213,7 @@ export async function saveChat(props: Props) { const { chatId, appId, + versionId, teamId, tmbId, nodes, @@ -299,6 +301,7 @@ export async function saveChat(props: Props) { teamId, tmbId, appId, + appVersionId: versionId, chatId, variableList, welcomeText, diff --git a/packages/service/core/workflow/dispatch/plugin/run.ts b/packages/service/core/workflow/dispatch/plugin/run.ts index 26701af4a..bd607f560 100644 --- a/packages/service/core/workflow/dispatch/plugin/run.ts +++ b/packages/service/core/workflow/dispatch/plugin/run.ts @@ -110,7 +110,7 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise item.url); + data[input.key] = val.map((item) => (typeof item === 'string' ? item : item.url)); } return { diff --git a/packages/web/common/fetch/type.d.ts b/packages/web/common/fetch/type.d.ts index 58e55bca4..19155eb79 100644 --- a/packages/web/common/fetch/type.d.ts +++ b/packages/web/common/fetch/type.d.ts @@ -12,21 +12,16 @@ type PaginationResponse = { list: T[]; }; -type LinkedPaginationProps = T & { +type LinkedPaginationProps = T & { pageSize: number; -} & RequireOnlyOne<{ - initialId: string; - nextId: string; - prevId: string; - }> & - RequireOnlyOne<{ - initialIndex: number; - nextIndex: number; - prevIndex: number; - }>; + anchor?: A; + initialId?: string; + nextId?: string; + prevId?: string; +}; -type LinkedListResponse = { - list: Array; +type LinkedListResponse = { + list: Array; hasMorePrev: boolean; hasMoreNext: boolean; }; diff --git a/packages/web/hooks/useConfirm.tsx b/packages/web/hooks/useConfirm.tsx index 7b0d4a6f3..d27035ff7 100644 --- a/packages/web/hooks/useConfirm.tsx +++ b/packages/web/hooks/useConfirm.tsx @@ -1,8 +1,18 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { useDisclosure, Button, ModalBody, ModalFooter, type ImageProps } from '@chakra-ui/react'; -import { useTranslation } from 'next-i18next'; +import { + useDisclosure, + Button, + ModalBody, + ModalFooter, + Input, + VStack, + Box, + type ImageProps +} from '@chakra-ui/react'; +import { Trans, useTranslation } from 'next-i18next'; import MyModal from '../components/common/MyModal'; import { useMemoizedFn } from 'ahooks'; +import { useMemoEnhance } from './useMemoEnhance'; export const useConfirm = (props?: { title?: string; @@ -12,10 +22,11 @@ export const useConfirm = (props?: { type?: 'common' | 'delete'; hideFooter?: boolean; iconColor?: ImageProps['color']; + inputConfirmText?: string; }) => { const { t } = useTranslation(); - const map = useMemo(() => { + const map = useMemoEnhance(() => { const map = { common: { title: t('common:action_confirm'), @@ -38,9 +49,13 @@ export const useConfirm = (props?: { iconColor, content, showCancel = true, - hideFooter = false + hideFooter = false, + inputConfirmText: initialInputConfirmText } = props || {}; const [customContent, setCustomContent] = useState(content); + const [customContentInputConfirmText, setCustomContentInputConfirmText] = useState< + string | undefined + >(initialInputConfirmText); const { isOpen, onOpen, onClose } = useDisclosure(); @@ -48,11 +63,22 @@ export const useConfirm = (props?: { const cancelCb = useRef(); const openConfirm = useMemoizedFn( - (confirm?: Function, cancel?: any, customContent?: string | React.ReactNode) => { - confirmCb.current = confirm; - cancelCb.current = cancel; + ({ + onConfirm, + onCancel, + customContent, + inputConfirmText + }: { + onConfirm?: Function; + onCancel?: any; + customContent?: string | React.ReactNode; + inputConfirmText?: string; + }) => { + confirmCb.current = onConfirm; + cancelCb.current = onCancel; - customContent && setCustomContent(customContent); + setCustomContent(customContent || content); + setCustomContentInputConfirmText(inputConfirmText || initialInputConfirmText); return onOpen; } @@ -63,22 +89,24 @@ export const useConfirm = (props?: { closeText = t('common:Cancel'), confirmText = t('common:Confirm'), isLoading, - bg, countDown = 0 }: { closeText?: string; confirmText?: string; isLoading?: boolean; - bg?: string; countDown?: number; }) => { + const isInputDelete = !!customContentInputConfirmText; + const timer = useRef(); const [countDownAmount, setCountDownAmount] = useState(countDown); const [requesting, setRequesting] = useState(false); + const [inputValue, setInputValue] = useState(''); useEffect(() => { if (isOpen) { setCountDownAmount(countDown); + setInputValue(''); timer.current = setInterval(() => { setCountDownAmount((val) => { if (val <= 0) { @@ -94,6 +122,10 @@ export const useConfirm = (props?: { } }, [isOpen]); + const isInputDeleteConfirmValid = !isInputDelete + ? true + : !!customContentInputConfirmText && inputValue.trim() === customContentInputConfirmText; + return ( - {customContent} + {isInputDelete ? ( + + {customContent} + + + }} + /> + + setInputValue(e.target.value)} + placeholder={t('common:confirm_input_delete_placeholder', { + confirmText: customContentInputConfirmText + })} + /> + + ) : ( + customContent + )} {!hideFooter && ( @@ -124,7 +179,7 @@ export const useConfirm = (props?: { + ) : ( + + )} + {chat.userBadFeedback && onToggleFeedbackContent && !showFeedbackContent && ( + + )} + )} - {!!onAddUserLike && ( - - )} - {!!onAddUserDislike && ( - - )} - - )} - + + ); }; diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx index 43cb00b95..9230d7cda 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx @@ -1,5 +1,5 @@ -import { Box, type BoxProps, Card, Flex } from '@chakra-ui/react'; -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { Box, type BoxProps, Card, Flex, Button } from '@chakra-ui/react'; +import React, { useMemo, useState } from 'react'; import ChatController, { type ChatControllerProps } from './ChatController'; import ChatAvatar from './ChatAvatar'; import { MessageCardStyle } from '../constants'; @@ -37,6 +37,7 @@ import { addStatisticalDataToHistoryItem } from '@/global/core/chat/utils'; import dynamic from 'next/dynamic'; import { useMemoizedFn } from 'ahooks'; import ChatBoxDivider from '../../../Divider'; +import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; const ResponseTags = dynamic(() => import('./ResponseTags')); @@ -131,29 +132,34 @@ const AIContentCard = React.memo(function AIContentCard({ const ChatItem = (props: Props) => { const { type, avatar, statusBoxData, children, isLastChild, questionGuides = [], chat } = props; + const { t } = useTranslation(); const { isPc } = useSystem(); - const styleMap: BoxProps = { - ...(type === ChatRoleEnum.Human - ? { - order: 0, - borderRadius: '8px 0 8px 8px', - justifyContent: 'flex-end', - textAlign: 'right', - bg: 'primary.100' - } - : { - order: 1, - borderRadius: '0 8px 8px 8px', - justifyContent: 'flex-start', - textAlign: 'left', - bg: 'myGray.50' - }), - fontSize: 'mini', - fontWeight: '400', - color: 'myGray.500' - }; - const { t } = useTranslation(); + const [showFeedbackContent, setShowFeedbackContent] = useState(false); + + const styleMap: BoxProps = useMemoEnhance( + () => ({ + ...(type === ChatRoleEnum.Human + ? { + order: 0, + borderRadius: '8px 0 8px 8px', + justifyContent: 'flex-end', + textAlign: 'right', + bg: 'primary.100' + } + : { + order: 1, + borderRadius: '0 8px 8px 8px', + justifyContent: 'flex-start', + textAlign: 'left', + bg: 'myGray.50' + }), + fontSize: 'mini', + fontWeight: '400', + color: 'myGray.500' + }), + [type] + ); const isChatting = useContextSelector(ChatBoxContext, (v) => v.isChatting); const chatType = useContextSelector(ChatBoxContext, (v) => v.chatType); @@ -280,6 +286,7 @@ const ChatItem = (props: Props) => { return ( { }).replace('#', ':')} )} - + setShowFeedbackContent(!showFeedbackContent)} + /> )} @@ -333,6 +345,37 @@ const ChatItem = (props: Props) => { )} + + {/* User Feedback Content: Admin log show */} + {isChatLog && + showFeedbackContent && + chat.obj === ChatRoleEnum.AI && + (chat.userGoodFeedback || chat.userBadFeedback) && ( + + + {chat.userBadFeedback || chat.userGoodFeedback} + + + + + + )} + {/* content */} {splitAiResponseResults.map((value, i) => ( import('@/components/Markdown'), { ssr: false }); - -const Empty = () => { - const { data: chatProblem } = useMarkdown({ url: '/chatProblem.md' }); - const { data: versionIntro } = useMarkdown({ url: '/versionIntro.md' }); - - return ( - - {/* version intro */} - - - - - - - - ); -}; - -export default React.memo(Empty); diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/FeedbackModal.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/FeedbackModal.tsx index 455ea0971..e31569493 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/FeedbackModal.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/FeedbackModal.tsx @@ -3,7 +3,7 @@ import { ModalBody, Textarea, ModalFooter, Button } from '@chakra-ui/react'; import MyModal from '@fastgpt/web/components/common/MyModal'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { useTranslation } from 'next-i18next'; -import { updateChatUserFeedback } from '@/web/core/chat/api'; +import { updateChatUserFeedback } from '@/web/core/chat/feedback/api'; import { useContextSelector } from 'use-context-selector'; import { WorkflowRuntimeContext } from '../../context/workflowRuntimeContext'; diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ReadFeedbackModal.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ReadFeedbackModal.tsx deleted file mode 100644 index 0ddf02a4e..000000000 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ReadFeedbackModal.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { ModalBody, ModalFooter, Button } from '@chakra-ui/react'; -import MyModal from '@fastgpt/web/components/common/MyModal'; -import { useTranslation } from 'next-i18next'; - -const ReadFeedbackModal = ({ - content, - onCloseFeedback, - onClose -}: { - content: string; - onCloseFeedback: () => void; - onClose: () => void; -}) => { - const { t } = useTranslation(); - - return ( - - {content} - - - - - ); -}; - -export default React.memo(ReadFeedbackModal); diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx index 73b040a67..9db60858e 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx @@ -22,10 +22,11 @@ import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useTranslation } from 'next-i18next'; import { closeCustomFeedback, - delChatRecordById, updateChatAdminFeedback, - updateChatUserFeedback -} from '@/web/core/chat/api'; + updateChatUserFeedback, + updateFeedbackReadStatus +} from '@/web/core/chat/feedback/api'; +import { delChatRecordById } from '@/web/core/chat/api'; import type { AdminMarkType } from './components/SelectMarkCollection'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; import { postQuestionGuide } from '@/web/core/ai/api'; @@ -68,9 +69,7 @@ import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time'; import { TeamErrEnum } from '@fastgpt/global/common/error/code/team'; const FeedbackModal = dynamic(() => import('./components/FeedbackModal')); -const ReadFeedbackModal = dynamic(() => import('./components/ReadFeedbackModal')); const SelectMarkCollection = dynamic(() => import('./components/SelectMarkCollection')); -const Empty = dynamic(() => import('./components/Empty')); const WelcomeBox = dynamic(() => import('./components/WelcomeBox')); const VariableInputForm = dynamic(() => import('./components/VariableInputForm')); const ChatHomeVariablesForm = dynamic(() => import('./components/home/ChatHomeVariablesForm')); @@ -90,7 +89,6 @@ type Props = OutLinkChatAuthProps & feedbackType?: `${FeedbackTypeEnum}`; showMarkIcon?: boolean; // admin mark dataset showVoiceIcon?: boolean; - showEmptyIntro?: boolean; active?: boolean; // can use showWorkorder?: boolean; @@ -99,6 +97,7 @@ type Props = OutLinkChatAuthProps & isNewChat?: boolean; } >; + onTriggerRefresh?: () => void; }; const ChatBox = ({ @@ -106,16 +105,16 @@ const ChatBox = ({ feedbackType = FeedbackTypeEnum.hidden, showMarkIcon = false, showVoiceIcon = true, - showEmptyIntro = false, active = true, showWorkorder, onStartChat, - chatType + chatType, + onTriggerRefresh }: Props) => { const ScrollContainerRef = useRef(null); const { t } = useTranslation(); const { toast } = useToast(); - const { feConfigs, setNotSufficientModalType } = useSystemStore(); + const { setNotSufficientModalType } = useSystemStore(); const { isPc } = useSystem(); const TextareaDom = useRef(null); const chatController = useRef(new AbortController()); @@ -124,10 +123,6 @@ const ChatBox = ({ const [isLoading, setIsLoading] = useState(false); const [feedbackId, setFeedbackId] = useState(); - const [readFeedbackData, setReadFeedbackData] = useState<{ - dataId: string; - content: string; - }>(); const [adminMarkData, setAdminMarkData] = useState(); const [questionGuides, setQuestionGuide] = useState([]); @@ -144,6 +139,7 @@ const ChatBox = ({ const setChatRecords = useContextSelector(ChatRecordContext, (v) => v.setChatRecords); const isChatRecordsLoaded = useContextSelector(ChatRecordContext, (v) => v.isChatRecordsLoaded); const ScrollData = useContextSelector(ChatRecordContext, (v) => v.ScrollData); + const itemRefs = useContextSelector(ChatRecordContext, (v) => v.itemRefs); const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId); const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId); @@ -506,6 +502,7 @@ const ChatBox = ({ requestVariables[item.key] = valueTypeFormat(val, item.valueType); }); + const humanChatId = getNanoid(24); const responseChatId = getNanoid(24); // set auto audio playing @@ -517,7 +514,8 @@ const ChatBox = ({ const newChatList: ChatSiteItemType[] = [ ...history, { - dataId: getNanoid(24), + id: humanChatId, + dataId: humanChatId, obj: ChatRoleEnum.Human, time: new Date(), hideInUI, @@ -546,6 +544,7 @@ const ChatBox = ({ status: ChatStatusEnum.finish }, { + id: responseChatId, dataId: responseChatId, obj: ChatRoleEnum.AI, value: [ @@ -803,24 +802,6 @@ const ChatBox = ({ } catch (error) {} }; }); - const onCloseUserLike = useMemoizedFn((chat: ChatSiteItemType) => { - if (feedbackType !== FeedbackTypeEnum.admin) return; - return () => { - if (!chat.dataId || !chatId || !appId) return; - setChatRecords((state) => - state.map((chatItem) => - chatItem.dataId === chat.dataId ? { ...chatItem, userGoodFeedback: undefined } : chatItem - ) - ); - updateChatUserFeedback({ - appId, - chatId, - dataId: chat.dataId, - userGoodFeedback: undefined, - ...outLinkAuthData - }); - }; - }); const onAddUserDislike = useMemoizedFn((chat: ChatSiteItemType) => { if ( feedbackType !== FeedbackTypeEnum.user || @@ -850,16 +831,6 @@ const ChatBox = ({ return () => setFeedbackId(chat.dataId); } }); - const onReadUserDislike = useMemoizedFn((chat: ChatSiteItemType) => { - if (feedbackType !== FeedbackTypeEnum.admin || chat.obj !== ChatRoleEnum.AI) return; - return () => { - if (!chat.dataId) return; - setReadFeedbackData({ - dataId: chat.dataId || '', - content: chat.userBadFeedback || '' - }); - }; - }); const onCloseCustomFeedback = useMemoizedFn((chat: ChatSiteItemType, i: number) => { return (e: React.ChangeEvent) => { if (e.target.checked && appId && chatId && chat.dataId) { @@ -883,26 +854,37 @@ const ChatBox = ({ } }; }); + const onToggleFeedbackReadStatus = useMemoizedFn((chat: ChatSiteItemType) => { + if (chatType !== ChatTypeEnum.log || chat.obj !== ChatRoleEnum.AI) return; + return async () => { + if (!appId || !chatId || !chat.dataId) return; + + const newReadStatus = !chat.isFeedbackRead; + + try { + await updateFeedbackReadStatus({ + appId, + chatId, + dataId: chat.dataId, + isRead: newReadStatus + }); + + setChatRecords((state) => + state.map((item) => + item.dataId === chat.dataId + ? { + ...item, + isFeedbackRead: newReadStatus + } + : item + ) + ); + + onTriggerRefresh?.(); + } catch (error) {} + }; + }); - const showEmpty = useMemo( - () => - chatType !== ChatTypeEnum.home && - feConfigs?.show_emptyChat && - showEmptyIntro && - chatRecords.length === 0 && - !commonVariableList?.length && - !showExternalVariable && - !welcomeText, - [ - chatType, - feConfigs?.show_emptyChat, - showEmptyIntro, - chatRecords.length, - commonVariableList?.length, - showExternalVariable, - welcomeText - ] - ); const statusBoxData = useCreation(() => { if (!isChatting) return; const chatContent = chatRecords[chatRecords.length - 1]; @@ -1033,7 +1015,12 @@ const ChatBox = ({ return ( {chatRecords.map((item, index) => ( - + { + itemRefs.current.set(item.dataId, e); + }} + > {/* 并且时间和上一条的time相差超过十分钟 */} {index !== 0 && item.time && @@ -1067,9 +1054,8 @@ const ChatBox = ({ formatChatValue2InputType(chatRecords[index - 1]?.value)?.text ), onAddUserLike: onAddUserLike(item), - onCloseUserLike: onCloseUserLike(item), onAddUserDislike: onAddUserDislike(item), - onReadUserDislike: onReadUserDislike(item) + onToggleFeedbackReadStatus: onToggleFeedbackReadStatus(item) }} > {/* custom feedback */} @@ -1124,11 +1110,11 @@ const ChatBox = ({ questionGuides, onMark, onAddUserLike, - onCloseUserLike, onAddUserDislike, - onReadUserDislike, + onToggleFeedbackReadStatus, t, showMarkIcon, + itemRefs, onCloseCustomFeedback ]); @@ -1144,8 +1130,7 @@ const ChatBox = ({ px={[4, 0]} pb={6} > - - {showEmpty && } + {!!welcomeText && } {/* variable input */} @@ -1157,7 +1142,7 @@ const ChatBox = ({ ); - }, [ScrollData, showEmpty, welcomeText, chatStarted, chatForm, chatType, RecordsBox]); + }, [ScrollData, welcomeText, chatStarted, chatForm, chatType, RecordsBox]); const HomeChatRenderBox = useMemo(() => { return ( <> @@ -1248,31 +1233,6 @@ const ChatBox = ({ }} /> )} - {/* admin read feedback modal */} - {!!readFeedbackData && ( - setReadFeedbackData(undefined)} - onCloseFeedback={() => { - setChatRecords((state) => - state.map((chatItem) => - chatItem.dataId === readFeedbackData.dataId - ? { ...chatItem, userBadFeedback: undefined } - : chatItem - ) - ); - try { - if (!chatId || !appId) return; - updateChatUserFeedback({ - appId, - chatId, - dataId: readFeedbackData.dataId - }); - } catch (error) {} - setReadFeedbackData(undefined); - }} - /> - )} {/* admin mark data */} {!!adminMarkData && ( - state.map((chatItem) => - chatItem.dataId === readFeedbackData.dataId - ? { ...chatItem, userBadFeedback: undefined } - : chatItem - ) - ); - setReadFeedbackData(undefined); - } }} /> )} diff --git a/projects/app/src/components/core/chat/ChatContainer/PluginRunBox/context.tsx b/projects/app/src/components/core/chat/ChatContainer/PluginRunBox/context.tsx index 666305d55..b31534dd6 100644 --- a/projects/app/src/components/core/chat/ChatContainer/PluginRunBox/context.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/PluginRunBox/context.tsx @@ -16,7 +16,6 @@ import { useTranslation } from 'next-i18next'; import { type ChatBoxInputFormType } from '../ChatBox/type'; import { chats2GPTMessages } from '@fastgpt/global/core/chat/adapt'; import { clientGetWorkflowToolRunUserQuery } from '@fastgpt/global/core/workflow/utils'; -import { cloneDeep } from 'lodash'; import { ChatItemContext } from '@/web/core/chat/context/chatItemContext'; import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext'; import { type AppFileSelectConfigType } from '@fastgpt/global/core/app/type'; @@ -190,6 +189,7 @@ const PluginRunContextProvider = ({ abortRequest(); const abortSignal = new AbortController(); chatController.current = abortSignal; + const humanChatItemId = getNanoid(24); const responseChatItemId = getNanoid(24); setChatRecords([ @@ -199,9 +199,12 @@ const PluginRunContextProvider = ({ variables, files: files as RuntimeUserPromptType['files'] }), + id: humanChatItemId, + dataId: humanChatItemId, status: 'finish' }, { + id: responseChatItemId, dataId: responseChatItemId, obj: ChatRoleEnum.AI, value: [ diff --git a/projects/app/src/components/support/apikey/Table.tsx b/projects/app/src/components/support/apikey/Table.tsx index 0f9a59bdb..b77765c57 100644 --- a/projects/app/src/components/support/apikey/Table.tsx +++ b/projects/app/src/components/support/apikey/Table.tsx @@ -220,7 +220,7 @@ const ApiKeyTable = ({ tips, appId }: { tips: string; appId?: string }) => { label: t('common:Delete'), icon: 'delete', type: 'danger', - onClick: () => openConfirm(() => onclickRemove(_id))() + onClick: () => openConfirm({ onConfirm: () => onclickRemove(_id) })() } ] } diff --git a/projects/app/src/components/support/permission/DefaultPerList/index.tsx b/projects/app/src/components/support/permission/DefaultPerList/index.tsx index 8054b256c..7c4e3c628 100644 --- a/projects/app/src/components/support/permission/DefaultPerList/index.tsx +++ b/projects/app/src/components/support/permission/DefaultPerList/index.tsx @@ -51,11 +51,10 @@ const DefaultPermissionList = ({ value={per} onChange={(per) => { if (isInheritPermission && hasParent) { - openConfirm( - () => onRequestChange(per), - undefined, - t('common:permission.Remove InheritPermission Confirm') - )(); + openConfirm({ + onConfirm: () => onRequestChange(per), + customContent: t('common:permission.Remove InheritPermission Confirm') + })(); } else { return onRequestChange(per); } diff --git a/projects/app/src/components/support/permission/MemberManager/MemberModal.tsx b/projects/app/src/components/support/permission/MemberManager/MemberModal.tsx index c2d56d494..1712daa51 100644 --- a/projects/app/src/components/support/permission/MemberManager/MemberModal.tsx +++ b/projects/app/src/components/support/permission/MemberManager/MemberModal.tsx @@ -147,7 +147,7 @@ function MemberModal({ newChildClbs }); if (isConflict && isInheritPermission) { - return openConfirmDisableInheritPer(_onConfirm)(); + return openConfirmDisableInheritPer({ onConfirm: _onConfirm })(); } else { return _onConfirm(); } diff --git a/projects/app/src/components/support/permission/ResumeInheritText/index.tsx b/projects/app/src/components/support/permission/ResumeInheritText/index.tsx index e70152392..2627fd779 100644 --- a/projects/app/src/components/support/permission/ResumeInheritText/index.tsx +++ b/projects/app/src/components/support/permission/ResumeInheritText/index.tsx @@ -23,17 +23,16 @@ const ResumeInherit = ({ cursor={'pointer'} _hover={{ color: 'primary.600' }} onClick={() => { - openCommonConfirm( - () => + openCommonConfirm({ + onConfirm: () => onResume()?.then(() => { toast({ title: t('common:permission.Resume InheritPermission Success'), status: 'success' }); }), - undefined, - t('common:permission.Resume InheritPermission Confirm') - )(); + customContent: t('common:permission.Resume InheritPermission Confirm') + })(); }} > {t('common:click_to_resume')} diff --git a/projects/app/src/global/core/api/appReq.d.ts b/projects/app/src/global/core/api/appReq.d.ts deleted file mode 100644 index 4b5d53142..000000000 --- a/projects/app/src/global/core/api/appReq.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ChatSourceEnum } from '@fastgpt/global/core/chat/constants'; -import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; -import type { PaginationProps } from '@fastgpt/web/common/fetch/type'; -import type { I18nName } from '@fastgpt/service/common/geo/type'; - -export type GetAppChatLogsProps = { - appId: string; - dateStart: string | Date; - dateEnd: string | Date; - sources?: ChatSourceEnum[]; - tmbIds?: string[]; - chatSearch?: string; -}; - -export type GetAppChatLogsParams = PaginationProps; diff --git a/projects/app/src/global/support/api/userRes.d.ts b/projects/app/src/global/support/api/userRes.d.ts index d9c9062c5..010967e44 100644 --- a/projects/app/src/global/support/api/userRes.d.ts +++ b/projects/app/src/global/support/api/userRes.d.ts @@ -1,4 +1,4 @@ -import type { UserType } from '@fastgpt/global/support/user/type.d'; +import type { UserType } from '@fastgpt/global/support/user/type'; import type { PromotionRecordSchema } from '@fastgpt/global/support/activity/type.d'; export interface LoginSuccessResponse { user: UserType; diff --git a/projects/app/src/pageComponents/account/AccountContainer.tsx b/projects/app/src/pageComponents/account/AccountContainer.tsx index 6b31608c7..3510e48cc 100644 --- a/projects/app/src/pageComponents/account/AccountContainer.tsx +++ b/projects/app/src/pageComponents/account/AccountContainer.tsx @@ -139,9 +139,11 @@ const AccountContainer = ({ const setCurrentTab = useCallback( (tab: string) => { if (tab === TabEnum.loginout) { - openConfirm(() => { - setUserInfo(null); - router.replace('/login'); + openConfirm({ + onConfirm: () => { + setUserInfo(null); + router.replace('/login'); + } })(); } else { router.replace('/account/' + tab); diff --git a/projects/app/src/pageComponents/account/model/Channel/index.tsx b/projects/app/src/pageComponents/account/model/Channel/index.tsx index 5607c818e..e262ff6b2 100644 --- a/projects/app/src/pageComponents/account/model/Channel/index.tsx +++ b/projects/app/src/pageComponents/account/model/Channel/index.tsx @@ -215,13 +215,12 @@ const ChannelTable = ({ Tab }: { Tab: React.ReactNode }) => { icon: 'delete', label: t('common:Delete'), onClick: () => - openConfirm( - () => onDeleteChannel(item.id), - undefined, - t('account_model:confirm_delete_channel', { + openConfirm({ + onConfirm: () => onDeleteChannel(item.id), + customContent: t('account_model:confirm_delete_channel', { name: item.name }) - )() + })() } ] } diff --git a/projects/app/src/pageComponents/account/team/GroupManage/index.tsx b/projects/app/src/pageComponents/account/team/GroupManage/index.tsx index cdd9ec169..2ff54d8a7 100644 --- a/projects/app/src/pageComponents/account/team/GroupManage/index.tsx +++ b/projects/app/src/pageComponents/account/team/GroupManage/index.tsx @@ -188,7 +188,9 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) { label: t('common:Delete'), icon: 'delete', onClick: () => { - openDeleteGroupModal(() => delDeleteGroup(group._id))(); + openDeleteGroupModal({ + onConfirm: () => delDeleteGroup(group._id) + })(); }, type: 'danger' as MenuItemType } diff --git a/projects/app/src/pageComponents/account/team/OrgManage/index.tsx b/projects/app/src/pageComponents/account/team/OrgManage/index.tsx index 10d191cde..5963f96a0 100644 --- a/projects/app/src/pageComponents/account/team/OrgManage/index.tsx +++ b/projects/app/src/pageComponents/account/team/OrgManage/index.tsx @@ -98,7 +98,8 @@ function OrgTable({ Tabs }: { Tabs: React.ReactNode }) { type: 'delete', content: t('account_team:confirm_delete_org') }); - const deleteOrgHandler = (orgId: string) => openDeleteOrgModal(() => deleteOrgReq(orgId))(); + const deleteOrgHandler = (orgId: string) => + openDeleteOrgModal({ onConfirm: () => deleteOrgReq(orgId) })(); const { runAsync: deleteOrgReq } = useRequest2(deleteOrg, { onSuccess: refresh }); @@ -229,13 +230,15 @@ function OrgTable({ Tabs }: { Tabs: React.ReactNode }) { username: member.memberName }), onClick: () => { - openDeleteMemberFromTeamModal( - () => deleteMemberFromTeamReq(member.tmbId), - undefined, - t('account_team:confirm_delete_from_team', { - username: member.memberName - }) - )(); + openDeleteMemberFromTeamModal({ + onConfirm: () => deleteMemberFromTeamReq(member.tmbId), + customContent: t( + 'account_team:confirm_delete_from_team', + { + username: member.memberName + } + ) + })(); } }, ...(isSyncMember @@ -250,8 +253,8 @@ function OrgTable({ Tabs }: { Tabs: React.ReactNode }) { }, label: t('account_team:delete_from_org'), onClick: () => - openDeleteMemberFromOrgModal( - () => { + openDeleteMemberFromOrgModal({ + onConfirm: () => { if (currentOrg) { return deleteMemberReq( currentOrg._id, @@ -259,11 +262,13 @@ function OrgTable({ Tabs }: { Tabs: React.ReactNode }) { ); } }, - undefined, - t('account_team:confirm_delete_from_org', { - username: member.memberName - }) - )() + customContent: t( + 'account_team:confirm_delete_from_org', + { + username: member.memberName + } + ) + })() } ]) ] diff --git a/projects/app/src/pageComponents/app/detail/Logs/DetailLogsModal.tsx b/projects/app/src/pageComponents/app/detail/Logs/DetailLogsModal.tsx index 9f41af2f3..0bfb95d5b 100644 --- a/projects/app/src/pageComponents/app/detail/Logs/DetailLogsModal.tsx +++ b/projects/app/src/pageComponents/app/detail/Logs/DetailLogsModal.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; import { Flex, Box } from '@chakra-ui/react'; import { useTranslation } from 'next-i18next'; import { HUMAN_ICON } from '@fastgpt/global/common/system/constants'; @@ -21,6 +21,7 @@ import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; import { useContextSelector } from 'use-context-selector'; import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList'; import { ChatTypeEnum } from '@/components/core/chat/ChatContainer/ChatBox/constants'; +import { DetailLogsModalFeedbackTypeFilter } from './FeedbackTypeFilter'; const PluginRunBox = dynamic(() => import('@/components/core/chat/ChatContainer/PluginRunBox')); const ChatBox = dynamic(() => import('@/components/core/chat/ChatContainer/ChatBox')); @@ -31,10 +32,24 @@ type Props = { onClose: () => void; }; -const DetailLogsModal = ({ appId, chatId, onClose }: Props) => { +const DetailLogsModal = ({ + appId, + chatId, + onClose, + + feedbackRecordId, + handleRecordChange +}: Props & { + feedbackRecordId: string | undefined; + handleRecordChange: (recordId: string | undefined) => void; +}) => { const { t } = useTranslation(); const { isPc } = useSystem(); + const [refreshTrigger, setRefreshTrigger] = useState(false); + const [feedbackType, setFeedbackType] = useState<'all' | 'has_feedback' | 'good' | 'bad'>('all'); + const [unreadOnly, setUnreadOnly] = useState(false); + const resetVariables = useContextSelector(ChatItemContext, (v) => v.resetVariables); const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData); const pluginRunTab = useContextSelector(ChatItemContext, (v) => v.pluginRunTab); @@ -67,6 +82,15 @@ const DetailLogsModal = ({ appId, chatId, onClose }: Props) => { } ); + const handleScrollToChatItem = React.useCallback((dataId: string) => { + setTimeout(() => { + const element = document.querySelector(`[data-chat-id="${dataId}"]`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 100); + }, []); + const title = chat?.title; const chatModels = chat?.app?.chatModels; const isPlugin = chat?.app.type === AppTypeEnum.workflowTool; @@ -152,45 +176,66 @@ const DetailLogsModal = ({ appId, chatId, onClose }: Props) => { )} {/* Chat container */} - - - {isPlugin ? ( - - - - ) : ( - - )} - - - {datasetCiteData && ( - - setCiteModalData(undefined)} - /> + + + + {isPlugin ? ( + + + + ) : ( + setRefreshTrigger((prev) => !prev)} + /> + )} - )} + + {datasetCiteData && ( + + setCiteModalData(undefined)} + /> + + )} + + + {/* Feedback filter bar - commented out, moved to Render component */} + + + @@ -201,6 +246,8 @@ const DetailLogsModal = ({ appId, chatId, onClose }: Props) => { const Render = (props: Props) => { const { appId, chatId } = props; + const [feedbackRecordId, setFeedbackRecordId] = useState(undefined); + const params = useMemo(() => { return { chatId, @@ -210,6 +257,10 @@ const Render = (props: Props) => { }; }, [appId, chatId]); + const handleRecordChange = useCallback((recordId: string | undefined) => { + setFeedbackRecordId(recordId); + }, []); + return ( { // isShowFullText={true} showNodeStatus > - - + + ); diff --git a/projects/app/src/pageComponents/app/detail/Logs/FeedbackTypeFilter.tsx b/projects/app/src/pageComponents/app/detail/Logs/FeedbackTypeFilter.tsx new file mode 100644 index 000000000..78cbc7b70 --- /dev/null +++ b/projects/app/src/pageComponents/app/detail/Logs/FeedbackTypeFilter.tsx @@ -0,0 +1,308 @@ +import React, { useCallback, useEffect } from 'react'; +import type { ButtonProps, PlacementWithLogical } from '@chakra-ui/react'; +import { + Checkbox, + Menu, + MenuButton, + MenuList, + MenuItem, + Divider, + Flex, + Box, + Button, + useDisclosure +} from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; +import { getFeedbackRecordIds } from '@/web/core/chat/feedback/api'; +import { eventBus, EventNameEnum } from '@/web/common/utils/eventbus'; + +type FilterProps = { + feedbackType: 'all' | 'has_feedback' | 'good' | 'bad'; + setFeedbackType: (feedbackType: 'all' | 'has_feedback' | 'good' | 'bad') => void; + unreadOnly: boolean; + setUnreadOnly: (unreadOnly: boolean) => void; + menuButtonProps?: ButtonProps; + placement?: PlacementWithLogical; +}; +const FeedbackTypeFilter = ({ + feedbackType, + setFeedbackType, + unreadOnly, + setUnreadOnly, + menuButtonProps, + placement +}: FilterProps) => { + const { t } = useTranslation(); + const { isOpen, onOpen, onClose } = useDisclosure(); + + const feedbackOptions = [ + { + value: 'all' as const, + label: t('app:logs_all_records') + }, + { + value: 'has_feedback' as const, + label: t('app:logs_has_any_feedback') + }, + { + value: 'good' as const, + label: t('app:logs_has_good_feedback') + }, + { + value: 'bad' as const, + label: t('app:logs_has_bad_feedback') + } + ]; + + return ( + + } + fontWeight={'normal'} + {...menuButtonProps} + > + {feedbackType === 'all' + ? t('app:logs_keys_feedback') + : feedbackOptions.find((option) => option.value === feedbackType)?.label} + + + + {/* Radio options */} + {feedbackOptions.map((option) => ( + { + e.stopPropagation(); + e.preventDefault(); + + // When switching from "all" to other options, keep unreadOnly state + // When switching to "all", unreadOnly state is preserved but ignored in query + setFeedbackType(option.value); + + // Don't close the menu - allow user to continue selecting checkbox + }} + > + + + + + + + {option.label} + + + ))} + + {/* Divider + Checkbox (only show when feedbackType is not "all") */} + {feedbackType !== 'all' && ( + <> + + { + e.stopPropagation(); + e.preventDefault(); + setUnreadOnly(!unreadOnly); + }} + autoFocus={false} + > + + + {t('app:logs_unread_only')} + + + + + )} + + + ); +}; + +export default FeedbackTypeFilter; + +// Enhanced FeedbackTypeFilter with navigation for DetailLogsModal +export const DetailLogsModalFeedbackTypeFilter = ({ + feedbackType, + setFeedbackType, + unreadOnly, + setUnreadOnly, + menuButtonProps, + appId, + chatId, + currentRecordId, + onRecordChange +}: FilterProps & { + appId?: string; + chatId?: string; + currentRecordId?: string; + onRecordChange?: (recordId: string | undefined) => void; +}) => { + const { t } = useTranslation(); + + // Get feedback record IDs when in feedback mode + const { data: feedbackRecords, runAsync: loadFeedbackRecords } = useRequest2( + async (_feedbackType = feedbackType, _unreadOnly = unreadOnly) => { + if (!appId || !chatId || _feedbackType === 'all') return null; + return await getFeedbackRecordIds({ + appId, + chatId, + feedbackType: _feedbackType, + unreadOnly: _unreadOnly + }); + }, + { + manual: false, + refreshDeps: [appId, chatId] + } + ); + + // Calculate current position + const currentIndex = feedbackRecords?.dataIds.findIndex((id) => id === currentRecordId) ?? -1; + const currentPosition = currentIndex >= 0 ? currentIndex + 1 : 0; + const totalCount = feedbackRecords?.total ?? 0; + + // Handle feedback type change + const handleFeedbackTypeChange = useCallback( + (type: 'all' | 'has_feedback' | 'good' | 'bad') => { + setFeedbackType(type); + + if (type === 'all') { + // Switch to all records - no feedbackRecordId + loadFeedbackRecords(type); + } else { + loadFeedbackRecords(type).then((records) => { + if (!records) return; + // Select the last (latest) feedback record + const lastRecordId = records.dataIds[records.dataIds.length - 1]; + onRecordChange?.(lastRecordId); + }); + } + }, + [setFeedbackType, onRecordChange, loadFeedbackRecords] + ); + const handleUnreadOnlyChange = useCallback( + (unreadOnly: boolean) => { + setUnreadOnly(unreadOnly); + loadFeedbackRecords(feedbackType, unreadOnly).then((records) => { + if (!records) return; + // Select the last (latest) feedback record + const lastRecordId = records.dataIds[records.dataIds.length - 1]; + onRecordChange?.(lastRecordId); + }); + }, + [setUnreadOnly, loadFeedbackRecords, feedbackType, onRecordChange] + ); + + // Navigation handlers + const handlePrev = useCallback(() => { + if (!feedbackRecords) return; + if (currentIndex > 0) { + onRecordChange?.(feedbackRecords.dataIds[currentIndex - 1]); + } else { + onRecordChange?.(feedbackRecords.dataIds[feedbackRecords.dataIds.length - 1]); + } + }, [feedbackRecords, currentIndex, onRecordChange]); + + const handleNext = useCallback(() => { + if (!feedbackRecords) return; + if (currentIndex < (feedbackRecords?.dataIds.length ?? 0) - 1) { + onRecordChange?.(feedbackRecords.dataIds[currentIndex + 1]); + } else { + onRecordChange?.(feedbackRecords.dataIds[0]); + } + }, [feedbackRecords, currentIndex, onRecordChange]); + + const showNavigation = appId && chatId && feedbackType !== 'all'; + + useEffect(() => { + eventBus.on(EventNameEnum.refreshFeedback, () => { + loadFeedbackRecords(); + }); + return () => { + eventBus.off(EventNameEnum.refreshFeedback); + }; + }, []); + + return ( + + + + {showNavigation && ( + <> + {/* Current position indicator */} + + {currentPosition}/{totalCount} + + + {/* Previous button */} + + + + )} + + ); +}; diff --git a/projects/app/src/pageComponents/app/detail/Logs/LogTable.tsx b/projects/app/src/pageComponents/app/detail/Logs/LogTable.tsx index a581a81bb..320973154 100644 --- a/projects/app/src/pageComponents/app/detail/Logs/LogTable.tsx +++ b/projects/app/src/pageComponents/app/detail/Logs/LogTable.tsx @@ -17,7 +17,7 @@ import { ChatSourceMap } from '@fastgpt/global/core/chat/constants'; import MultipleSelect, { useMultipleSelect } from '@fastgpt/web/components/common/MySelect/MultipleSelect'; -import React, { useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'next-i18next'; import DateRangePicker from '@fastgpt/web/components/common/DateRangePicker'; import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination'; @@ -40,17 +40,19 @@ import { downloadFetch } from '@/web/common/system/utils'; import { usePagination } from '@fastgpt/web/hooks/usePagination'; import { getAppChatLogs } from '@/web/core/app/api/log'; import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; -import type { AppLogsListItemType } from '@/types/app'; +import type { AppLogsListItemType } from '@fastgpt/global/openapi/core/app/log/api'; import dayjs from 'dayjs'; import UserBox from '@fastgpt/web/components/common/UserBox'; import MyIcon from '@fastgpt/web/components/common/Icon'; import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; import dynamic from 'next/dynamic'; import type { HeaderControlProps } from './LogChart'; +import FeedbackTypeFilter from './FeedbackTypeFilter'; import { useSystemStore } from '@/web/common/system/useSystemStore'; 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'; const DetailLogsModal = dynamic(() => import('./DetailLogsModal')); @@ -70,6 +72,8 @@ const LogTable = ({ const [detailLogsId, setDetailLogsId] = useState(); const appName = useContextSelector(AppContext, (v) => v.appDetail.name); + const [feedbackType, setFeedbackType] = useState<'all' | 'has_feedback' | 'good' | 'bad'>('all'); + const [unreadOnly, setUnreadOnly] = useState(false); // source const sourceList = useMemo( @@ -142,45 +146,44 @@ const LogTable = ({ return !isEqual(teamLogKeysList, personalLogKeysList); }, [teamLogKeys, logKeys]); - const { runAsync: exportLogs } = useRequest2( - async () => { - const enabledKeys = logKeys.filter((item) => item.enable).map((item) => item.key); - const headerTitle = enabledKeys.map((k) => t(AppLogKeysEnumMap[k])).join(','); - await downloadFetch({ - url: '/api/core/app/exportChatLogs', - filename: t('app:export_log_filename', { name: appName }), - body: { - appId, - dateStart: dayjs(dateRange.from || new Date()).format(), - dateEnd: dayjs(dateRange.to || new Date()).format(), - sources: isSelectAllSource ? undefined : chatSources, - tmbIds: isSelectAllTmb ? undefined : selectTmbIds, - chatSearch, - title: `${headerTitle},${t('app:logs_keys_chatDetails')}`, - logKeys: enabledKeys, - sourcesMap: Object.fromEntries( - Object.entries(ChatSourceMap).map(([key, config]) => [ - key, - { - label: t(config.name as any) - } - ]) - ) - } - }); - }, - { - refreshDeps: [chatSources] - } - ); - const params = useMemo( + const { runAsync: exportLogs } = useRequest2(async () => { + const enabledKeys = logKeys.filter((item) => item.enable).map((item) => item.key); + const headerTitle = enabledKeys.map((k) => t(AppLogKeysEnumMap[k])).join(','); + await downloadFetch({ + url: '/api/core/app/logs/exportLogs', + filename: t('app:export_log_filename', { name: appName }), + body: { + appId, + dateStart: dayjs(dateRange.from || new Date()).format(), + dateEnd: dayjs(dateRange.to || new Date()).format(), + sources: isSelectAllSource ? undefined : chatSources, + tmbIds: isSelectAllTmb ? undefined : selectTmbIds, + chatSearch, + title: `${headerTitle},${t('app:logs_keys_chatDetails')}`, + logKeys: enabledKeys, + sourcesMap: Object.fromEntries( + Object.entries(ChatSourceMap).map(([key, config]) => [ + key, + { + label: t(config.name as any) + } + ]) + ), + feedbackType, + unreadOnly + } + }); + }); + const params = useMemoEnhance( () => ({ appId, dateStart: dateRange.from!, dateEnd: dateRange.to!, sources: isSelectAllSource ? undefined : chatSources, tmbIds: isSelectAllTmb ? undefined : selectTmbIds, - chatSearch + chatSearch, + feedbackType, + unreadOnly: feedbackType === 'all' ? undefined : unreadOnly }), [ appId, @@ -190,7 +193,9 @@ const LogTable = ({ isSelectAllSource, selectTmbIds, isSelectAllTmb, - chatSearch + chatSearch, + feedbackType, + unreadOnly ] ); @@ -228,7 +233,25 @@ const LogTable = ({ [AppLogKeysEnum.MESSAGE_COUNT]: ( {t('app:logs_message_total')} ), - [AppLogKeysEnum.FEEDBACK]: {t('app:feedback_count')}, + [AppLogKeysEnum.FEEDBACK]: ( + + + + ), [AppLogKeysEnum.CUSTOM_FEEDBACK]: ( {t('common:core.app.feedback.Custom feedback')} @@ -248,102 +271,104 @@ const LogTable = ({ [AppLogKeysEnum.ERROR_COUNT]: ( {t('app:logs_error_count')} ), - [AppLogKeysEnum.POINTS]: {t('app:logs_points')} + [AppLogKeysEnum.POINTS]: {t('app:logs_points')}, + [AppLogKeysEnum.VERSION_NAME]: ( + {t('app:logs_keys_versionName')} + ) + }), + [t, feedbackType, setFeedbackType, unreadOnly, setUnreadOnly] + ); + + const getCellRenderMap = useCallback( + (item: AppLogsListItemType) => ({ + [AppLogKeysEnum.SOURCE]: ( + + {/* @ts-ignore */} + {item.sourceName || t(ChatSourceMap[item.source]?.name) || item.source} + + ), + [AppLogKeysEnum.CREATED_TIME]: ( + + {dayjs(item.createTime).format('YYYY/MM/DD HH:mm')} + + ), + [AppLogKeysEnum.LAST_CONVERSATION_TIME]: ( + + {dayjs(item.updateTime).format('YYYY/MM/DD HH:mm')} + + ), + [AppLogKeysEnum.USER]: ( + + + {!!item.outLinkUid ? ( + item.outLinkUid + ) : item.sourceMember ? ( + + ) : ( + '-' + )} + + + ), + [AppLogKeysEnum.REGION]: {item.region || '-'}, + [AppLogKeysEnum.TITLE]: ( + + {item.customTitle || item.title} + + ), + [AppLogKeysEnum.SESSION_ID]: ( + + {item.chatId || '-'} + + ), + [AppLogKeysEnum.MESSAGE_COUNT]: ( + {item.messageCount} + ), + [AppLogKeysEnum.FEEDBACK]: ( + + + {!!item?.userGoodFeedbackCount && ( + + + {item.userGoodFeedbackCount} + + )} + {!!item?.userBadFeedbackCount && ( + + + {item.userBadFeedbackCount} + + )} + {!item?.userGoodFeedbackCount && !item?.userBadFeedbackCount && <>-} + + + ), + [AppLogKeysEnum.CUSTOM_FEEDBACK]: ( + {item.customFeedbacksCount || '-'} + ), + [AppLogKeysEnum.ANNOTATED_COUNT]: ( + {item.markCount} + ), + [AppLogKeysEnum.RESPONSE_TIME]: ( + + {item.averageResponseTime ? `${item.averageResponseTime.toFixed(2)}s` : '-'} + + ), + [AppLogKeysEnum.ERROR_COUNT]: ( + {item.errorCount || '-'} + ), + [AppLogKeysEnum.POINTS]: ( + + {item.totalPoints ? `${item.totalPoints.toFixed(2)}` : '-'} + + ), + [AppLogKeysEnum.VERSION_NAME]: ( + {item.versionName || '-'} + ) }), [t] ); - const getCellRenderMap = (item: AppLogsListItemType) => ({ - [AppLogKeysEnum.SOURCE]: ( - - {/* @ts-ignore */} - {item.sourceName || t(ChatSourceMap[item.source]?.name) || item.source} - - ), - [AppLogKeysEnum.CREATED_TIME]: ( - {dayjs(item.createTime).format('YYYY/MM/DD HH:mm')} - ), - [AppLogKeysEnum.LAST_CONVERSATION_TIME]: ( - - {dayjs(item.updateTime).format('YYYY/MM/DD HH:mm')} - - ), - [AppLogKeysEnum.USER]: ( - - - {!!item.outLinkUid ? item.outLinkUid : } - - - ), - [AppLogKeysEnum.REGION]: {item.region || '-'}, - [AppLogKeysEnum.TITLE]: ( - - {item.customTitle || item.title} - - ), - [AppLogKeysEnum.SESSION_ID]: ( - - {item.id || '-'} - - ), - [AppLogKeysEnum.MESSAGE_COUNT]: {item.messageCount}, - [AppLogKeysEnum.FEEDBACK]: ( - - {!!item?.userGoodFeedbackCount && ( - - - {item.userGoodFeedbackCount} - - )} - {!!item?.userBadFeedbackCount && ( - - - {item.userBadFeedbackCount} - - )} - {!item?.userGoodFeedbackCount && !item?.userBadFeedbackCount && <>-} - - ), - [AppLogKeysEnum.CUSTOM_FEEDBACK]: ( - {item.customFeedbacksCount || '-'} - ), - [AppLogKeysEnum.ANNOTATED_COUNT]: ( - {item.markCount} - ), - [AppLogKeysEnum.RESPONSE_TIME]: ( - - {item.averageResponseTime ? `${item.averageResponseTime.toFixed(2)}s` : '-'} - - ), - [AppLogKeysEnum.ERROR_COUNT]: ( - {item.errorCount || '-'} - ), - [AppLogKeysEnum.POINTS]: ( - - {item.totalPoints ? `${item.totalPoints.toFixed(2)}` : '-'} - - ) - }); - return ( @@ -492,7 +517,7 @@ const LogTable = ({ _hover={{ bg: 'myWhite.600' }} cursor={'pointer'} title={t('common:core.view_chat_detail')} - onClick={() => setDetailLogsId(item.id)} + onClick={() => setDetailLogsId(item.chatId)} > {logKeys .filter((logKey) => logKey.enable) diff --git a/projects/app/src/pageComponents/app/detail/Logs/SyncLogKeysPopover.tsx b/projects/app/src/pageComponents/app/detail/Logs/SyncLogKeysPopover.tsx index a1c6d29ea..7c3872872 100644 --- a/projects/app/src/pageComponents/app/detail/Logs/SyncLogKeysPopover.tsx +++ b/projects/app/src/pageComponents/app/detail/Logs/SyncLogKeysPopover.tsx @@ -3,11 +3,13 @@ import { Box, Button, Flex } from '@chakra-ui/react'; import MyIcon from '@fastgpt/web/components/common/Icon'; import { useTranslation } from 'next-i18next'; import React from 'react'; -import type { updateLogKeysBody } from '@/pages/api/core/app/logs/updateLogKeys'; import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; import { updateLogKeys } from '@/web/core/app/api/log'; import type { AppLogKeysType } from '@fastgpt/global/core/app/logs/type'; -import type { getLogKeysResponse } from '@/pages/api/core/app/logs/getLogKeys'; +import type { + getLogKeysResponseType, + updateLogKeysBody +} from '@fastgpt/global/openapi/core/app/log/api'; import type { SetState } from 'ahooks/lib/createUseStorageState'; const SyncLogKeysPopover = ({ @@ -20,7 +22,7 @@ const SyncLogKeysPopover = ({ logKeys: AppLogKeysType[]; setLogKeys: (value: SetState) => void; teamLogKeys: AppLogKeysType[]; - fetchLogKeys: () => Promise; + fetchLogKeys: () => Promise; appId: string; }) => { const { t } = useTranslation(); diff --git a/projects/app/src/pageComponents/app/detail/Plugin/index.tsx b/projects/app/src/pageComponents/app/detail/Plugin/index.tsx index 4ab2c9467..f5354f6a2 100644 --- a/projects/app/src/pageComponents/app/detail/Plugin/index.tsx +++ b/projects/app/src/pageComponents/app/detail/Plugin/index.tsx @@ -33,8 +33,10 @@ const WorkflowEdit = () => { useMount(() => { if (!isV2Workflow) { - openConfirm(() => { - initData(JSON.parse(JSON.stringify(v1Workflow2V2((appDetail.modules || []) as any)))); + openConfirm({ + onConfirm: () => { + initData(JSON.parse(JSON.stringify(v1Workflow2V2((appDetail.modules || []) as any)))); + } })(); } else { initData( diff --git a/projects/app/src/pageComponents/app/detail/Publish/Link/index.tsx b/projects/app/src/pageComponents/app/detail/Publish/Link/index.tsx index 25bdfbc35..124fb3834 100644 --- a/projects/app/src/pageComponents/app/detail/Publish/Link/index.tsx +++ b/projects/app/src/pageComponents/app/detail/Publish/Link/index.tsx @@ -194,15 +194,17 @@ const Share = ({ appId }: { appId: string; type: PublishChannelEnum }) => { icon: 'delete', type: 'danger', onClick: () => - openConfirm(async () => { - setIsLoading(true); - try { - await delShareChatById(item._id); - refetchShareChatList(); - } catch (error) { - console.log(error); + openConfirm({ + onConfirm: async () => { + setIsLoading(true); + try { + await delShareChatById(item._id); + refetchShareChatList(); + } catch (error) { + console.log(error); + } + setIsLoading(false); } - setIsLoading(false); })() } ] diff --git a/projects/app/src/pageComponents/app/detail/SimpleApp/AppCard.tsx b/projects/app/src/pageComponents/app/detail/SimpleApp/AppCard.tsx index cbcdf4588..9fb08b24a 100644 --- a/projects/app/src/pageComponents/app/detail/SimpleApp/AppCard.tsx +++ b/projects/app/src/pageComponents/app/detail/SimpleApp/AppCard.tsx @@ -105,7 +105,10 @@ const AppCard = ({ variant={'whitePrimary'} leftIcon={} onClick={() => - router.push(`/chat?appId=${appId}&pane=${ChatSidebarPaneEnum.RECENTLY_USED_APPS}`) + window.open( + `/chat?appId=${appId}&pane=${ChatSidebarPaneEnum.RECENTLY_USED_APPS}`, + '_blank' + ) } > {t('common:core.Chat')} diff --git a/projects/app/src/pageComponents/app/detail/SimpleApp/index.tsx b/projects/app/src/pageComponents/app/detail/SimpleApp/index.tsx index 882d86c73..e6aa7b6e3 100644 --- a/projects/app/src/pageComponents/app/detail/SimpleApp/index.tsx +++ b/projects/app/src/pageComponents/app/detail/SimpleApp/index.tsx @@ -82,7 +82,7 @@ const SimpleEdit = () => { nodes: appDetail.modules, chatConfig: { ...appDetail.chatConfig, - fileSelectConfig: appDetail.chatConfig.fileSelectConfig || { + fileSelectConfig: appDetail.chatConfig?.fileSelectConfig || { ...defaultAppSelectFileConfig, canSelectFile: true } diff --git a/projects/app/src/pageComponents/app/detail/Workflow/index.tsx b/projects/app/src/pageComponents/app/detail/Workflow/index.tsx index e868e5f40..1c243eb8b 100644 --- a/projects/app/src/pageComponents/app/detail/Workflow/index.tsx +++ b/projects/app/src/pageComponents/app/detail/Workflow/index.tsx @@ -36,8 +36,13 @@ const WorkflowEdit = () => { useMount(() => { if (!isV2Workflow) { - openConfirm(() => { - initData(JSON.parse(JSON.stringify(v1Workflow2V2((appDetail.modules || []) as any))), true); + openConfirm({ + onConfirm: () => { + initData( + JSON.parse(JSON.stringify(v1Workflow2V2((appDetail.modules || []) as any))), + true + ); + } })(); } else { initData( diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/index.tsx index 999bb123f..f3a10b78a 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/index.tsx @@ -64,23 +64,25 @@ const NodeCode = ({ data, selected }: NodeProps) => { ]} value={codeType?.value} onChange={(newLang) => { - openSwitchLangConfirm(() => { - onChangeNode({ - nodeId, - type: 'updateInput', - key: NodeInputKeyEnum.codeType, - value: { ...codeType, value: newLang } - }); + openSwitchLangConfirm({ + onConfirm: () => { + onChangeNode({ + nodeId, + type: 'updateInput', + key: NodeInputKeyEnum.codeType, + value: { ...codeType, value: newLang } + }); - onChangeNode({ - nodeId, - type: 'updateInput', - key: item.key, - value: { - ...item, - value: SANDBOX_CODE_TEMPLATE[newLang] - } - }); + onChangeNode({ + nodeId, + type: 'updateInput', + key: item.key, + value: { + ...item, + value: SANDBOX_CODE_TEMPLATE[newLang] + } + }); + } })(); }} /> diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx index b0f2fba7a..71a2b3095 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx @@ -22,7 +22,7 @@ import { stringConditionList } from '@fastgpt/global/core/workflow/template/system/ifElse/constant'; import { useContextSelector } from 'use-context-selector'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { WorkflowBufferDataContext } from '../../../context/workflowInitContext'; import MySelect from '@fastgpt/web/components/common/MySelect'; import MyInput from '@/components/MyInput'; @@ -452,10 +452,6 @@ const ConditionValueInput = ({ return output?.valueType; } }, [globalVariables, getNodeById, variable]); - const { referenceList } = useReference({ - nodeId, - valueType - }); const showBooleanSelect = useMemo(() => { return ( @@ -473,6 +469,45 @@ const ConditionValueInput = ({ ); }, [condition, valueType]); + // Get array element type for include/notInclude operations + const getArrayElementType = useCallback((arrayType: WorkflowIOValueTypeEnum) => { + const typeMap: Record = { + [WorkflowIOValueTypeEnum.arrayString]: WorkflowIOValueTypeEnum.string, + [WorkflowIOValueTypeEnum.arrayNumber]: WorkflowIOValueTypeEnum.number, + [WorkflowIOValueTypeEnum.arrayBoolean]: WorkflowIOValueTypeEnum.boolean, + [WorkflowIOValueTypeEnum.arrayObject]: WorkflowIOValueTypeEnum.object, + [WorkflowIOValueTypeEnum.arrayAny]: WorkflowIOValueTypeEnum.any + }; + return typeMap[arrayType] || arrayType; + }, []); + + // Adjust reference value type based on condition + const referenceValueType = useMemo(() => { + if (!valueType?.includes('array') || !condition) { + return valueType; + } + + // Array length operations need number type + if (renderNumberConditionList.has(condition)) { + return WorkflowIOValueTypeEnum.number; + } + + // Array include/notInclude operations need element type + if ( + condition === VariableConditionEnum.include || + condition === VariableConditionEnum.notInclude + ) { + return getArrayElementType(valueType); + } + + return valueType; + }, [condition, valueType, getArrayElementType]); + + const { referenceList } = useReference({ + nodeId, + valueType: referenceValueType + }); + const RenderInput = useMemo(() => { if (showBooleanSelect) { return ( diff --git a/projects/app/src/pageComponents/app/detail/context.tsx b/projects/app/src/pageComponents/app/detail/context.tsx index cfb807db8..22c59f5f3 100644 --- a/projects/app/src/pageComponents/app/detail/context.tsx +++ b/projects/app/src/pageComponents/app/detail/context.tsx @@ -207,11 +207,10 @@ const AppContextProvider = ({ children }: { children: ReactNode }) => { ); const onDelApp = useCallback( () => - openConfirmDel( - deleteApp, - undefined, - t('app:confirm_del_app_tip', { name: appDetail.name }) - )(), + openConfirmDel({ + onConfirm: deleteApp, + customContent: t('app:confirm_del_app_tip', { name: appDetail.name }) + })(), [appDetail.name, deleteApp, openConfirmDel, t] ); diff --git a/projects/app/src/pageComponents/chat/ChatQuoteList/CollectionQuoteReader.tsx b/projects/app/src/pageComponents/chat/ChatQuoteList/CollectionQuoteReader.tsx index ae3f37215..a10cc1613 100644 --- a/projects/app/src/pageComponents/chat/ChatQuoteList/CollectionQuoteReader.tsx +++ b/projects/app/src/pageComponents/chat/ChatQuoteList/CollectionQuoteReader.tsx @@ -49,7 +49,12 @@ const CollectionReader = ({ const filterResults = useMemo(() => { const res = rawSearch .filter((item) => item.collectionId === collectionId) - .sort((a, b) => (a.chunkIndex || 0) - (b.chunkIndex || 0)); + .sort((a, b) => { + const chunkDiff = (a.chunkIndex || 0) - (b.chunkIndex || 0); + if (chunkDiff !== 0) return chunkDiff; + + return a.id.localeCompare(b.id); + }); if (quoteId) { setQuoteIndex(res.findIndex((item) => item.id === quoteId)); @@ -65,7 +70,7 @@ const CollectionReader = ({ if (item) { return { id: item.id, - index: item.chunkIndex, + anchor: item.chunkIndex, score: item.score }; } diff --git a/projects/app/src/pageComponents/chat/ChatSetting/FavouriteAppSetting/TagManageModal.tsx b/projects/app/src/pageComponents/chat/ChatSetting/FavouriteAppSetting/TagManageModal.tsx index 6fa9df1bf..56e7cd9a3 100644 --- a/projects/app/src/pageComponents/chat/ChatSetting/FavouriteAppSetting/TagManageModal.tsx +++ b/projects/app/src/pageComponents/chat/ChatSetting/FavouriteAppSetting/TagManageModal.tsx @@ -65,8 +65,10 @@ const EditableTagItem = React.memo(function EditableTagItem({ }); const handleConfirmDelete = useCallback(() => { - openConfirm(() => { - onConfirmDelete(tag); + openConfirm({ + onConfirm: () => { + onConfirmDelete(tag); + } })(); }, [openConfirm, onConfirmDelete, tag]); diff --git a/projects/app/src/pageComponents/chat/ChatSetting/LogDetails.tsx b/projects/app/src/pageComponents/chat/ChatSetting/LogDetails.tsx index 5a7cbe698..c7da09305 100644 --- a/projects/app/src/pageComponents/chat/ChatSetting/LogDetails.tsx +++ b/projects/app/src/pageComponents/chat/ChatSetting/LogDetails.tsx @@ -5,13 +5,16 @@ import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants'; import type { DateRangeType } from '@fastgpt/web/components/common/DateRangePicker'; import { useMultipleSelect } from '@fastgpt/web/components/common/MySelect/MultipleSelect'; import { addDays } from 'date-fns'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useContextSelector } from 'use-context-selector'; type Props = { Header: React.FC<{ children?: React.ReactNode }>; }; +// Cache the chat source enum values to avoid creating new array on every render +const chatSourceValues = Object.values(ChatSourceEnum); + const LogDetails = ({ Header }: Props) => { const appId = useContextSelector(ChatSettingContext, (v) => v.chatSettings?.appId || ''); @@ -25,12 +28,11 @@ const LogDetails = ({ Header }: Props) => { setValue: setChatSources, isSelectAll: isSelectAllSource, setIsSelectAll: setIsSelectAllSource - } = useMultipleSelect(Object.values(ChatSourceEnum), true); + } = useMultipleSelect(chatSourceValues, true); return (
- { /> ) : ( import('@/components/Markdown')); -const Avatar = dynamic(() => import('@fastgpt/web/components/common/Avatar')); - -const Empty = ({ - showChatProblem, - model: { name, intro, avatar } -}: { - showChatProblem: boolean; - model: { - name: string; - intro: string; - avatar: string; - }; -}) => { - const { data: chatProblem } = useMarkdown({ url: '/chatProblem.md' }); - const { data: versionIntro } = useMarkdown({ url: '/versionIntro.md' }); - - return ( - - {name && ( - - - - - {name} - - - {intro} - - )} - - {showChatProblem && ( - <> - {/* version intro */} - - - - - - - - )} - - ); -}; - -export default Empty; diff --git a/projects/app/src/pageComponents/chat/UserAvatarPopover.tsx b/projects/app/src/pageComponents/chat/UserAvatarPopover.tsx index 3e2058973..f7f0dc0fe 100644 --- a/projects/app/src/pageComponents/chat/UserAvatarPopover.tsx +++ b/projects/app/src/pageComponents/chat/UserAvatarPopover.tsx @@ -46,7 +46,7 @@ const UserAvatarPopover = ({ {({ onClose }) => { const onLogout = useCallback(() => { onClose(); - openConfirm(handleLogout)(); + openConfirm({ onConfirm: handleLogout })(); }, [onClose]); return ( diff --git a/projects/app/src/pageComponents/dashboard/agent/List.tsx b/projects/app/src/pageComponents/dashboard/agent/List.tsx index e36b804d1..c9951b8f0 100644 --- a/projects/app/src/pageComponents/dashboard/agent/List.tsx +++ b/projects/app/src/pageComponents/dashboard/agent/List.tsx @@ -96,7 +96,7 @@ const List = () => { borderColor: 'primary.600' }, onDrop: (dragId: string, targetId: string) => { - openMoveConfirm(async () => onPutAppById(dragId, { parentId: targetId }))(); + openMoveConfirm({ onConfirm: async () => onPutAppById(dragId, { parentId: targetId }) })(); } }); @@ -397,7 +397,9 @@ const List = () => { type: 'grayBg' as MenuItemType, label: t('app:copy_one_app'), onClick: () => - openConfirmCopy(() => onclickCopy({ appId: app._id }))() + openConfirmCopy({ + onConfirm: () => onclickCopy({ appId: app._id }) + })() } ] } @@ -411,13 +413,14 @@ const List = () => { icon: 'delete', label: t('common:Delete'), onClick: () => - openConfirmDel( - () => onclickDelApp(app._id), - undefined, - app.type === AppTypeEnum.folder - ? t('app:confirm_delete_folder_tip') - : t('app:confirm_del_app_tip', { name: app.name }) - )() + openConfirmDel({ + onConfirm: () => onclickDelApp(app._id), + inputConfirmText: app.name, + customContent: + app.type === AppTypeEnum.folder + ? t('app:confirm_delete_folder_tip') + : t('app:confirm_del_app_tip') + })() } ] } @@ -499,8 +502,11 @@ const CreateButton = ({ appType }: { appType: AppTypeEnum | 'all' }) => { const router = useRouter(); const parentId = router.query.parentId; const createAppType = - createAppTypeMap[appType as CreateAppType]?.type || - (router.pathname.includes('/agent') ? AppTypeEnum.workflow : AppTypeEnum.workflowTool); + appType !== 'all' && appType in createAppTypeMap + ? createAppTypeMap[appType as keyof typeof createAppTypeMap].type + : router.pathname.includes('/agent') + ? AppTypeEnum.workflow + : AppTypeEnum.workflowTool; const isToolType = ToolTypeList.includes(createAppType); return ( @@ -571,8 +577,11 @@ const ListCreateButton = ({ appType }: { appType: AppTypeEnum | 'all' }) => { const router = useRouter(); const parentId = router.query.parentId; const createAppType = - createAppTypeMap[appType as CreateAppType]?.type || - (router.pathname.includes('/agent') ? AppTypeEnum.workflow : AppTypeEnum.workflowTool); + appType !== 'all' && appType in createAppTypeMap + ? createAppTypeMap[appType as keyof typeof createAppTypeMap].type + : router.pathname.includes('/agent') + ? AppTypeEnum.workflow + : AppTypeEnum.workflowTool; return ( refreshDeps: [parentId, searchText, filterTags] }); - const syncDataset = async () => { + const syncDataset = useCallback(async () => { if (datasetDetail.type === DatasetTypeEnum.websiteDataset) { await checkTeamWebSyncLimit(); } @@ -110,7 +117,7 @@ const CollectionPageContextProvider = ({ children }: { children: ReactNode }) => status: 'success', title: t('dataset:collection.sync.submit') }); - }; + }, [datasetDetail.type, datasetId, getData, loadDatasetDetail, pageNum, t, toast]); // dataset sync confirm const { openConfirm: openDatasetSyncConfirm, ConfirmModal: ConfirmDatasetSyncModal } = useConfirm( @@ -141,22 +148,38 @@ const CollectionPageContextProvider = ({ children }: { children: ReactNode }) => } ); - const contextValue: CollectionPageContextType = { - openDatasetSyncConfirm: openDatasetSyncConfirm(syncDataset), - onOpenWebsiteModal, + const contextValue: CollectionPageContextType = useMemo( + () => ({ + openDatasetSyncConfirm: openDatasetSyncConfirm({ onConfirm: syncDataset }), + onOpenWebsiteModal, - searchText, - setSearchText, - filterTags, - setFilterTags, - collections, - Pagination, - total, - getData, - isGetting, - pageNum, - pageSize - }; + searchText, + setSearchText, + filterTags, + setFilterTags, + collections, + Pagination, + total, + getData, + isGetting, + pageNum, + pageSize + }), + [ + Pagination, + collections, + filterTags, + getData, + isGetting, + onOpenWebsiteModal, + openDatasetSyncConfirm, + pageNum, + pageSize, + searchText, + syncDataset, + total + ] + ); return ( diff --git a/projects/app/src/pageComponents/dataset/detail/CollectionCard/index.tsx b/projects/app/src/pageComponents/dataset/detail/CollectionCard/index.tsx index 3d60c635c..157af6265 100644 --- a/projects/app/src/pageComponents/dataset/detail/CollectionCard/index.tsx +++ b/projects/app/src/pageComponents/dataset/detail/CollectionCard/index.tsx @@ -372,8 +372,10 @@ const CollectionCard = () => { ), onClick: () => - openSyncConfirm(() => { - onclickStartSync(collection._id); + openSyncConfirm({ + onConfirm: () => { + onclickStartSync(collection._id); + } })() } ] @@ -423,13 +425,15 @@ const CollectionCard = () => { ), type: 'danger', onClick: () => - openDeleteConfirm( - () => onDelCollection([collection._id]), - undefined, - collection.type === DatasetCollectionTypeEnum.folder - ? t('common:dataset.collections.Confirm to delete the folder') - : t('common:dataset.Confirm to delete the file') - )() + openDeleteConfirm({ + onConfirm: () => onDelCollection([collection._id]), + customContent: + collection.type === DatasetCollectionTypeEnum.folder + ? t( + 'common:dataset.collections.Confirm to delete the folder' + ) + : t('common:dataset.Confirm to delete the file') + })() } ] } @@ -451,16 +455,15 @@ const CollectionCard = () => { diff --git a/projects/app/src/pages/login/fastlogin.tsx b/projects/app/src/pages/login/fastlogin.tsx index 37449fb32..4922c1270 100644 --- a/projects/app/src/pages/login/fastlogin.tsx +++ b/projects/app/src/pages/login/fastlogin.tsx @@ -9,6 +9,8 @@ import Loading from '@fastgpt/web/components/common/MyLoading'; import { serviceSideProps } from '@/web/common/i18n/utils'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { useTranslation } from 'next-i18next'; +import { validateRedirectUrl } from '@/web/common/utils/uri'; + const FastLogin = ({ code, token, @@ -27,7 +29,7 @@ const FastLogin = ({ setUserInfo(res.user); setTimeout(() => { - router.push(decodeURIComponent(callbackUrl)); + router.push(validateRedirectUrl(callbackUrl)); }, 100); }, [setUserInfo, router, callbackUrl] @@ -65,7 +67,8 @@ const FastLogin = ({ useEffect(() => { clearToken(); - router.prefetch(callbackUrl); + const safeCallbackUrl = validateRedirectUrl(callbackUrl); + router.prefetch(safeCallbackUrl); authCode(code, token); }, [authCode, callbackUrl, code, router, token]); diff --git a/projects/app/src/pages/login/index.tsx b/projects/app/src/pages/login/index.tsx index c0c272923..3116957f3 100644 --- a/projects/app/src/pages/login/index.tsx +++ b/projects/app/src/pages/login/index.tsx @@ -10,6 +10,7 @@ import { useToast } from '@fastgpt/web/hooks/useToast'; import { useTranslation } from 'next-i18next'; import { useUserStore } from '@/web/support/user/useUserStore'; import { subRoute } from '@fastgpt/web/common/system/utils'; +import { validateRedirectUrl } from '@/web/common/utils/uri'; const Login = () => { const router = useRouter(); @@ -22,7 +23,8 @@ const Login = () => { async (res: LoginSuccessResponse) => { setUserInfo(res.user); - const decodeLastRoute = decodeURIComponent(lastRoute); + const decodeLastRoute = validateRedirectUrl(lastRoute); + const navigateTo = await (async () => { if (res.user.team.status !== 'active') { if (decodeLastRoute.includes('/account/team?invitelinkid=')) { @@ -40,11 +42,7 @@ const Login = () => { return '/dashboard/agent'; } - return decodeLastRoute && - !decodeLastRoute.includes('/login') && - decodeLastRoute.startsWith('/') - ? lastRoute - : '/dashboard/agent'; + return decodeLastRoute; })(); navigateTo && router.replace(navigateTo); diff --git a/projects/app/src/pages/login/provider.tsx b/projects/app/src/pages/login/provider.tsx index ed4c2e6e7..724f4c793 100644 --- a/projects/app/src/pages/login/provider.tsx +++ b/projects/app/src/pages/login/provider.tsx @@ -22,6 +22,7 @@ import { import { postAcceptInvitationLink } from '@/web/support/user/team/api'; import { retryFn } from '@fastgpt/global/common/system/utils'; import type { LangEnum } from '@fastgpt/global/common/i18n/type'; +import { validateRedirectUrl } from '@/web/common/utils/uri'; let isOauthLogging = false; @@ -34,13 +35,13 @@ const provider = () => { const { toast } = useToast(); const lastRoute = loginStore?.lastRoute - ? decodeURIComponent(loginStore.lastRoute) + ? validateRedirectUrl(loginStore.lastRoute) : '/dashboard/agent'; const errorRedirectPage = lastRoute.startsWith('/chat') ? lastRoute : '/login'; const loginSuccess = useCallback( async (res: LoginSuccessResponse) => { - const decodeLastRoute = decodeURIComponent(lastRoute); + const decodeLastRoute = validateRedirectUrl(lastRoute); setUserInfo(res.user); const navigateTo = await (async () => { @@ -57,16 +58,12 @@ const provider = () => { } } - return decodeLastRoute && - !decodeLastRoute.includes('/login') && - decodeLastRoute.startsWith('/') - ? lastRoute - : '/dashboard/agent'; + return decodeLastRoute; })(); navigateTo && router.replace(navigateTo); }, - [setUserInfo, router, lastRoute] + [setUserInfo, router, lastRoute, t, toast] ); const authProps = useCallback( diff --git a/projects/app/src/service/core/app/utils.ts b/projects/app/src/service/core/app/utils.ts index 1583fdd0b..c9784dedc 100644 --- a/projects/app/src/service/core/app/utils.ts +++ b/projects/app/src/service/core/app/utils.ts @@ -45,7 +45,9 @@ export const getScheduleTriggerApp = async () => { await delay(Math.floor(Math.random() * 60 * 1000)); // Get app latest version - const { nodes, edges, chatConfig } = await retryFn(() => getAppLatestVersion(app._id, app)); + const { versionId, nodes, edges, chatConfig } = await retryFn(() => + getAppLatestVersion(app._id, app) + ); const userQuery: UserChatItemValueItemType[] = [ { type: ChatItemValueTypeEnum.text, @@ -97,6 +99,7 @@ export const getScheduleTriggerApp = async () => { await saveChat({ chatId, appId: app._id, + versionId, teamId: String(app.teamId), tmbId: String(app.tmbId), nodes, diff --git a/projects/app/src/service/support/mcp/utils.ts b/projects/app/src/service/support/mcp/utils.ts index f885284e9..0eca67f80 100644 --- a/projects/app/src/service/support/mcp/utils.ts +++ b/projects/app/src/service/support/mcp/utils.ts @@ -37,7 +37,6 @@ import { saveChat } from '@fastgpt/service/core/chat/saveChat'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; import { removeDatasetCiteText } from '@fastgpt/global/core/ai/llm/utils'; -import { clone } from 'lodash'; export const pluginNodes2InputSchema = ( nodes: { flowNodeType: FlowNodeTypeEnum; inputs: FlowNodeInputItemType[] }[] @@ -175,7 +174,7 @@ export const callMcpServerTool = async ({ key, toolName, inputs }: toolCallProps const isPlugin = app.type === AppTypeEnum.workflowTool; // Get app latest version - const { nodes, edges, chatConfig } = await getAppLatestVersion(app._id, app); + const { versionId, nodes, edges, chatConfig } = await getAppLatestVersion(app._id, app); const userQuestion: UserChatItemType = (() => { if (isPlugin) { @@ -252,6 +251,7 @@ export const callMcpServerTool = async ({ key, toolName, inputs }: toolCallProps await saveChat({ chatId, appId: app._id, + versionId, teamId: app.teamId, tmbId: app.tmbId, nodes, diff --git a/projects/app/src/types/app.d.ts b/projects/app/src/types/app.d.ts index b667b02be..19f02f991 100644 --- a/projects/app/src/types/app.d.ts +++ b/projects/app/src/types/app.d.ts @@ -33,25 +33,3 @@ export type AppItemType = { modules: StoreNodeItemType[]; edges: StoreEdgeItemType[]; }; - -export type AppLogsListItemType = { - _id: string; - id: string; - source: string; - createTime: Date; - updateTime: Date; - title: string; - customTitle: string; - messageCount: number; - userGoodFeedbackCount: number; - userBadFeedbackCount: number; - customFeedbacksCount: number; - markCount: number; - averageResponseTime: number; - errorCount: number; - totalPoints: number; - outLinkUid?: string; - tmbId: string; - sourceMember: SourceMember; - region?: string; -}; diff --git a/projects/app/src/web/common/api/client.ts b/projects/app/src/web/common/api/client.ts index 6e2c3bae2..5578237d3 100644 --- a/projects/app/src/web/common/api/client.ts +++ b/projects/app/src/web/common/api/client.ts @@ -6,6 +6,7 @@ import { i18nT } from '@fastgpt/web/i18n/utils'; import { TeamErrEnum } from '@fastgpt/global/common/error/code/team'; import { useSystemStore } from '../system/useSystemStore'; import { clearToken } from '@/web/support/user/auth'; +import { safeEncodeURIComponent } from '@/web/common/utils/uri'; const responseError = (err: any) => { console.log('error->', '请求错误', err); @@ -32,7 +33,9 @@ const responseError = (err: any) => { if (!isOutlinkPage) { clearToken(); window.location.replace( - getWebReqUrl(`/login?lastRoute=${encodeURIComponent(location.pathname + location.search)}`) + getWebReqUrl( + `/login?lastRoute=${safeEncodeURIComponent(location.pathname + location.search)}` + ) ); } diff --git a/projects/app/src/web/common/api/request.ts b/projects/app/src/web/common/api/request.ts index 2fae3fb3a..53282e268 100644 --- a/projects/app/src/web/common/api/request.ts +++ b/projects/app/src/web/common/api/request.ts @@ -12,6 +12,7 @@ import { getWebReqUrl, subRoute } from '@fastgpt/web/common/system/utils'; import { i18nT } from '@fastgpt/web/i18n/utils'; import { getNanoid } from '@fastgpt/global/common/string/tools'; import dayjs from 'dayjs'; +import { safeEncodeURIComponent } from '@/web/common/utils/uri'; interface ConfigType { headers?: { [key: string]: string }; @@ -134,7 +135,9 @@ function responseError(err: any) { if (!isOutlinkPage && pathname !== `${subRoute}/chat`) { clearToken(); window.location.replace( - getWebReqUrl(`/login?lastRoute=${encodeURIComponent(location.pathname + location.search)}`) + getWebReqUrl( + `/login?lastRoute=${safeEncodeURIComponent(location.pathname + location.search)}` + ) ); } diff --git a/projects/app/src/web/common/utils/eventbus.ts b/projects/app/src/web/common/utils/eventbus.ts index 581696191..beb1c7b5e 100644 --- a/projects/app/src/web/common/utils/eventbus.ts +++ b/projects/app/src/web/common/utils/eventbus.ts @@ -1,7 +1,8 @@ export enum EventNameEnum { sendQuestion = 'sendQuestion', editQuestion = 'editQuestion', - openQuoteReader = 'openQuoteReader' + openQuoteReader = 'openQuoteReader', + refreshFeedback = 'refreshFeedback' } export const eventBus = { diff --git a/projects/app/src/web/common/utils/uri.ts b/projects/app/src/web/common/utils/uri.ts new file mode 100644 index 000000000..0120fcaf7 --- /dev/null +++ b/projects/app/src/web/common/utils/uri.ts @@ -0,0 +1,73 @@ +/** + * 安全的 URI 解码函数,包含异常处理 + * @param encodedString 需要解码的字符串 + * @param fallback 解码失败时的默认值,默认为 '/dashboard/agent' + * @returns 解码后的字符串或默认值 + */ +export const safeDecodeURIComponent = ( + encodedString: string, + fallback: string = '/dashboard/agent' +): string => { + if (!encodedString) return fallback; + + try { + return decodeURIComponent(encodedString); + } catch (error) { + console.warn('Invalid URI encoding in string:', encodedString, error); + return fallback; + } +}; + +/** + * 安全的 URI 编码函数 + * @param string 需要编码的字符串 + * @returns 编码后的字符串 + */ +export const safeEncodeURIComponent = (str: string): string => { + try { + return encodeURIComponent(str); + } catch (error) { + console.warn('Error encoding string:', str, error); + return ''; + } +}; + +/** + * Validate if a URL is safe for internal redirect + * Prevents open redirect vulnerabilities by ensuring the URL is a relative path + * @param url The URL to validate + * @param fallback The fallback URL to use if validation fails, defaults to '/dashboard/agent' + * @returns A safe URL for redirect + */ +export const validateRedirectUrl = (url: string, fallback: string = '/dashboard/agent'): string => { + if (!url) return fallback; + + const decodedUrl = safeDecodeURIComponent(url, fallback); + + // Check if URL is a relative path starting with / + if (!decodedUrl.startsWith('/')) { + console.warn('Invalid redirect URL (not starting with /): ', url); + return fallback; + } + + // Prevent redirect to login pages + if (decodedUrl.includes('/login')) { + console.warn('Redirect to login page is not allowed: ', url); + return fallback; + } + + // Check for common protocol patterns that indicate absolute URLs + // This prevents URLs like //evil.com or /\evil.com + if (decodedUrl.match(/^\/[\/\\]/)) { + console.warn('Invalid redirect URL (protocol-relative or double slashes): ', url); + return fallback; + } + + // Check for javascript: or data: protocols + if (decodedUrl.toLowerCase().match(/^[a-z]+:/)) { + console.warn('Invalid redirect URL (contains protocol): ', url); + return fallback; + } + + return decodedUrl; +}; diff --git a/projects/app/src/web/core/app/api/log.ts b/projects/app/src/web/core/app/api/log.ts index 20677ae26..a5ab228a6 100644 --- a/projects/app/src/web/core/app/api/log.ts +++ b/projects/app/src/web/core/app/api/log.ts @@ -1,24 +1,24 @@ -import type { getLogKeysQuery, getLogKeysResponse } from '@/pages/api/core/app/logs/getLogKeys'; -import type { updateLogKeysBody } from '@/pages/api/core/app/logs/updateLogKeys'; import { GET, POST } from '@/web/common/api/request'; -import type { AppLogsListItemType } from '@/types/app'; -import type { PaginationResponse } from '@fastgpt/web/common/fetch/type'; -import type { GetAppChatLogsParams } from '@/global/core/api/appReq'; import type { + getLogKeysQuery, + getLogKeysResponseType, + updateLogKeysBody, + getAppChatLogsBody, getChartDataBody, getChartDataResponse, getTotalDataQuery, - getTotalDataResponse -} from '@fastgpt/global/core/app/logs/api'; + getTotalDataResponse, + getAppChatLogsResponseType +} from '@fastgpt/global/openapi/core/app/log/api'; export const updateLogKeys = (data: updateLogKeysBody) => POST('/core/app/logs/updateLogKeys', data); export const getLogKeys = (data: getLogKeysQuery) => - GET('/core/app/logs/getLogKeys', data); + GET('/core/app/logs/getLogKeys', data); -export const getAppChatLogs = (data: GetAppChatLogsParams) => - POST>(`/core/app/getChatLogs`, data, { maxQuantity: 1 }); +export const getAppChatLogs = (data: getAppChatLogsBody) => + POST(`/core/app/logs/list`, data, { maxQuantity: 1 }); export const getAppTotalData = (data: getTotalDataQuery) => GET('/proApi/core/app/logs/getTotalData', data); diff --git a/projects/app/src/web/core/chat/api.ts b/projects/app/src/web/core/chat/api.ts index a008c22bf..4e0e404b3 100644 --- a/projects/app/src/web/core/chat/api.ts +++ b/projects/app/src/web/core/chat/api.ts @@ -2,7 +2,6 @@ import { GET, POST, DELETE, PUT } from '@/web/common/api/request'; import type { ChatHistoryItemType, ChatHistoryItemResType } from '@fastgpt/global/core/chat/type.d'; import type { getResDataQuery } from '@/pages/api/core/chat/getResData'; import type { - CloseCustomFeedbackParams, InitChatProps, InitChatResponse, InitOutLinkChatProps, @@ -11,20 +10,18 @@ import type { } from '@/global/core/chat/api.d'; import type { - AdminUpdateFeedbackParams, ClearHistoriesProps, DelHistoryProps, DeleteChatItemProps, UpdateHistoryProps } from '@/global/core/chat/api.d'; -import type { UpdateChatFeedbackProps } from '@fastgpt/global/core/chat/api'; 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 { - getPaginationRecordsBody, - getPaginationRecordsResponse -} from '@/pages/api/core/chat/getPaginationRecords'; + getChatRecordsBody, + getChatRecordsResponse +} from '@/pages/api/core/chat/getRecords_v2'; import type { GetQuoteProps, GetQuotesRes } from '@/pages/api/core/chat/quote/getQuote'; import type { GetCollectionQuoteProps, @@ -58,8 +55,8 @@ export const getChatHistories = (data: PaginationProps) => export const getChatResData = (data: getResDataQuery) => GET(`/core/chat/getResData`, data); -export const getChatRecords = (data: getPaginationRecordsBody) => - POST('core/chat/getPaginationRecords', data); +export const getChatRecords = (data: getChatRecordsBody) => + POST('/core/chat/getRecords_v2', data); /** * delete one history @@ -82,16 +79,6 @@ export const delChatRecordById = (data: DeleteChatItemProps) => */ export const putChatHistory = (data: UpdateHistoryProps) => PUT('/core/chat/updateHistory', data); -/* -------------- feedback ------------ */ -export const updateChatUserFeedback = (data: UpdateChatFeedbackProps) => - POST('/core/chat/feedback/updateUserFeedback', data); - -export const updateChatAdminFeedback = (data: AdminUpdateFeedbackParams) => - POST('/core/chat/feedback/adminUpdate', data); - -export const closeCustomFeedback = (data: CloseCustomFeedbackParams) => - POST('/core/chat/feedback/closeCustom', data).catch(); - /* team chat */ /** * Get the app that can be used with this token diff --git a/projects/app/src/web/core/chat/context/chatRecordContext.tsx b/projects/app/src/web/core/chat/context/chatRecordContext.tsx index 99e75812f..c635abb84 100644 --- a/projects/app/src/web/core/chat/context/chatRecordContext.tsx +++ b/projects/app/src/web/core/chat/context/chatRecordContext.tsx @@ -1,15 +1,13 @@ -import { type getPaginationRecordsBody } from '@/pages/api/core/chat/getPaginationRecords'; import { type ChatSiteItemType } from '@fastgpt/global/core/chat/type'; -import { type PaginationResponse } from '@fastgpt/web/common/fetch/type'; -import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination'; -import React, { type ReactNode, useMemo, useState } from 'react'; -import { createContext, useContextSelector } from 'use-context-selector'; -import { ChatItemContext } from './chatItemContext'; +import type { LinkedPaginationProps, LinkedListResponse } from '@fastgpt/web/common/fetch/type'; +import { useLinkedScroll } from '@fastgpt/web/hooks/useLinkedScroll'; +import React, { type ReactNode, useState } from 'react'; +import { createContext } from 'use-context-selector'; import { getChatRecords } from '../api'; import { ChatStatusEnum } from '@fastgpt/global/core/chat/constants'; -import { getNanoid } from '@fastgpt/global/common/string/tools'; import { type BoxProps } from '@chakra-ui/react'; import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; +import type { GetChatRecordsProps } from '@/global/core/chat/api'; type ChatRecordContextType = { isLoadingRecords: boolean; @@ -24,6 +22,7 @@ type ChatRecordContextType = { children: React.ReactNode; ScrollContainerRef?: React.RefObject; } & BoxProps) => React.JSX.Element; + itemRefs: React.MutableRefObject>; }; export const ChatRecordContext = createContext({ @@ -34,7 +33,6 @@ export const ChatRecordContext = createContext({ }, isChatRecordsLoaded: false, - totalRecordsCount: 0, ScrollData: function ({ children, ...props @@ -43,7 +41,9 @@ export const ChatRecordContext = createContext({ ScrollContainerRef?: React.RefObject; } & BoxProps): React.JSX.Element { throw new Error('Function not implemented.'); - } + }, + totalRecordsCount: 0, + itemRefs: { current: new Map() } }); /* @@ -51,54 +51,50 @@ export const ChatRecordContext = createContext({ */ const ChatRecordContextProvider = ({ children, - params + params, + feedbackRecordId }: { children: ReactNode; - params: Omit; + params: GetChatRecordsProps; + feedbackRecordId?: string; }) => { - const ChatBoxRef = useContextSelector(ChatItemContext, (v) => v.ChatBoxRef); const [isChatRecordsLoaded, setIsChatRecordsLoaded] = useState(false); + const [totalRecordsCount, setTotalRecordsCount] = useState(0); + const currentData = useMemoEnhance(() => ({ id: feedbackRecordId || '' }), [feedbackRecordId]); const { - data: chatRecords, + dataList: chatRecords, + setDataList: setChatRecords, ScrollData, - setData: setChatRecords, - total: totalRecordsCount, - isLoading - } = useScrollPagination( - async (data: getPaginationRecordsBody): Promise> => { + isLoading, + itemRefs, + loadInitData + } = useLinkedScroll( + async ( + data: LinkedPaginationProps + ): Promise> => { setIsChatRecordsLoaded(false); - const res = await getChatRecords(data); - - // First load scroll to bottom - if (Number(data.offset) === 0) { - function scrollToBottom() { - requestAnimationFrame( - ChatBoxRef?.current ? () => ChatBoxRef?.current?.scrollToBottom?.() : scrollToBottom - ); - } - scrollToBottom(); - } + const res = await getChatRecords(data).finally(() => { + setIsChatRecordsLoaded(true); + }); + setTotalRecordsCount(res.total); return { - ...res, list: res.list.map((item) => ({ ...item, - dataId: item.dataId || getNanoid(), + dataId: item.dataId!, status: ChatStatusEnum.finish - })) + })), + hasMorePrev: res.hasMorePrev, + hasMoreNext: res.hasMoreNext }; }, { pageSize: 10, - refreshDeps: [params], params, - scrollLoadType: 'top', - showErrorToast: false, - onFinally() { - setIsChatRecordsLoaded(true); - } + currentData, + defaultScroll: 'bottom' } ); @@ -107,9 +103,10 @@ const ChatRecordContextProvider = ({ isLoadingRecords: isLoading, chatRecords, setChatRecords, - totalRecordsCount, ScrollData, - isChatRecordsLoaded + isChatRecordsLoaded, + totalRecordsCount, + itemRefs }; }, [isLoading, chatRecords, setChatRecords, totalRecordsCount, ScrollData, isChatRecordsLoaded]); return {children}; diff --git a/projects/app/src/web/core/chat/context/chatSettingContext.tsx b/projects/app/src/web/core/chat/context/chatSettingContext.tsx index fe47915f5..4f2ea4365 100644 --- a/projects/app/src/web/core/chat/context/chatSettingContext.tsx +++ b/projects/app/src/web/core/chat/context/chatSettingContext.tsx @@ -12,6 +12,7 @@ import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; import { useRouter } from 'next/router'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { createContext } from 'use-context-selector'; +import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; export type ChatSettingContextValue = { pane: ChatSidebarPaneEnum; @@ -121,17 +122,21 @@ export const ChatSettingContextProvider = ({ children }: { children: React.React [chatSettings?.squareLogoUrl, chatSettings?.wideLogoUrl] ); - const value: ChatSettingContextValue = useMemo( + const onTriggerCollapse = useCallback(() => { + setCollapse(collapse === 0 ? 1 : 0); + }, [collapse]); + + const value: ChatSettingContextValue = useMemoEnhance( () => ({ pane, handlePaneChange, collapse, - onTriggerCollapse: () => setCollapse(collapse === 0 ? 1 : 0), + onTriggerCollapse, chatSettings, refreshChatSetting, logos }), - [pane, handlePaneChange, collapse, chatSettings, refreshChatSetting, logos] + [pane, handlePaneChange, collapse, chatSettings, refreshChatSetting, onTriggerCollapse, logos] ); return {children}; diff --git a/projects/app/src/web/core/chat/feedback/api.ts b/projects/app/src/web/core/chat/feedback/api.ts new file mode 100644 index 000000000..e3f7273eb --- /dev/null +++ b/projects/app/src/web/core/chat/feedback/api.ts @@ -0,0 +1,29 @@ +import { POST } from '@/web/common/api/request'; +import type { + GetFeedbackRecordIdsBodyType, + GetFeedbackRecordIdsResponseType, + UpdateUserFeedbackBodyType, + UpdateFeedbackReadStatusBodyType, + AdminUpdateFeedbackBodyType, + CloseCustomFeedbackBodyType +} from '@fastgpt/global/openapi/core/chat/feedback/api'; + +/* Get feedback record IDs */ +export const getFeedbackRecordIds = (data: GetFeedbackRecordIdsBodyType) => + POST('/core/chat/feedback/getFeedbackRecordIds', data); + +/* Update user feedback */ +export const updateChatUserFeedback = (data: UpdateUserFeedbackBodyType) => + POST('/core/chat/feedback/updateUserFeedback', data); + +/* Update admin feedback */ +export const updateChatAdminFeedback = (data: AdminUpdateFeedbackBodyType) => + POST('/core/chat/feedback/adminUpdate', data); + +/* Close custom feedback */ +export const closeCustomFeedback = (data: CloseCustomFeedbackBodyType) => + POST('/core/chat/feedback/closeCustom', data).catch(); + +/* Update feedback read status */ +export const updateFeedbackReadStatus = (data: UpdateFeedbackReadStatusBodyType) => + POST('/core/chat/feedback/updateFeedbackReadStatus', data); diff --git a/projects/app/src/web/support/user/api.ts b/projects/app/src/web/support/user/api.ts index 6cf3ee85a..212ff3398 100644 --- a/projects/app/src/web/support/user/api.ts +++ b/projects/app/src/web/support/user/api.ts @@ -3,7 +3,7 @@ import { hashStr } from '@fastgpt/global/common/string/tools'; import type { LoginSuccessResponse } from '@/global/support/api/userRes.d'; import type { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; import type { UserUpdateParams } from '@/types/user'; -import type { UserType } from '@fastgpt/global/support/user/type.d'; +import type { UserType } from '@fastgpt/global/support/user/type'; import type { FastLoginProps, OauthLoginProps, diff --git a/projects/app/src/web/support/user/useUserStore.ts b/projects/app/src/web/support/user/useUserStore.ts index 46f225635..c49dac0ca 100644 --- a/projects/app/src/web/support/user/useUserStore.ts +++ b/projects/app/src/web/support/user/useUserStore.ts @@ -3,7 +3,7 @@ import { create, devtools, persist, immer } from '@fastgpt/web/common/zustand'; import type { UserUpdateParams } from '@/types/user'; import { getTokenLogin, putUserInfo } from '@/web/support/user/api'; import type { OrgType } from '@fastgpt/global/support/user/team/org/type'; -import type { UserType } from '@fastgpt/global/support/user/type.d'; +import type { UserType } from '@fastgpt/global/support/user/type'; import type { ClientTeamPlanStatusType } from '@fastgpt/global/support/wallet/sub/type'; import { getTeamPlanStatus } from './team/api'; import { setLangToStorage, getLangMapping } from '@fastgpt/web/i18n/utils'; diff --git a/projects/app/test/api/core/chat/feedback/admin.test.ts b/projects/app/test/api/core/chat/feedback/admin.test.ts new file mode 100644 index 000000000..0af106552 --- /dev/null +++ b/projects/app/test/api/core/chat/feedback/admin.test.ts @@ -0,0 +1,127 @@ +import handler from '@/pages/api/core/chat/feedback/adminUpdate'; +import { + type AdminUpdateFeedbackBodyType, + type AdminUpdateFeedbackResponseType +} from '@fastgpt/global/openapi/core/chat/feedback/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 { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema'; +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'; + +describe('adminUpdate api test', () => { + let testUser: Awaited>; + let appId: string; + let chatId: string; + let dataId: string; + + beforeEach(async () => { + testUser = await getUser('test-user-admin-feedback'); + + // 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); + chatId = getNanoid(); + dataId = getNanoid(); + + // Create chat + await MongoChat.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + appId, + chatId, + source: ChatSourceEnum.test + }); + + // Create chat item + await MongoChatItem.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + userId: testUser.userId, + appId, + chatId, + dataId, + obj: ChatRoleEnum.AI, + value: [ + { + type: 'text', + text: { + content: 'Test response' + } + } + ] + }); + }); + + it('should update admin feedback successfully', async () => { + const datasetId = getNanoid(); + const feedbackDataId = getNanoid(); + const q = 'What is AI?'; + const a = 'AI stands for Artificial Intelligence'; + + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + dataId, + datasetId, + feedbackDataId, + q, + a + } + } + ); + + expect(res.code).toBe(200); + expect(res.error).toBeUndefined(); + + // Verify that adminFeedback was updated + const updatedChatItem = await MongoChatItem.findOne({ + appId, + chatId, + dataId + }); + + expect(updatedChatItem?.adminFeedback).toBeDefined(); + expect(updatedChatItem?.adminFeedback?.datasetId).toBe(datasetId); + expect(updatedChatItem?.adminFeedback?.dataId).toBe(feedbackDataId); + expect(updatedChatItem?.adminFeedback?.q).toBe(q); + expect(updatedChatItem?.adminFeedback?.a).toBe(a); + }); + + it('should fail when user does not have permission', async () => { + const unauthorizedUser = await getUser('unauthorized-user-admin'); + + const res = await Call( + handler, + { + auth: unauthorizedUser, + body: { + appId, + chatId, + dataId, + datasetId: getNanoid(), + feedbackDataId: getNanoid(), + q: 'test', + a: 'test' + } + } + ); + + expect(res.code).toBe(500); + expect(res.error).toBeDefined(); + }); +}); diff --git a/projects/app/test/api/core/chat/feedback/closeCustom.test.ts b/projects/app/test/api/core/chat/feedback/closeCustom.test.ts new file mode 100644 index 000000000..b866e814c --- /dev/null +++ b/projects/app/test/api/core/chat/feedback/closeCustom.test.ts @@ -0,0 +1,167 @@ +import handler from '@/pages/api/core/chat/feedback/closeCustom'; +import { + type CloseCustomFeedbackBodyType, + type CloseCustomFeedbackResponseType +} from '@fastgpt/global/openapi/core/chat/feedback/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 { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema'; +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'; + +describe('closeCustom api test', () => { + let testUser: Awaited>; + let appId: string; + let chatId: string; + let dataId: string; + + beforeEach(async () => { + testUser = await getUser('test-user-close-custom'); + + // 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); + chatId = getNanoid(); + dataId = getNanoid(); + + // Create chat + await MongoChat.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + appId, + chatId, + source: ChatSourceEnum.test + }); + + // Create chat item with custom feedbacks + await MongoChatItem.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + userId: testUser.userId, + appId, + chatId, + dataId, + obj: ChatRoleEnum.AI, + value: [ + { + type: 'text', + text: { + content: 'Test response' + } + } + ], + customFeedbacks: ['feedback1', 'feedback2', 'feedback3'] + }); + }); + + it('should close custom feedback successfully', async () => { + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + dataId, + index: 1 + } + } + ); + + expect(res.code).toBe(200); + expect(res.error).toBeUndefined(); + + // Verify that custom feedback at index 1 was removed + const updatedChatItem = await MongoChatItem.findOne({ + appId, + chatId, + dataId + }); + + expect(updatedChatItem?.customFeedbacks).toHaveLength(2); + expect(updatedChatItem?.customFeedbacks?.[0]).toBe('feedback1'); + expect(updatedChatItem?.customFeedbacks?.[1]).toBe('feedback3'); + }); + + it('should fail when user does not have permission', async () => { + const unauthorizedUser = await getUser('unauthorized-user-close'); + + const res = await Call( + handler, + { + auth: unauthorizedUser, + body: { + appId, + chatId, + dataId, + index: 0 + } + } + ); + + expect(res.code).toBe(500); + expect(res.error).toBeDefined(); + }); + + it('should handle closing first feedback', async () => { + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + dataId, + index: 0 + } + } + ); + + expect(res.code).toBe(200); + + const updatedChatItem = await MongoChatItem.findOne({ + appId, + chatId, + dataId + }); + + expect(updatedChatItem?.customFeedbacks).toHaveLength(2); + expect(updatedChatItem?.customFeedbacks?.[0]).toBe('feedback2'); + }); + + it('should handle closing last feedback', async () => { + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + dataId, + index: 2 + } + } + ); + + expect(res.code).toBe(200); + + const updatedChatItem = await MongoChatItem.findOne({ + appId, + chatId, + dataId + }); + + expect(updatedChatItem?.customFeedbacks).toHaveLength(2); + expect(updatedChatItem?.customFeedbacks?.[1]).toBe('feedback2'); + }); +}); diff --git a/projects/app/test/api/core/chat/feedback/getFeedbackRecordIds.test.ts b/projects/app/test/api/core/chat/feedback/getFeedbackRecordIds.test.ts new file mode 100644 index 000000000..1887dd153 --- /dev/null +++ b/projects/app/test/api/core/chat/feedback/getFeedbackRecordIds.test.ts @@ -0,0 +1,291 @@ +import handler from '@/pages/api/core/chat/feedback/getFeedbackRecordIds'; +import { + type GetFeedbackRecordIdsBodyType, + type GetFeedbackRecordIdsResponseType +} from '@fastgpt/global/openapi/core/chat/feedback/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 { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema'; +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'; + +describe('getFeedbackRecordIds api test', () => { + let testUser: Awaited>; + let appId: string; + let chatId: string; + + beforeEach(async () => { + testUser = await getUser('test-user-get-feedback-ids'); + + // 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); + chatId = getNanoid(); + + // Create chat + await MongoChat.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + appId, + chatId, + source: ChatSourceEnum.test + }); + + // Create chat items with different feedback types + // Item 1: Good feedback, read + await MongoChatItem.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + userId: testUser.userId, + appId, + chatId, + dataId: 'data-1', + obj: ChatRoleEnum.AI, + value: [{ type: 'text', text: { content: 'Response 1' } }], + userGoodFeedback: 'Great answer!', + isFeedbackRead: true + }); + + // Item 2: Good feedback, unread + await MongoChatItem.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + userId: testUser.userId, + appId, + chatId, + dataId: 'data-2', + obj: ChatRoleEnum.AI, + value: [{ type: 'text', text: { content: 'Response 2' } }], + userGoodFeedback: 'Excellent!', + isFeedbackRead: false + }); + + // Item 3: Bad feedback, read + await MongoChatItem.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + userId: testUser.userId, + appId, + chatId, + dataId: 'data-3', + obj: ChatRoleEnum.AI, + value: [{ type: 'text', text: { content: 'Response 3' } }], + userBadFeedback: 'Not helpful', + isFeedbackRead: true + }); + + // Item 4: Bad feedback, unread + await MongoChatItem.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + userId: testUser.userId, + appId, + chatId, + dataId: 'data-4', + obj: ChatRoleEnum.AI, + value: [{ type: 'text', text: { content: 'Response 4' } }], + userBadFeedback: 'Incorrect answer', + isFeedbackRead: false + }); + + // Item 5: No feedback + await MongoChatItem.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + userId: testUser.userId, + appId, + chatId, + dataId: 'data-5', + obj: ChatRoleEnum.AI, + value: [{ type: 'text', text: { content: 'Response 5' } }] + }); + + // Item 6: Human message (should not be included) + await MongoChatItem.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + userId: testUser.userId, + appId, + chatId, + dataId: 'data-6', + obj: ChatRoleEnum.Human, + value: [{ type: 'text', text: { content: 'Question' } }], + userGoodFeedback: 'Good question' + }); + }); + + it('should return all good feedback records', async () => { + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + feedbackType: 'good', + unreadOnly: false + } + } + ); + + expect(res.code).toBe(200); + expect(res.data?.total).toBe(2); + expect(res.data?.dataIds).toHaveLength(2); + expect(res.data?.dataIds).toContain('data-1'); + expect(res.data?.dataIds).toContain('data-2'); + }); + + it('should return only unread good feedback records', async () => { + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + feedbackType: 'good', + unreadOnly: true + } + } + ); + + expect(res.code).toBe(200); + expect(res.data?.total).toBe(1); + expect(res.data?.dataIds).toHaveLength(1); + expect(res.data?.dataIds).toContain('data-2'); + }); + + it('should return all bad feedback records', async () => { + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + feedbackType: 'bad', + unreadOnly: false + } + } + ); + + expect(res.code).toBe(200); + expect(res.data?.total).toBe(2); + expect(res.data?.dataIds).toHaveLength(2); + expect(res.data?.dataIds).toContain('data-3'); + expect(res.data?.dataIds).toContain('data-4'); + }); + + it('should return only unread bad feedback records', async () => { + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + feedbackType: 'bad', + unreadOnly: true + } + } + ); + + expect(res.code).toBe(200); + expect(res.data?.total).toBe(1); + expect(res.data?.dataIds).toHaveLength(1); + expect(res.data?.dataIds).toContain('data-4'); + }); + + it('should return all feedback records with has_feedback type', async () => { + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + feedbackType: 'has_feedback', + unreadOnly: false + } + } + ); + + expect(res.code).toBe(200); + expect(res.data?.total).toBe(4); + expect(res.data?.dataIds).toHaveLength(4); + expect(res.data?.dataIds).toContain('data-1'); + expect(res.data?.dataIds).toContain('data-2'); + expect(res.data?.dataIds).toContain('data-3'); + expect(res.data?.dataIds).toContain('data-4'); + }); + + it('should return only unread feedback records with has_feedback type', async () => { + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + feedbackType: 'has_feedback', + unreadOnly: true + } + } + ); + + expect(res.code).toBe(200); + expect(res.data?.total).toBe(2); + expect(res.data?.dataIds).toHaveLength(2); + expect(res.data?.dataIds).toContain('data-2'); + expect(res.data?.dataIds).toContain('data-4'); + }); + + it('should return empty result when no appId or chatId', async () => { + const res = await Call( + handler, + { + auth: testUser, + body: { + appId: '', + chatId: '', + feedbackType: 'good', + unreadOnly: false + } + } + ); + + expect(res.code).toBe(200); + expect(res.data?.total).toBe(0); + expect(res.data?.dataIds).toHaveLength(0); + }); + + it('should fail when user does not have permission', async () => { + const unauthorizedUser = await getUser('unauthorized-user-get-ids'); + + const res = await Call( + handler, + { + auth: unauthorizedUser, + body: { + appId, + chatId, + feedbackType: 'good', + unreadOnly: false + } + } + ); + + expect(res.code).toBe(500); + expect(res.error).toBeDefined(); + }); +}); diff --git a/projects/app/test/api/core/chat/feedback/updateFeedbackReadStatus.test.ts b/projects/app/test/api/core/chat/feedback/updateFeedbackReadStatus.test.ts new file mode 100644 index 000000000..3b629e873 --- /dev/null +++ b/projects/app/test/api/core/chat/feedback/updateFeedbackReadStatus.test.ts @@ -0,0 +1,217 @@ +import handler from '@/pages/api/core/chat/feedback/updateFeedbackReadStatus'; +import { + type UpdateFeedbackReadStatusBodyType, + type UpdateFeedbackReadStatusResponseType +} from '@fastgpt/global/openapi/core/chat/feedback/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 { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema'; +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'; + +describe('updateFeedbackReadStatus api test', () => { + let testUser: Awaited>; + let appId: string; + let chatId: string; + let dataId: string; + + beforeEach(async () => { + testUser = await getUser('test-user-update-read-status'); + + // 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); + chatId = getNanoid(); + dataId = getNanoid(); + + // Create chat + await MongoChat.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + appId, + chatId, + source: ChatSourceEnum.test + }); + + // Create chat item with feedback + await MongoChatItem.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + userId: testUser.userId, + appId, + chatId, + dataId, + obj: ChatRoleEnum.AI, + value: [ + { + type: 'text', + text: { + content: 'Test response' + } + } + ], + userGoodFeedback: 'Great answer!', + isFeedbackRead: false + }); + }); + + it('should mark feedback as read', async () => { + const res = await Call< + UpdateFeedbackReadStatusBodyType, + {}, + UpdateFeedbackReadStatusResponseType + >(handler, { + auth: testUser, + body: { + appId, + chatId, + dataId, + isRead: true + } + }); + + expect(res.code).toBe(200); + expect(res.error).toBeUndefined(); + expect(res.data?.success).toBe(true); + + // Verify that feedback was marked as read + const updatedChatItem = await MongoChatItem.findOne({ + appId, + chatId, + dataId + }); + + expect(updatedChatItem?.isFeedbackRead).toBe(true); + }); + + it('should mark feedback as unread', async () => { + // First mark as read + await MongoChatItem.updateOne({ appId, chatId, dataId }, { isFeedbackRead: true }); + + const res = await Call< + UpdateFeedbackReadStatusBodyType, + {}, + UpdateFeedbackReadStatusResponseType + >(handler, { + auth: testUser, + body: { + appId, + chatId, + dataId, + isRead: false + } + }); + + expect(res.code).toBe(200); + expect(res.error).toBeUndefined(); + expect(res.data?.success).toBe(true); + + // Verify that feedback was marked as unread + const updatedChatItem = await MongoChatItem.findOne({ + appId, + chatId, + dataId + }); + + expect(updatedChatItem?.isFeedbackRead).toBe(false); + }); + + it('should fail when user does not have permission', async () => { + const unauthorizedUser = await getUser('unauthorized-user-read-status'); + + const res = await Call< + UpdateFeedbackReadStatusBodyType, + {}, + UpdateFeedbackReadStatusResponseType + >(handler, { + auth: unauthorizedUser, + body: { + appId, + chatId, + dataId, + isRead: true + } + }); + + expect(res.code).toBe(500); + expect(res.error).toBeDefined(); + }); + + it('should only update AI role chat items', async () => { + const humanDataId = getNanoid(); + + // Create a human message + await MongoChatItem.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + userId: testUser.userId, + appId, + chatId, + dataId: humanDataId, + obj: ChatRoleEnum.Human, + value: [ + { + type: 'text', + text: { + content: 'Test question' + } + } + ], + isFeedbackRead: false + }); + + const res = await Call< + UpdateFeedbackReadStatusBodyType, + {}, + UpdateFeedbackReadStatusResponseType + >(handler, { + auth: testUser, + body: { + appId, + chatId, + dataId: humanDataId, + isRead: true + } + }); + + expect(res.code).toBe(200); + + // Verify that human message was not updated (obj filter should prevent it) + const humanChatItem = await MongoChatItem.findOne({ + appId, + chatId, + dataId: humanDataId + }); + + expect(humanChatItem?.isFeedbackRead).toBe(false); + }); + + it('should handle non-existent dataId gracefully', async () => { + const res = await Call< + UpdateFeedbackReadStatusBodyType, + {}, + UpdateFeedbackReadStatusResponseType + >(handler, { + auth: testUser, + body: { + appId, + chatId, + dataId: 'non-existent-id', + isRead: true + } + }); + + expect(res.code).toBe(200); + expect(res.data?.success).toBe(true); + }); +}); diff --git a/projects/app/test/api/core/chat/feedback/updateUserFeedback.test.ts b/projects/app/test/api/core/chat/feedback/updateUserFeedback.test.ts new file mode 100644 index 000000000..69f791ce8 --- /dev/null +++ b/projects/app/test/api/core/chat/feedback/updateUserFeedback.test.ts @@ -0,0 +1,404 @@ +import handler from '@/pages/api/core/chat/feedback/updateUserFeedback'; +import { + type UpdateUserFeedbackBodyType, + type UpdateUserFeedbackResponseType +} from '@fastgpt/global/openapi/core/chat/feedback/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 { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema'; +import { MongoChat } from '@fastgpt/service/core/chat/chatSchema'; +import { MongoAppChatLog } from '@fastgpt/service/core/app/logs/chatLogsSchema'; +import { getUser } from '@test/datas/users'; +import { Call } from '@test/utils/request'; +import { describe, expect, it, beforeEach } from 'vitest'; + +describe('updateUserFeedback api test', () => { + let testUser: Awaited>; + let appId: string; + let chatId: string; + let dataId: string; + + beforeEach(async () => { + testUser = await getUser('test-user-update-feedback'); + + // 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); + chatId = getNanoid(); + dataId = getNanoid(); + + // Create chat + await MongoChat.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + appId, + chatId, + source: ChatSourceEnum.test + }); + + // Create chat log + await MongoAppChatLog.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + appId, + chatId, + userId: String(testUser.userId), + source: ChatSourceEnum.test, + createTime: new Date(), + updateTime: new Date(), + goodFeedbackCount: 0, + badFeedbackCount: 0 + }); + + // Create chat item + await MongoChatItem.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + userId: testUser.userId, + appId, + chatId, + dataId, + obj: ChatRoleEnum.AI, + value: [ + { + type: 'text', + text: { + content: 'Test response' + } + } + ] + }); + }); + + it('should add good feedback', async () => { + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + dataId, + userGoodFeedback: 'Great answer!' + } + } + ); + + expect(res.code).toBe(200); + expect(res.error).toBeUndefined(); + + // Verify that good feedback was added + const updatedChatItem = await MongoChatItem.findOne({ + appId, + chatId, + dataId + }); + + expect(updatedChatItem?.userGoodFeedback).toBe('Great answer!'); + + // Verify chat log was updated + const chatLog = await MongoAppChatLog.findOne({ + teamId: testUser.teamId, + appId, + chatId + }); + + expect(chatLog?.goodFeedbackCount).toBe(1); + expect(chatLog?.badFeedbackCount).toBe(0); + }); + + it('should add bad feedback', async () => { + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + dataId, + userBadFeedback: 'Not helpful' + } + } + ); + + expect(res.code).toBe(200); + expect(res.error).toBeUndefined(); + + // Verify that bad feedback was added + const updatedChatItem = await MongoChatItem.findOne({ + appId, + chatId, + dataId + }); + + expect(updatedChatItem?.userBadFeedback).toBe('Not helpful'); + + // Verify chat log was updated + const chatLog = await MongoAppChatLog.findOne({ + teamId: testUser.teamId, + appId, + chatId + }); + + expect(chatLog?.goodFeedbackCount).toBe(0); + expect(chatLog?.badFeedbackCount).toBe(1); + }); + + it('should remove good feedback', async () => { + // First add good feedback + await MongoChatItem.updateOne({ appId, chatId, dataId }, { userGoodFeedback: 'Great!' }); + await MongoAppChatLog.updateOne( + { teamId: testUser.teamId, appId, chatId }, + { goodFeedbackCount: 1 } + ); + + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + dataId, + userGoodFeedback: undefined + } + } + ); + + expect(res.code).toBe(200); + expect(res.error).toBeUndefined(); + + // Verify that good feedback was removed + const updatedChatItem = await MongoChatItem.findOne({ + appId, + chatId, + dataId + }); + + expect(updatedChatItem?.userGoodFeedback).toBeUndefined(); + + // Verify chat log was updated + const chatLog = await MongoAppChatLog.findOne({ + teamId: testUser.teamId, + appId, + chatId + }); + + expect(chatLog?.goodFeedbackCount).toBe(0); + }); + + it('should remove bad feedback', async () => { + // First add bad feedback + await MongoChatItem.updateOne({ appId, chatId, dataId }, { userBadFeedback: 'Not helpful' }); + await MongoAppChatLog.updateOne( + { teamId: testUser.teamId, appId, chatId }, + { badFeedbackCount: 1 } + ); + + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + dataId, + userBadFeedback: undefined + } + } + ); + + expect(res.code).toBe(200); + expect(res.error).toBeUndefined(); + + // Verify that bad feedback was removed + const updatedChatItem = await MongoChatItem.findOne({ + appId, + chatId, + dataId + }); + + expect(updatedChatItem?.userBadFeedback).toBeUndefined(); + + // Verify chat log was updated + const chatLog = await MongoAppChatLog.findOne({ + teamId: testUser.teamId, + appId, + chatId + }); + + expect(chatLog?.badFeedbackCount).toBe(0); + }); + + it('should update good feedback', async () => { + // First add good feedback + await MongoChatItem.updateOne({ appId, chatId, dataId }, { userGoodFeedback: 'Good' }); + await MongoAppChatLog.updateOne( + { teamId: testUser.teamId, appId, chatId }, + { goodFeedbackCount: 1 } + ); + + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + dataId, + userGoodFeedback: 'Excellent!' + } + } + ); + + expect(res.code).toBe(200); + expect(res.error).toBeUndefined(); + + // Verify that good feedback was updated + const updatedChatItem = await MongoChatItem.findOne({ + appId, + chatId, + dataId + }); + + expect(updatedChatItem?.userGoodFeedback).toBe('Excellent!'); + + // Verify chat log count remains the same + const chatLog = await MongoAppChatLog.findOne({ + teamId: testUser.teamId, + appId, + chatId + }); + + expect(chatLog?.goodFeedbackCount).toBe(1); + }); + + it('should switch from good to bad feedback', async () => { + // First add good feedback + await MongoChatItem.updateOne({ appId, chatId, dataId }, { userGoodFeedback: 'Good' }); + await MongoAppChatLog.updateOne( + { teamId: testUser.teamId, appId, chatId }, + { goodFeedbackCount: 1 } + ); + + // Remove good and add bad feedback + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + dataId, + userGoodFeedback: undefined, + userBadFeedback: 'Actually not good' + } + } + ); + + expect(res.code).toBe(200); + expect(res.error).toBeUndefined(); + + // Verify feedbacks were updated + const updatedChatItem = await MongoChatItem.findOne({ + appId, + chatId, + dataId + }); + + expect(updatedChatItem?.userGoodFeedback).toBeUndefined(); + expect(updatedChatItem?.userBadFeedback).toBe('Actually not good'); + + // Verify chat log was updated + const chatLog = await MongoAppChatLog.findOne({ + teamId: testUser.teamId, + appId, + chatId + }); + + expect(chatLog?.goodFeedbackCount).toBe(0); + expect(chatLog?.badFeedbackCount).toBe(1); + }); + + it('should fail when chatId is empty', async () => { + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId: '', + dataId, + userGoodFeedback: 'Great!' + } + } + ); + + expect(res.code).toBe(500); + expect(res.error).toBeDefined(); + }); + + it('should fail when dataId is empty', async () => { + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + dataId: '', + userGoodFeedback: 'Great!' + } + } + ); + + expect(res.code).toBe(500); + expect(res.error).toBeDefined(); + }); + + it('should fail when chat item does not exist', async () => { + const res = await Call( + handler, + { + auth: testUser, + body: { + appId, + chatId, + dataId: 'non-existent-id', + userGoodFeedback: 'Great!' + } + } + ); + + 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-feedback'); + + const res = await Call( + handler, + { + auth: unauthorizedUser, + body: { + appId, + chatId, + dataId, + userGoodFeedback: 'Great!' + } + } + ); + + expect(res.code).toBe(500); + expect(res.error).toBeDefined(); + }); +}); diff --git a/test/cases/service/core/chat/controller.test.ts b/test/cases/service/core/chat/controller.test.ts new file mode 100644 index 000000000..64055cede --- /dev/null +++ b/test/cases/service/core/chat/controller.test.ts @@ -0,0 +1,1308 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { + getChatItems, + addCustomFeedbacks, + updateChatFeedbackCount +} from '@fastgpt/service/core/chat/controller'; +import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema'; +import { MongoChat } from '@fastgpt/service/core/chat/chatSchema'; +import { ChatRoleEnum, ChatSourceEnum } from '@fastgpt/global/core/chat/constants'; +import { getUser } from '@test/datas/users'; +import { MongoApp } from '@fastgpt/service/core/app/schema'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { getNanoid } from '@fastgpt/global/common/string/tools'; +import type { ChatItemSchema } from '@fastgpt/global/core/chat/type'; + +describe('getChatItems', () => { + let testUser: Awaited>; + let appId: string; + let chatId: string; + + beforeEach(async () => { + testUser = await getUser('test-user'); + + // 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); + chatId = getNanoid(); + }); + + // Helper function to create chat items + const createChatItems = async (count: number): Promise => { + const items: ChatItemSchema[] = []; + for (let i = 0; i < count; i++) { + const item = await MongoChatItem.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + userId: testUser.userId, + appId, + chatId, + dataId: getNanoid(), + obj: i % 2 === 0 ? ChatRoleEnum.Human : ChatRoleEnum.AI, + value: [ + { + type: 'text', + text: { + content: `Message ${i + 1}` + } + } + ] + }); + items.push(item.toObject() as ChatItemSchema); + } + return items; + }; + + describe('Normal Pagination Mode', () => { + it('should return empty array when chatId is not provided', async () => { + const result = await getChatItems({ + appId, + chatId: undefined, + offset: 0, + limit: 10, + field: 'obj value' + }); + + expect(result.histories).toEqual([]); + expect(result.total).toBe(0); + }); + + it('should return empty array when no chat items exist', async () => { + const result = await getChatItems({ + appId, + chatId, + offset: 0, + limit: 10, + field: 'obj value' + }); + + expect(result.histories).toEqual([]); + expect(result.total).toBe(0); + }); + + it('should fetch chat items with pagination correctly', async () => { + await createChatItems(20); + + const result = await getChatItems({ + appId, + chatId, + offset: 0, + limit: 5, + field: 'obj value' + }); + + expect(result.histories).toHaveLength(5); + expect(result.total).toBe(20); + // Should be in chronological order (oldest first) + expect(result.histories[0].value[0].text?.content).toContain('Message 1'); + }); + + it('should handle pagination offset correctly', async () => { + await createChatItems(20); + + const result = await getChatItems({ + appId, + chatId, + offset: 5, + limit: 5, + field: 'obj value' + }); + + expect(result.histories).toHaveLength(5); + expect(result.total).toBe(20); + // The function gets items in reverse order then reverses them back + // So offset 5 should skip the first 5 newest items and get items 6-10 (from newest) + // After reversing, these would be items 11-15 from oldest (Message 11-15) + expect(result.histories[0].value[0].text?.content).toContain('Message 11'); + }); + + it('should return remaining items when limit exceeds available items', async () => { + await createChatItems(5); + + const result = await getChatItems({ + appId, + chatId, + offset: 0, + limit: 10, + field: 'obj value' + }); + + expect(result.histories).toHaveLength(5); + expect(result.total).toBe(5); + }); + + it('should only return specified fields', async () => { + await createChatItems(5); + + const result = await getChatItems({ + appId, + chatId, + offset: 0, + limit: 5, + field: 'obj' + }); + + expect(result.histories).toHaveLength(5); + // Should have dataId (always included) and obj + expect(result.histories[0].dataId).toBeDefined(); + expect(result.histories[0].obj).toBeDefined(); + // Should not have other optional fields + expect(result.histories[0].value).toBeUndefined(); + }); + }); + + describe('Field Selection', () => { + it('should always include dataId field even if not specified', async () => { + await createChatItems(3); + + const result = await getChatItems({ + appId, + chatId, + offset: 0, + limit: 3, + field: 'obj value' + }); + + expect(result.histories).toHaveLength(3); + result.histories.forEach((item) => { + expect(item.dataId).toBeDefined(); + expect(typeof item.dataId).toBe('string'); + }); + }); + + it('should include custom fields when specified', async () => { + // Create AI items to support customFeedbacks + const aiItem = await MongoChatItem.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + userId: testUser.userId, + appId, + chatId, + dataId: getNanoid(), + obj: ChatRoleEnum.AI, + value: [{ type: 'text', text: { content: 'AI response' } }], + customFeedbacks: ['good', 'helpful'] + }); + + const result = await getChatItems({ + appId, + chatId, + offset: 0, + limit: 3, + field: 'obj value customFeedbacks' + }); + + const aiHistory = result.histories.find((h) => h.obj === ChatRoleEnum.AI); + expect(aiHistory).toBeDefined(); + // Type assertion to access customFeedbacks on AI item + if (aiHistory && aiHistory.obj === ChatRoleEnum.AI) { + expect(aiHistory.customFeedbacks).toEqual(['good', 'helpful']); + } + }); + }); + + describe('Edge Cases', () => { + it('should handle single item correctly', async () => { + await createChatItems(1); + + const result = await getChatItems({ + appId, + chatId, + offset: 0, + limit: 10, + field: 'obj value' + }); + + expect(result.histories).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it('should handle offset beyond total items', async () => { + await createChatItems(5); + + const result = await getChatItems({ + appId, + chatId, + offset: 10, + limit: 5, + field: 'obj value' + }); + + expect(result.histories).toHaveLength(0); + expect(result.total).toBe(5); + }); + + it('should handle zero limit gracefully', async () => { + await createChatItems(5); + + const result = await getChatItems({ + appId, + chatId, + offset: 0, + limit: 0, + field: 'obj value' + }); + + // MongoDB's limit(0) returns all documents, so we should get all 5 + expect(result.histories).toHaveLength(5); + expect(result.total).toBe(5); + }); + + it('should filter by appId and chatId correctly', async () => { + const otherChatId = getNanoid(); + + // Create items in target chat + await createChatItems(5); + + // Create items in another chat + await MongoChatItem.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + appId, + chatId: otherChatId, + dataId: getNanoid(), + obj: ChatRoleEnum.Human, + value: [{ type: 'text', text: { content: 'Other chat' } }] + }); + + const result = await getChatItems({ + appId, + chatId, + offset: 0, + limit: 10, + field: 'obj value' + }); + + // Should only return items from target chat + expect(result.histories).toHaveLength(5); + expect(result.total).toBe(5); + }); + }); + + describe('Order Verification', () => { + it('should return items in chronological order (oldest first)', async () => { + const items = await createChatItems(10); + + const result = await getChatItems({ + appId, + chatId, + offset: 0, + limit: 10, + field: 'obj value' + }); + + // Verify order by comparing dataId with original items + expect(result.histories).toHaveLength(10); + result.histories.forEach((history, index) => { + expect(history.dataId).toBe(items[index].dataId); + }); + }); + }); + + describe('initialId Mode - Get items around target', () => { + it('should return items around the target item when initialId is provided', async () => { + const items = await createChatItems(10); + const targetItem = items[4]; // Middle item + + const result = await getChatItems({ + appId, + chatId, + initialId: targetItem.dataId, + limit: 5, + field: 'obj value' + }); + + // With limit 5: halfLimit=2, ceilLimit=3 + // Returns 2 items before + target + 3 items after = 6 items + expect(result.histories).toHaveLength(6); + expect(result.histories[2].dataId).toBe(targetItem.dataId); + expect(result.total).toBe(10); + }); + + it('should handle initialId at the beginning of chat history', async () => { + const items = await createChatItems(10); + const firstItem = items[0]; + + const result = await getChatItems({ + appId, + chatId, + initialId: firstItem.dataId, + limit: 5, + field: 'obj value' + }); + + // Should get first item and 4 items after it + expect(result.histories[0].dataId).toBe(firstItem.dataId); + expect(result.hasMorePrev).toBe(false); + expect(result.hasMoreNext).toBe(true); + }); + + it('should handle initialId at the end of chat history', async () => { + const items = await createChatItems(10); + const lastItem = items[9]; + + const result = await getChatItems({ + appId, + chatId, + initialId: lastItem.dataId, + limit: 5, + field: 'obj value' + }); + + // Should get last item and items before it + expect(result.histories[result.histories.length - 1].dataId).toBe(lastItem.dataId); + expect(result.hasMorePrev).toBe(true); + expect(result.hasMoreNext).toBe(false); + }); + + it('should set hasMorePrev and hasMoreNext correctly with initialId', async () => { + const items = await createChatItems(20); + const middleItem = items[10]; + + const result = await getChatItems({ + appId, + chatId, + initialId: middleItem.dataId, + limit: 5, + field: 'obj value' + }); + + expect(result.hasMorePrev).toBe(true); + expect(result.hasMoreNext).toBe(true); + expect(result.total).toBe(20); + }); + + it('should throw error when initialId does not exist', async () => { + await createChatItems(5); + + await expect( + getChatItems({ + appId, + chatId, + initialId: 'non-existent-id', + limit: 5, + field: 'obj value' + }) + ).rejects.toThrow('Target item not found'); + }); + + it('should handle small limit with initialId', async () => { + const items = await createChatItems(10); + const middleItem = items[5]; + + const result = await getChatItems({ + appId, + chatId, + initialId: middleItem.dataId, + limit: 3, + field: 'obj value' + }); + + // With limit 3: halfLimit=1, ceilLimit=2 + // Returns 1 item before + target + 2 items after = 4 items + expect(result.histories).toHaveLength(4); + expect(result.histories[1].dataId).toBe(middleItem.dataId); + }); + + it('should handle odd limit with initialId', async () => { + const items = await createChatItems(15); + const middleItem = items[7]; + + const result = await getChatItems({ + appId, + chatId, + initialId: middleItem.dataId, + limit: 7, + field: 'obj value' + }); + + // With limit 7: halfLimit=3, ceilLimit=4 + // Returns 3 items before + target + 4 items after = 8 items + expect(result.histories).toHaveLength(8); + expect(result.histories[3].dataId).toBe(middleItem.dataId); + }); + + it('should handle even limit with initialId', async () => { + const items = await createChatItems(15); + const middleItem = items[7]; + + const result = await getChatItems({ + appId, + chatId, + initialId: middleItem.dataId, + limit: 6, + field: 'obj value' + }); + + // With limit 6: halfLimit=3, ceilLimit=3 + // Returns 3 items before + target + 3 items after = 7 items + expect(result.histories).toHaveLength(7); + expect(result.histories[3].dataId).toBe(middleItem.dataId); + }); + + it('should return latest items when no initialId provided', async () => { + const items = await createChatItems(20); + + const result = await getChatItems({ + appId, + chatId, + limit: 5, + field: 'obj value' + }); + + // Should return the 5 latest items (items 16-20) + expect(result.histories).toHaveLength(5); + expect(result.histories[0].dataId).toBe(items[15].dataId); + expect(result.hasMorePrev).toBe(true); + expect(result.hasMoreNext).toBe(false); + }); + }); + + describe('prevId Mode - Get items before target', () => { + it('should return items before the target item when prevId is provided', async () => { + const items = await createChatItems(10); + const targetItem = items[5]; + + const result = await getChatItems({ + appId, + chatId, + prevId: targetItem.dataId, + limit: 3, + field: 'obj value' + }); + + // Should return 3 items before the target (items 2, 3, 4) + expect(result.histories).toHaveLength(3); + expect(result.histories[0].dataId).toBe(items[2].dataId); + expect(result.histories[2].dataId).toBe(items[4].dataId); + expect(result.hasMoreNext).toBe(true); // Target item and items after exist + expect(result.total).toBe(10); + }); + + it('should set hasMorePrev correctly with prevId', async () => { + const items = await createChatItems(20); + const targetItem = items[15]; + + const result = await getChatItems({ + appId, + chatId, + prevId: targetItem.dataId, + limit: 5, + field: 'obj value' + }); + + // Should return 5 items before item 15 (items 10-14) + expect(result.histories).toHaveLength(5); + expect(result.hasMorePrev).toBe(true); // Items 0-9 still exist + expect(result.hasMoreNext).toBe(true); // Target and items after exist + }); + + it('should handle prevId at the beginning of chat history', async () => { + const items = await createChatItems(10); + const earlyItem = items[2]; + + const result = await getChatItems({ + appId, + chatId, + prevId: earlyItem.dataId, + limit: 5, + field: 'obj value' + }); + + // Should only return 2 items (items 0 and 1) + expect(result.histories).toHaveLength(2); + expect(result.histories[0].dataId).toBe(items[0].dataId); + expect(result.hasMorePrev).toBe(false); + expect(result.hasMoreNext).toBe(true); + }); + + it('should throw error when prevId does not exist', async () => { + await createChatItems(5); + + await expect( + getChatItems({ + appId, + chatId, + prevId: 'non-existent-id', + limit: 5, + field: 'obj value' + }) + ).rejects.toThrow('Prev item not found'); + }); + + it('should return empty array when prevId is the first item', async () => { + const items = await createChatItems(10); + const firstItem = items[0]; + + const result = await getChatItems({ + appId, + chatId, + prevId: firstItem.dataId, + limit: 5, + field: 'obj value' + }); + + // No items before the first item + expect(result.histories).toHaveLength(0); + expect(result.hasMorePrev).toBe(false); + expect(result.hasMoreNext).toBe(true); + }); + + it('should maintain chronological order with prevId', async () => { + const items = await createChatItems(10); + const targetItem = items[7]; + + const result = await getChatItems({ + appId, + chatId, + prevId: targetItem.dataId, + limit: 4, + field: 'obj value' + }); + + // Should return items 3, 4, 5, 6 in order + expect(result.histories).toHaveLength(4); + for (let i = 0; i < result.histories.length; i++) { + expect(result.histories[i].dataId).toBe(items[3 + i].dataId); + } + }); + }); + + describe('nextId Mode - Get items after target', () => { + it('should return items after the target item when nextId is provided', async () => { + const items = await createChatItems(10); + const targetItem = items[4]; + + const result = await getChatItems({ + appId, + chatId, + nextId: targetItem.dataId, + limit: 3, + field: 'obj value' + }); + + // Should return 3 items after the target (items 5, 6, 7) + expect(result.histories).toHaveLength(3); + expect(result.histories[0].dataId).toBe(items[5].dataId); + expect(result.histories[2].dataId).toBe(items[7].dataId); + expect(result.hasMorePrev).toBe(true); // Target item and items before exist + expect(result.total).toBe(10); + }); + + it('should set hasMoreNext correctly with nextId', async () => { + const items = await createChatItems(20); + const targetItem = items[5]; + + const result = await getChatItems({ + appId, + chatId, + nextId: targetItem.dataId, + limit: 5, + field: 'obj value' + }); + + // Should return 5 items after item 5 (items 6-10) + expect(result.histories).toHaveLength(5); + expect(result.hasMorePrev).toBe(true); // Target and items before exist + expect(result.hasMoreNext).toBe(true); // Items 11-19 still exist + }); + + it('should handle nextId at the end of chat history', async () => { + const items = await createChatItems(10); + const lateItem = items[7]; + + const result = await getChatItems({ + appId, + chatId, + nextId: lateItem.dataId, + limit: 5, + field: 'obj value' + }); + + // Should only return 2 items (items 8 and 9) + expect(result.histories).toHaveLength(2); + expect(result.histories[0].dataId).toBe(items[8].dataId); + expect(result.hasMorePrev).toBe(true); + expect(result.hasMoreNext).toBe(false); + }); + + it('should throw error when nextId does not exist', async () => { + await createChatItems(5); + + await expect( + getChatItems({ + appId, + chatId, + nextId: 'non-existent-id', + limit: 5, + field: 'obj value' + }) + ).rejects.toThrow('Next item not found'); + }); + + it('should return empty array when nextId is the last item', async () => { + const items = await createChatItems(10); + const lastItem = items[9]; + + const result = await getChatItems({ + appId, + chatId, + nextId: lastItem.dataId, + limit: 5, + field: 'obj value' + }); + + // No items after the last item + expect(result.histories).toHaveLength(0); + expect(result.hasMorePrev).toBe(true); + expect(result.hasMoreNext).toBe(false); + }); + + it('should maintain chronological order with nextId', async () => { + const items = await createChatItems(10); + const targetItem = items[2]; + + const result = await getChatItems({ + appId, + chatId, + nextId: targetItem.dataId, + limit: 4, + field: 'obj value' + }); + + // Should return items 3, 4, 5, 6 in order + expect(result.histories).toHaveLength(4); + for (let i = 0; i < result.histories.length; i++) { + expect(result.histories[i].dataId).toBe(items[3 + i].dataId); + } + }); + }); + + describe('Pagination Mode Priorities', () => { + it('should use offset mode when offset is provided with other params', async () => { + const items = await createChatItems(10); + + const result = await getChatItems({ + appId, + chatId, + offset: 0, + initialId: items[5].dataId, + limit: 5, + field: 'obj value' + }); + + // Offset mode should take precedence - returns latest items + expect(result.histories).toHaveLength(5); + expect(result.hasMoreNext).toBe(false); // Offset mode starts from newest + }); + + it('should use prevId mode when both prevId and nextId are provided', async () => { + const items = await createChatItems(10); + + const result = await getChatItems({ + appId, + chatId, + prevId: items[5].dataId, + nextId: items[7].dataId, + limit: 3, + field: 'obj value' + }); + + // prevId mode should take precedence (checked before nextId in code) + expect(result.hasMoreNext).toBe(true); + // Should return items before items[5] + expect(result.histories.every((h) => h.dataId !== items[5].dataId)).toBe(true); + }); + }); +}); + +describe('addCustomFeedbacks', () => { + let testUser: Awaited>; + let appId: string; + let chatId: string; + let dataId: string; + + beforeEach(async () => { + testUser = await getUser('test-user-feedback'); + + const app = await MongoApp.create({ + name: 'Test App', + type: AppTypeEnum.simple, + teamId: testUser.teamId, + tmbId: testUser.tmbId, + modules: [] + }); + appId = String(app._id); + chatId = getNanoid(); + dataId = getNanoid(); + + // Create chat record + await MongoChat.create({ + chatId, + teamId: testUser.teamId, + tmbId: testUser.tmbId, + appId, + source: ChatSourceEnum.online + }); + + await MongoChatItem.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + appId, + chatId, + dataId, + obj: ChatRoleEnum.AI, + value: [{ type: 'text', text: { content: 'Test message' } }] + }); + }); + + it('should add custom feedbacks to chat item', async () => { + await addCustomFeedbacks({ + appId, + chatId, + dataId, + feedbacks: ['good', 'helpful'] + }); + + const item = await MongoChatItem.findOne({ dataId }).lean(); + // Cast to any to access customFeedbacks (it's optional on schema) + expect((item as any)?.customFeedbacks).toEqual(['good', 'helpful']); + }); + + it('should append feedbacks to existing ones', async () => { + // Add initial feedbacks + await addCustomFeedbacks({ + appId, + chatId, + dataId, + feedbacks: ['good'] + }); + + // Add more feedbacks + await addCustomFeedbacks({ + appId, + chatId, + dataId, + feedbacks: ['helpful', 'clear'] + }); + + const item = await MongoChatItem.findOne({ dataId }).lean(); + expect((item as any)?.customFeedbacks).toEqual(['good', 'helpful', 'clear']); + }); + + it('should handle empty feedbacks array', async () => { + await addCustomFeedbacks({ + appId, + chatId, + dataId, + feedbacks: [] + }); + + const item = await MongoChatItem.findOne({ dataId }).lean(); + expect((item as any)?.customFeedbacks || []).toHaveLength(0); + }); + + it('should do nothing when chatId is not provided', async () => { + await addCustomFeedbacks({ + appId, + chatId: undefined, + dataId, + feedbacks: ['good'] + }); + + const item = await MongoChatItem.findOne({ dataId }).lean(); + // When no update occurs, the field may be an empty array due to schema default or undefined + const feedbacks = (item as any)?.customFeedbacks; + expect(feedbacks === undefined || feedbacks?.length === 0).toBe(true); + }); + + it('should do nothing when dataId is not provided', async () => { + await addCustomFeedbacks({ + appId, + chatId, + dataId: undefined, + feedbacks: ['good'] + }); + + const item = await MongoChatItem.findOne({ dataId }).lean(); + const feedbacks = (item as any)?.customFeedbacks; + expect(feedbacks === undefined || feedbacks?.length === 0).toBe(true); + }); + + it('should handle non-existent item gracefully', async () => { + // Should not throw error + await expect( + addCustomFeedbacks({ + appId, + chatId, + dataId: 'non-existent-id', + feedbacks: ['good'] + }) + ).resolves.not.toThrow(); + }); + + describe('updateChatFeedbackCount integration', () => { + it('should not set Chat feedback flags when no user feedback exists', async () => { + // addCustomFeedbacks always calls updateChatFeedbackCount + // When there's no user feedback, flags should remain undefined + await addCustomFeedbacks({ + appId, + chatId, + dataId, + feedbacks: ['good', 'helpful'] + }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + // Without user feedback, flags should be undefined (not set) + expect(chat?.hasGoodFeedback).toBeUndefined(); + expect(chat?.hasBadFeedback).toBeUndefined(); + expect(chat?.hasUnreadGoodFeedback).toBeUndefined(); + expect(chat?.hasUnreadBadFeedback).toBeUndefined(); + }); + + it('should update Chat flags when chat item has user feedback', async () => { + // First, add user good feedback to the chat item + await MongoChatItem.updateOne({ dataId }, { $set: { userGoodFeedback: 'Great answer!' } }); + + // Then add custom feedbacks (which triggers updateChatFeedbackCount) + await addCustomFeedbacks({ + appId, + chatId, + dataId, + feedbacks: ['category1', 'category2'] + }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + // Should detect the userGoodFeedback and set flag + expect(chat?.hasGoodFeedback).toBe(true); + expect(chat?.hasBadFeedback).toBeUndefined(); + }); + + it('should aggregate all feedback when adding custom feedbacks', async () => { + // Create another AI message with bad feedback + const dataId2 = getNanoid(); + await MongoChatItem.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + appId, + chatId, + dataId: dataId2, + obj: ChatRoleEnum.AI, + value: [{ type: 'text', text: { content: 'Another message' } }], + userBadFeedback: 'Wrong answer', + isFeedbackRead: false + }); + + // Add user good feedback to first message + await MongoChatItem.updateOne( + { dataId }, + { $set: { userGoodFeedback: 'Good answer', isFeedbackRead: true } } + ); + + // Add custom feedbacks to first message + await addCustomFeedbacks({ + appId, + chatId, + dataId, + feedbacks: ['helpful'] + }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + // Should aggregate both messages + expect(chat?.hasGoodFeedback).toBe(true); + expect(chat?.hasBadFeedback).toBe(true); + expect(chat?.hasUnreadGoodFeedback).toBeUndefined(); // first message is read + expect(chat?.hasUnreadBadFeedback).toBe(true); // second message is unread + }); + + it('should handle transaction rollback correctly', async () => { + // Add user feedback first + await MongoChatItem.updateOne({ dataId }, { $set: { userGoodFeedback: 'Great!' } }); + + // Normal add should succeed and update Chat flags + await addCustomFeedbacks({ + appId, + chatId, + dataId, + feedbacks: ['good'] + }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBe(true); + + // Verify custom feedbacks were added + const item = await MongoChatItem.findOne({ dataId }).lean(); + expect((item as any)?.customFeedbacks).toEqual(['good']); + }); + + it('should maintain Chat flags consistency across multiple operations', async () => { + // Start with no feedback flags + let chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBeUndefined(); + + // Add user feedback and custom feedback together + await MongoChatItem.updateOne( + { dataId }, + { $set: { userGoodFeedback: 'Excellent', isFeedbackRead: false } } + ); + + await addCustomFeedbacks({ + appId, + chatId, + dataId, + feedbacks: ['category1'] + }); + + // Check flags are set + chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBe(true); + expect(chat?.hasUnreadGoodFeedback).toBe(true); + + // Mark as read + await MongoChatItem.updateOne({ dataId }, { $set: { isFeedbackRead: true } }); + + // Add more custom feedbacks + await addCustomFeedbacks({ + appId, + chatId, + dataId, + feedbacks: ['category2'] + }); + + // Unread flag should be undefined now (not false) + chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBe(true); + expect(chat?.hasUnreadGoodFeedback).toBeUndefined(); + }); + }); +}); + +describe('updateChatFeedbackCount', () => { + let testUser: Awaited>; + let appId: string; + let chatId: string; + + beforeEach(async () => { + testUser = await getUser('test-user-feedback-count'); + + // 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); + chatId = getNanoid(); + + // Create chat record + await MongoChat.create({ + chatId, + teamId: testUser.teamId, + tmbId: testUser.tmbId, + appId, + source: ChatSourceEnum.online + }); + }); + + // Helper function to create chat item with feedback + const createChatItemWithFeedback = async ( + feedback: { + userGoodFeedback?: string; + userBadFeedback?: string; + isFeedbackRead?: boolean; + }, + obj: ChatRoleEnum = ChatRoleEnum.AI + ) => { + return await MongoChatItem.create({ + teamId: testUser.teamId, + tmbId: testUser.tmbId, + userId: testUser.userId, + appId, + chatId, + dataId: getNanoid(), + obj, + value: [{ type: 'text', text: { content: 'Test message' } }], + ...feedback + }); + }; + + it('should not set feedback flags when no feedback exists', async () => { + // Create AI items without feedback + await createChatItemWithFeedback({}, ChatRoleEnum.AI); + await createChatItemWithFeedback({}, ChatRoleEnum.AI); + + await updateChatFeedbackCount({ appId, chatId }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBeUndefined(); + expect(chat?.hasBadFeedback).toBeUndefined(); + expect(chat?.hasUnreadGoodFeedback).toBeUndefined(); + expect(chat?.hasUnreadBadFeedback).toBeUndefined(); + }); + + it('should set hasGoodFeedback to true when good feedback exists', async () => { + await createChatItemWithFeedback({ + userGoodFeedback: 'Great response!' + }); + + await updateChatFeedbackCount({ appId, chatId }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBe(true); + expect(chat?.hasBadFeedback).toBeUndefined(); + }); + + it('should set hasBadFeedback to true when bad feedback exists', async () => { + await createChatItemWithFeedback({ + userBadFeedback: 'Incorrect answer' + }); + + await updateChatFeedbackCount({ appId, chatId }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBeUndefined(); + expect(chat?.hasBadFeedback).toBe(true); + }); + + it('should set both feedback flags when both types exist', async () => { + await createChatItemWithFeedback({ + userGoodFeedback: 'Great response!' + }); + await createChatItemWithFeedback({ + userBadFeedback: 'Incorrect answer' + }); + + await updateChatFeedbackCount({ appId, chatId }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBe(true); + expect(chat?.hasBadFeedback).toBe(true); + }); + + it('should set hasUnreadGoodFeedback when good feedback is unread', async () => { + await createChatItemWithFeedback({ + userGoodFeedback: 'Great response!', + isFeedbackRead: false + }); + + await updateChatFeedbackCount({ appId, chatId }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBe(true); + expect(chat?.hasUnreadGoodFeedback).toBe(true); + }); + + it('should not set hasUnreadGoodFeedback when good feedback is read', async () => { + await createChatItemWithFeedback({ + userGoodFeedback: 'Great response!', + isFeedbackRead: true + }); + + await updateChatFeedbackCount({ appId, chatId }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBe(true); + expect(chat?.hasUnreadGoodFeedback).toBeUndefined(); + }); + + it('should set hasUnreadBadFeedback when bad feedback is unread', async () => { + await createChatItemWithFeedback({ + userBadFeedback: 'Incorrect answer', + isFeedbackRead: false + }); + + await updateChatFeedbackCount({ appId, chatId }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasBadFeedback).toBe(true); + expect(chat?.hasUnreadBadFeedback).toBe(true); + }); + + it('should not set hasUnreadBadFeedback when bad feedback is read', async () => { + await createChatItemWithFeedback({ + userBadFeedback: 'Incorrect answer', + isFeedbackRead: true + }); + + await updateChatFeedbackCount({ appId, chatId }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasBadFeedback).toBe(true); + expect(chat?.hasUnreadBadFeedback).toBeUndefined(); + }); + + it('should handle mixed read/unread feedback correctly', async () => { + // Unread good feedback + await createChatItemWithFeedback({ + userGoodFeedback: 'Great!', + isFeedbackRead: false + }); + // Read good feedback + await createChatItemWithFeedback({ + userGoodFeedback: 'Nice!', + isFeedbackRead: true + }); + // Unread bad feedback + await createChatItemWithFeedback({ + userBadFeedback: 'Wrong', + isFeedbackRead: false + }); + // Read bad feedback + await createChatItemWithFeedback({ + userBadFeedback: 'Incorrect', + isFeedbackRead: true + }); + + await updateChatFeedbackCount({ appId, chatId }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBe(true); + expect(chat?.hasBadFeedback).toBe(true); + expect(chat?.hasUnreadGoodFeedback).toBe(true); + expect(chat?.hasUnreadBadFeedback).toBe(true); + }); + + it('should only count AI messages, not Human messages', async () => { + // Human message with feedback (should be ignored) + await createChatItemWithFeedback( + { + userGoodFeedback: 'Great!' + }, + ChatRoleEnum.Human + ); + + // AI message without feedback + await createChatItemWithFeedback({}, ChatRoleEnum.AI); + + await updateChatFeedbackCount({ appId, chatId }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBeUndefined(); + expect(chat?.hasBadFeedback).toBeUndefined(); + }); + + it('should handle multiple feedbacks of the same type', async () => { + // Create 3 good feedbacks + await createChatItemWithFeedback({ + userGoodFeedback: 'Great response 1!' + }); + await createChatItemWithFeedback({ + userGoodFeedback: 'Great response 2!' + }); + await createChatItemWithFeedback({ + userGoodFeedback: 'Great response 3!' + }); + + await updateChatFeedbackCount({ appId, chatId }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBe(true); + expect(chat?.hasBadFeedback).toBeUndefined(); + }); + + it('should update flags correctly when feedback is removed', async () => { + // Create item with good feedback + const item = await createChatItemWithFeedback({ + userGoodFeedback: 'Great response!' + }); + + await updateChatFeedbackCount({ appId, chatId }); + + let chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBe(true); + + // Remove feedback + await MongoChatItem.updateOne({ _id: item._id }, { $unset: { userGoodFeedback: '' } }); + + await updateChatFeedbackCount({ appId, chatId }); + + chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBeUndefined(); + }); + + it('should handle chat with no AI messages', async () => { + // Create only human messages + await createChatItemWithFeedback({}, ChatRoleEnum.Human); + + await updateChatFeedbackCount({ appId, chatId }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBeUndefined(); + expect(chat?.hasBadFeedback).toBeUndefined(); + expect(chat?.hasUnreadGoodFeedback).toBeUndefined(); + expect(chat?.hasUnreadBadFeedback).toBeUndefined(); + }); + + it('should handle isFeedbackRead undefined as unread', async () => { + // When isFeedbackRead is undefined, it should be treated as unread + await createChatItemWithFeedback({ + userGoodFeedback: 'Great response!' + // isFeedbackRead is undefined + }); + + await updateChatFeedbackCount({ appId, chatId }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBe(true); + expect(chat?.hasUnreadGoodFeedback).toBe(true); + }); + + it('should correctly aggregate large number of feedbacks', async () => { + // Create 10 good feedbacks (5 unread, 5 read) + for (let i = 0; i < 10; i++) { + await createChatItemWithFeedback({ + userGoodFeedback: `Good ${i}`, + isFeedbackRead: i >= 5 + }); + } + + // Create 8 bad feedbacks (3 unread, 5 read) + for (let i = 0; i < 8; i++) { + await createChatItemWithFeedback({ + userBadFeedback: `Bad ${i}`, + isFeedbackRead: i >= 3 + }); + } + + await updateChatFeedbackCount({ appId, chatId }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBe(true); + expect(chat?.hasBadFeedback).toBe(true); + expect(chat?.hasUnreadGoodFeedback).toBe(true); + expect(chat?.hasUnreadBadFeedback).toBe(true); + }); + + it('should work correctly within a transaction session', async () => { + await createChatItemWithFeedback({ + userGoodFeedback: 'Great response!' + }); + + // Test that it works with session parameter (session will be undefined in this test) + await updateChatFeedbackCount({ appId, chatId, session: undefined }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + expect(chat?.hasGoodFeedback).toBe(true); + }); + + it('should handle edge case with empty feedback strings', async () => { + // Create items with empty strings + await createChatItemWithFeedback({ + userGoodFeedback: '' + }); + + await updateChatFeedbackCount({ appId, chatId }); + + const chat = await MongoChat.findOne({ appId, chatId }).lean(); + // Empty string is still truthy in MongoDB's $ifNull check, so it counts as feedback + expect(chat?.hasGoodFeedback).toBe(true); + }); +});