Commit 134ece8c by Xianquan Committed by GitHub

feat: refine chat log feedback actions (#7172)

* fix: truncate long chat tool names

* feat: refine chat log feedback actions

* fix: type narrow log feedback actions
parent 0225519b
......@@ -63,6 +63,7 @@
"log.feedback.mark_as_read": "Mark as Read",
"log.feedback.read": "Read",
"log.feedback.show_feedback": "Show Feedback",
"log.feedback.user_bad_feedback": "User feedback",
"master_agent_call": "Master agent",
"mobile_clear_history_confirm_tip": "This action cannot be undone",
"mobile_clear_history_confirm_title": "Clear history?",
......
......@@ -63,6 +63,7 @@
"log.feedback.mark_as_read": "标为已读",
"log.feedback.read": "已读",
"log.feedback.show_feedback": "显示反馈",
"log.feedback.user_bad_feedback": "用户反馈",
"master_agent_call": "主 agent 调用",
"mobile_clear_history_confirm_tip": "此操作不可撤销",
"mobile_clear_history_confirm_title": "确定清空历史记录?",
......
......@@ -62,6 +62,7 @@
"log.feedback.mark_as_read": "標為已讀",
"log.feedback.read": "已讀",
"log.feedback.show_feedback": "顯示反饋",
"log.feedback.user_bad_feedback": "用戶反饋",
"master_agent_call": "主 agent 調用",
"mobile_clear_history_confirm_tip": "此操作不可撤銷",
"mobile_clear_history_confirm_title": "確定清空歷史記錄?",
......
......@@ -8,6 +8,7 @@ import ChatController, { type ChatControllerProps } from '../ChatController';
import { ChatBoxContext } from '../../Provider';
import { useContextSelector } from 'use-context-selector';
import { ChatTypeEnum } from '../../constants';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import type { ChatSiteItemType } from '../../type';
import { addStatisticalDataToHistoryItem } from '@/global/core/chat/utils';
import { useSandboxEditor } from '@/pageComponents/chat/SandboxEditor/hook';
......@@ -37,7 +38,7 @@ const AIChatBubbleActions = ({
responseData
}: AIChatBubbleActionsProps) => {
const { t } = useTranslation();
const { onRetry } = chatControllerProps;
const { onRetry, feedbackUserName } = chatControllerProps;
const { isPc } = useSystem();
const chatType = useContextSelector(ChatBoxContext, (v) => v.chatType);
const showRetry = chatType !== ChatTypeEnum.log && !!onRetry;
......@@ -65,6 +66,11 @@ const AIChatBubbleActions = ({
);
}, [responseData]);
const showTotalPoints = showPoints && totalPoints > 0;
const badFeedback = historyItem.obj === ChatRoleEnum.AI ? historyItem.userBadFeedback : undefined;
const isFeedbackRead =
historyItem.obj === ChatRoleEnum.AI ? historyItem.isFeedbackRead : undefined;
const showUnreadBadFeedback =
chatType === ChatTypeEnum.log && !!badFeedback && isFeedbackRead !== true;
const formattedPoints = useMemo(() => {
const formatted = new Intl.NumberFormat(undefined, {
......@@ -72,6 +78,22 @@ const AIChatBubbleActions = ({
}).format(totalPoints);
return totalPoints > 0 ? `-${formatted}` : formatted;
}, [totalPoints]);
const renderRunDetailAction = () => (
<Flex
alignItems={'center'}
gap={'4px'}
p={'4px'}
cursor={'pointer'}
color={'myGray.400'}
_hover={{ color: 'primary.600' }}
onClick={onOpenWholeModal}
>
<MyIcon name={'core/chat/terminal'} w={'16px'} />
<Box>{t('chat:run_detail')}</Box>
</Flex>
);
const showRunDetailAfterCopy =
chatControllerProps.footerRunDetailPosition === 'afterCopy' && showWholeResponse;
return (
<Box mt={4} maxW={'100%'}>
......@@ -86,7 +108,11 @@ const AIChatBubbleActions = ({
zIndex={1}
>
<Flex alignItems={'center'} gap={'4px'}>
<ChatController {...chatControllerProps} variant="footer" />
<ChatController
{...chatControllerProps}
variant="footer"
footerAfterCopySlot={showRunDetailAfterCopy ? renderRunDetailAction() : undefined}
/>
{showRetry && (
<MyTooltip label={t('common:core.chat.retry')}>
......@@ -102,20 +128,7 @@ const AIChatBubbleActions = ({
</MyTooltip>
)}
{showWholeResponse && (
<Flex
alignItems={'center'}
gap={'4px'}
p={'4px'}
cursor={'pointer'}
color={'myGray.400'}
_hover={{ color: 'primary.600' }}
onClick={onOpenWholeModal}
>
<MyIcon name={'core/chat/terminal'} w={'16px'} />
<Box>{t('chat:run_detail')}</Box>
</Flex>
)}
{showWholeResponse && !showRunDetailAfterCopy && renderRunDetailAction()}
{showSandboxAction && isPc && useAgentSandbox && (
<Flex
......@@ -157,6 +170,27 @@ const AIChatBubbleActions = ({
)}
</Flex>
{showUnreadBadFeedback && (
<Flex
mt={4}
flexDirection={'column'}
gap={'8px'}
maxW={'100%'}
border={'1px solid'}
borderColor={'myGray.250'}
borderRadius={'8px'}
p={'12px'}
whiteSpace={'pre-wrap'}
>
<Box fontSize={'10px'} lineHeight={'14px'} color={'myGray.500'}>
{feedbackUserName || t('chat:log.feedback.user_bad_feedback')}
</Box>
<Box fontSize={'12px'} lineHeight={'18px'} color={'myGray.900'}>
{badFeedback}
</Box>
</Flex>
)}
{questionGuides.length > 0 && (
<Flex mt={4} flexDirection={'column'} alignItems={'flex-start'} gap={'8px'}>
{questionGuides.map((text) => (
......
import { Box, type BoxProps, Button, Flex } from '@chakra-ui/react';
import React, { useMemo, useState } from 'react';
import { Box, type BoxProps, Flex } from '@chakra-ui/react';
import React, { useMemo } from 'react';
import { type ChatControllerProps } from './ChatController';
import styles from '../index.module.scss';
import { ChatRoleEnum, ChatStatusEnum } from '@fastgpt/global/core/chat/constants';
......@@ -52,8 +52,6 @@ const ChatItem = (props: Props) => {
const { t } = useTranslation();
const [showFeedbackContent, setShowFeedbackContent] = useState(false);
const styleMap: BoxProps = useMemoEnhance(
() => ({
order: chat.obj === ChatRoleEnum.Human ? 0 : 1,
......@@ -259,36 +257,6 @@ const ChatItem = (props: Props) => {
</Flex>
)}
{/* User Feedback Content: Admin log show */}
{isChatLog &&
showFeedbackContent &&
chat.obj === ChatRoleEnum.AI &&
(chat.userGoodFeedback || chat.userBadFeedback) && (
<Box
mt={2}
maxW={'250'}
border={'1px solid'}
borderColor={'myGray.250'}
borderRadius={'md'}
p={3}
>
<Box fontSize={'sm'} color={'myGray.900'} whiteSpace={'pre-wrap'}>
{chat.userBadFeedback || chat.userGoodFeedback}
</Box>
<Flex justifyContent={'flex-end'} mt={2}>
<Button
size={'xs'}
variant={'grayGhost'}
fontSize={'xs'}
onClick={() => setShowFeedbackContent(false)}
color={'primary.600'}
>
{t('chat:log.feedback.hide_feedback')}
</Button>
</Flex>
</Box>
)}
{/* content */}
{splitAiResponseResults.map((value, i) => {
const isPlanCard =
......@@ -352,9 +320,7 @@ const ChatItem = (props: Props) => {
onOpenCiteModal={onOpenCiteModal}
chatControllerProps={{
...props,
isLastChild,
showFeedbackContent,
onToggleFeedbackContent: () => setShowFeedbackContent(!showFeedbackContent)
isLastChild
}}
>
{renderCommonFooter()}
......
......@@ -42,6 +42,9 @@ export type ChatRecordsListProps = {
dataId: string;
trigger: number;
};
disableFooterHoverTranslate?: boolean;
footerRunDetailPosition?: 'default' | 'afterCopy';
feedbackUserName?: string;
onCloseCustomFeedback: (
chat: ChatSiteItemType,
index: number
......@@ -76,6 +79,9 @@ const ChatRecordsList = ({
onAddUserLike,
onAddUserDislike,
likeFeedbackEffect,
disableFooterHoverTranslate,
footerRunDetailPosition,
feedbackUserName,
onCloseCustomFeedback,
onToggleFeedbackReadStatus
}: ChatRecordsListProps) => {
......@@ -222,6 +228,9 @@ const ChatRecordsList = ({
likeFeedbackEffect?.dataId === item.dataId
? likeFeedbackEffect.trigger
: undefined,
disableFooterHoverTranslate,
footerRunDetailPosition,
feedbackUserName,
onToggleFeedbackReadStatus: onToggleFeedbackReadStatus(item)
}}
>
......
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, type BoxProps, type IconProps } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import styles from '../index.module.scss';
......@@ -16,10 +16,12 @@ type Particle = {
color: string;
};
type LikeFeedbackButtonProps = Pick<BoxProps, 'cursor' | 'onClick'> &
type LikeFeedbackButtonProps = Pick<BoxProps, 'cursor'> &
Pick<IconProps, 'w' | 'h' | 'boxSize' | 'p'> & {
isActive: boolean;
effectTrigger?: number;
disableHoverTranslate?: boolean;
onClick?: () => void;
};
const blueColors = ['#3370ff', '#4f82ff', '#7ca3ff'];
......@@ -50,7 +52,7 @@ const getParticles = (x: number, y: number): Particle[] =>
/**
* 渲染点赞按钮的局部成功反馈。
*
* hover、图标弹跳和 canvas 粒子参数都对齐 prototype,只有新的 effectTrigger 会播放撒花。
* hover、点击图标弹跳和 canvas 粒子参数都对齐 prototype,只有新的 effectTrigger 会播放撒花。
*/
const LikeFeedbackButton = ({
isActive,
......@@ -60,13 +62,15 @@ const LikeFeedbackButton = ({
w,
h,
boxSize,
p
p,
disableHoverTranslate = false
}: LikeFeedbackButtonProps) => {
const buttonRef = useRef<HTMLSpanElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const particlesRef = useRef<Particle[]>([]);
const rafRef = useRef<number>();
const playedTriggerRef = useRef<number>();
const [iconPopTrigger, setIconPopTrigger] = useState(0);
const resizeCanvas = useCallback(() => {
const canvas = canvasRef.current;
......@@ -183,6 +187,13 @@ const LikeFeedbackButton = ({
useEffect(() => stopAnimation, [stopAnimation]);
const handleClick = useCallback(() => {
if (!onClick) return;
setIconPopTrigger((trigger) => trigger + 1);
onClick();
}, [onClick]);
return (
<Box
as="span"
......@@ -200,12 +211,12 @@ const LikeFeedbackButton = ({
transition="color 180ms ease, transform 180ms ease, filter 180ms ease"
_hover={{
color: 'primary.600',
transform: 'translateY(-1px)'
...(!disableHoverTranslate && { transform: 'translateY(-1px)' })
}}
onClick={onClick}
onClick={handleClick}
>
<MyIcon
key={effectTrigger || 'idle'}
key={iconPopTrigger}
w={w}
h={h}
boxSize={boxSize}
......@@ -213,7 +224,7 @@ const LikeFeedbackButton = ({
cursor={undefined}
color="currentColor"
_hover={undefined}
className={effectTrigger ? styles.likeFeedbackIconPop : undefined}
className={iconPopTrigger > 0 ? styles.likeFeedbackIconPop : undefined}
name="core/chat/feedback/goodLight"
/>
</Box>
......
......@@ -99,6 +99,12 @@ type Props = OutLinkChatAuthProps &
EmptyState?: React.ReactNode;
/** 是否启用 AI 正文 quick-replies 快捷回复渲染,默认关闭。 */
enableQuickReplies?: boolean;
/** 是否禁用 footer actions hover 时的上移动画。 */
disableFooterHoverTranslate?: boolean;
/** footer 中运行详情的位置,默认保持原有顺序。 */
footerRunDetailPosition?: 'default' | 'afterCopy';
/** 日志详情中展示用户反馈内容时使用的用户显示名。 */
feedbackUserName?: string;
};
const ChatBox = ({
......@@ -121,6 +127,9 @@ const ChatBox = ({
inputBodyProps,
EmptyState,
enableQuickReplies = false,
disableFooterHoverTranslate = false,
footerRunDetailPosition = 'default',
feedbackUserName,
...props
}: Props) => {
const { t } = useTranslation();
......@@ -553,6 +562,9 @@ const ChatBox = ({
onAddUserLike,
onAddUserDislike,
likeFeedbackEffect,
disableFooterHoverTranslate,
footerRunDetailPosition,
feedbackUserName,
onCloseCustomFeedback,
onToggleFeedbackReadStatus
}),
......@@ -571,6 +583,9 @@ const ChatBox = ({
onAddUserLike,
onAddUserDislike,
likeFeedbackEffect,
disableFooterHoverTranslate,
footerRunDetailPosition,
feedbackUserName,
onCloseCustomFeedback,
onToggleFeedbackReadStatus
]
......
......@@ -44,8 +44,10 @@ const RenderTool = React.memo(
h={'24px'}
minH={'24px'}
w={'fit-content'}
maxW={'100%'}
display={'flex'}
alignItems={'center'}
overflow={'hidden'}
lineHeight={'24px'}
p={0}
bg={'transparent'}
......@@ -56,11 +58,19 @@ const RenderTool = React.memo(
_hover={{ bg: 'transparent', color: 'myGray.600' }}
_expanded={{ color: 'myGray.600' }}
>
<HStack h={'24px'} lineHeight={'24px'} mr={1} spacing="0">
<Flex w="24px" h="24px" alignItems="center" justifyContent="center">
<HStack h={'24px'} lineHeight={'24px'} mr={1} spacing="0" minW={0} overflow={'hidden'}>
<Flex w="24px" h="24px" flexShrink={0} alignItems="center" justifyContent="center">
<Avatar src={tool.toolAvatar} w="16px" h="16px" borderRadius="xs" />
</Flex>
<Box fontSize="16px" lineHeight="24px" color="myGray.600">
<Box
fontSize="16px"
lineHeight="24px"
color="myGray.600"
minW={0}
overflow={'hidden'}
textOverflow={'ellipsis'}
whiteSpace={'nowrap'}
>
{t(tool.toolName)}
</Box>
</HStack>
......
......@@ -10,7 +10,6 @@ import dynamic from 'next/dynamic';
import LightRowTabs from '@fastgpt/web/components/common/Tabs/LightRowTabs';
import { PluginRunBoxTabEnum } from '@/components/core/chat/ChatContainer/PluginRunBox/constants';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { PcHeader } from '@/pageComponents/chat/ChatHeader';
import { GetChatTypeEnum } from '@fastgpt/global/core/chat/constants';
import ChatItemContextProvider, { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import ChatRecordContextProvider, {
......@@ -30,12 +29,14 @@ const ChatBox = dynamic(() => import('@/components/core/chat/ChatContainer/ChatB
type Props = {
appId: string;
chatId: string;
feedbackUserName?: string;
onClose: () => void;
};
const DetailLogsModal = ({
appId,
chatId,
feedbackUserName,
onClose,
feedbackRecordId,
......@@ -47,7 +48,7 @@ const DetailLogsModal = ({
const { t } = useTranslation();
const { isPc } = useSystem();
const [refreshTrigger, setRefreshTrigger] = useState(false);
const [, setRefreshTrigger] = useState(false);
const [feedbackType, setFeedbackType] = useState<'all' | 'has_feedback' | 'good' | 'bad'>('all');
const [unreadOnly, setUnreadOnly] = useState<boolean>(false);
......@@ -59,7 +60,6 @@ const DetailLogsModal = ({
const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData);
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
const totalRecordsCount = useContextSelector(ChatRecordContext, (v) => v.totalRecordsCount);
const { data: chat } = useRequest(
async () => {
......@@ -77,14 +77,13 @@ const DetailLogsModal = ({
{
manual: false,
refreshDeps: [chatId],
onError(e) {
onError() {
onClose();
}
}
);
const title = chat?.title;
const chatModels = chat?.app?.chatModels;
const isPlugin = chat?.app.type === AppTypeEnum.workflowTool;
// Sandbox: Status Hook 负责网络同步,UI Hook 负责弹窗渲染
......@@ -146,42 +145,36 @@ const DetailLogsModal = ({
/>
</Flex>
) : (
<Flex
alignItems={'center'}
px={[4, 5]}
h={['48px', '56px']}
borderBottom={'base'}
borderBottomColor={'myGray.200'}
color={'myGray.900'}
>
<Flex alignItems={'center'} gap={2} px={[4, 5]} h={['48px', '56px']} color={'myGray.900'}>
{isPc ? (
<>
<PcHeader
totalRecordsCount={totalRecordsCount}
title={title || ''}
chatModels={chatModels}
chatId={chatId}
/>
<Box flex={1} />
</>
<Box
flex={'1 1 0'}
minW={0}
className="textEllipsis"
fontSize={'16px'}
fontWeight={500}
lineHeight={'24px'}
>
{title}
</Box>
) : (
<>
<Flex px={3} alignItems={'center'} flex={'1 0 0'} w={0} justifyContent={'center'}>
<Box ml={1} className="textEllipsis">
{title}
</Box>
</Flex>
</>
<Flex px={3} alignItems={'center'} flex={'1 1 0'} w={0} justifyContent={'center'}>
<Box ml={1} className="textEllipsis">
{title}
</Box>
</Flex>
)}
<SandboxEntryIcon size={'smSquare'} mr={2} onOpen={onOpenSandboxModal} />
<SandboxEntryIcon size={'smSquare'} onOpen={onOpenSandboxModal} />
<IconButton
variant={'whiteBase'}
variant={'ghost'}
w={'32px'}
h={'32px'}
minW={'32px'}
p={0}
borderColor={'myGray.250'}
bg={'transparent'}
border={'none'}
boxShadow={'none'}
aria-label="Close"
icon={<MyIcon name={'common/closeLight'} w={'16px'} />}
onClick={onClose}
......@@ -206,6 +199,9 @@ const DetailLogsModal = ({
showMarkIcon
showVoiceIcon={false}
chatType={ChatTypeEnum.log}
disableFooterHoverTranslate
footerRunDetailPosition={'afterCopy'}
feedbackUserName={feedbackUserName}
onTriggerRefresh={() => setRefreshTrigger((prev) => !prev)}
/>
)}
......@@ -234,7 +230,16 @@ const DetailLogsModal = ({
</Flex>
{/* Feedback filter bar - commented out, moved to Render component */}
<Flex bg="white" px={6} py={3} borderTop="1px solid" borderColor="myGray.200">
<Flex
bg="white"
mx={6}
py={6}
h={'85px'}
minH={'85px'}
flexShrink={0}
borderTop="1px solid"
borderColor="myGray.200"
>
<DetailLogsModalFeedbackTypeFilter
feedbackType={feedbackType}
setFeedbackType={setFeedbackType}
......
......@@ -275,7 +275,7 @@ export const DetailLogsModalFeedbackTypeFilter = ({
return () => {
eventBus.off(EventNameEnum.refreshFeedback);
};
}, []);
}, [loadFeedbackRecords]);
return (
<Flex alignItems={'center'} gap={3} w={'100%'}>
......@@ -290,17 +290,18 @@ export const DetailLogsModalFeedbackTypeFilter = ({
{showNavigation && (
<>
{/* Current position indicator */}
<Box fontSize={'sm'} color={'myGray.600'} whiteSpace={'nowrap'} flex={1}>
<Box fontSize={'sm'} color={'myGray.600'} whiteSpace={'nowrap'}>
{currentPosition}/{totalCount}
</Box>
{/* Previous button */}
<Button size="sm" w={'100px'} variant={'whiteBase'} onClick={handlePrev}>
{t('chat:Previous')}
</Button>
<Button size="sm" w={'100px'} variant={'whiteBase'} onClick={handleNext}>
{t('chat:Next')}
</Button>
<Flex flex={1} gap={3} minW={0}>
<Button size="sm" flex={1} h={'36px'} py={2} variant={'whiteBase'} onClick={handlePrev}>
{t('chat:Previous')}
</Button>
<Button size="sm" flex={1} h={'36px'} py={2} variant={'whiteBase'} onClick={handleNext}>
{t('chat:Next')}
</Button>
</Flex>
</>
)}
</Flex>
......
......@@ -73,7 +73,10 @@ const LogTable = ({
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const [detailLogsId, setDetailLogsId] = useState<string>();
const [detailLogData, setDetailLogData] = useState<{
chatId: string;
feedbackUserName?: string;
}>();
const appName = useContextSelector(AppContext, (v) => v.appDetail.name);
const [unreadOnly, setUnreadOnly] = useState<boolean>(false);
const [userIpType, setUserIpType] = useState<UserIpTypeValue>('all');
......@@ -392,13 +395,13 @@ const LogTable = ({
<Flex gap={3} px={1}>
{!!item?.userGoodFeedbackCount && (
<Flex alignItems={'center'}>
<MyIcon mr={1} name={'core/chat/feedback/goodLight'} color={'green.500'} w={4} />
<MyIcon mr={1} name={'core/chat/feedback/goodLight'} color={'myGray.400'} w={4} />
{item.userGoodFeedbackCount}
</Flex>
)}
{!!item?.userBadFeedbackCount && (
<Flex alignItems={'center'}>
<MyIcon mr={1} name={'core/chat/feedback/badLight'} color={'yellow.500'} w={4} />
<MyIcon mr={1} name={'core/chat/feedback/badLight'} color={'myGray.400'} w={4} />
{item.userBadFeedbackCount}
</Flex>
)}
......@@ -569,7 +572,13 @@ const LogTable = ({
key={item._id}
_hover={{ bg: 'myWhite.600' }}
cursor={'pointer'}
onClick={() => setDetailLogsId(item.chatId)}
onClick={() =>
setDetailLogData({
chatId: item.chatId,
feedbackUserName:
item.outLinkUid || item.sourceMember?.name || item.tmbId || undefined
})
}
>
<Td>
<HStack onClick={(e) => e.stopPropagation()}>
......@@ -626,12 +635,13 @@ const LogTable = ({
)}
</FloatingActionBar>
{!!detailLogsId && (
{!!detailLogData && (
<DetailLogsModal
appId={appId}
chatId={detailLogsId}
chatId={detailLogData.chatId}
feedbackUserName={detailLogData.feedbackUserName}
onClose={() => {
setDetailLogsId(undefined);
setDetailLogData(undefined);
getData(pageNum);
}}
/>
......
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { authChatCrud } from '@/service/support/permission/auth/chat';
import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema';
......@@ -8,14 +8,13 @@ import {
GetFeedbackRecordIdsResponseSchema,
type GetFeedbackRecordIdsResponseType
} from '@fastgpt/global/openapi/core/chat/feedback/api';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
async function handler(
req: ApiRequestProps,
_res: ApiResponseType<any>
): Promise<GetFeedbackRecordIdsResponseType> {
const { appId, chatId, feedbackType, unreadOnly } = GetFeedbackRecordIdsBodySchema.parse(
req.body
);
async function handler(req: ApiRequestProps): Promise<GetFeedbackRecordIdsResponseType> {
const { appId, chatId, feedbackType, unreadOnly } = parseApiInput({
req,
bodySchema: GetFeedbackRecordIdsBodySchema
}).body;
if (!appId || !chatId) {
return {
......
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { authChatCrud } from '@/service/support/permission/auth/chat';
import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema';
......@@ -10,12 +10,13 @@ import {
} from '@fastgpt/global/openapi/core/chat/feedback/api';
import { updateChatFeedbackCount } from '@fastgpt/service/core/chat/controller';
import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
async function handler(
req: ApiRequestProps,
_res: ApiResponseType<any>
): Promise<UpdateFeedbackReadStatusResponseType> {
const { appId, chatId, dataId, isRead } = UpdateFeedbackReadStatusBodySchema.parse(req.body);
async function handler(req: ApiRequestProps): Promise<UpdateFeedbackReadStatusResponseType> {
const { appId, chatId, dataId, isRead } = parseApiInput({
req,
bodySchema: UpdateFeedbackReadStatusBodySchema
}).body;
await authChatCrud({
req,
......
......@@ -26,7 +26,7 @@ async function handler(req: ApiRequestProps): Promise<UpdateUserFeedbackResponse
...req.body
});
const chatItem = await MongoChatItem.findOne({ appId, chatId, dataId });
const chatItem = await MongoChatItem.findOne({ appId, chatId, dataId, obj: ChatRoleEnum.AI });
if (!chatItem) {
return Promise.reject('Chat item not found');
}
......@@ -34,7 +34,7 @@ async function handler(req: ApiRequestProps): Promise<UpdateUserFeedbackResponse
await mongoSessionRun(async (session) => {
// Update ChatItem feedback
await MongoChatItem.updateOne(
{ appId, chatId, dataId },
{ appId, chatId, dataId, obj: ChatRoleEnum.AI },
{
$unset: {
...(userBadFeedback === undefined && { userBadFeedback: '' }),
......
......@@ -125,18 +125,19 @@ describe('getFeedbackRecordIds api test', () => {
});
it('should return all good feedback records', async () => {
const res = await Call<GetFeedbackRecordIdsBodyType, {}, GetFeedbackRecordIdsResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
feedbackType: 'good',
unreadOnly: false
}
const res = await Call<
GetFeedbackRecordIdsBodyType,
Record<string, never>,
GetFeedbackRecordIdsResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
feedbackType: 'good',
unreadOnly: false
}
);
});
expect(res.code).toBe(200);
expect(res.data?.total).toBe(2);
......@@ -146,18 +147,19 @@ describe('getFeedbackRecordIds api test', () => {
});
it('should return only unread good feedback records', async () => {
const res = await Call<GetFeedbackRecordIdsBodyType, {}, GetFeedbackRecordIdsResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
feedbackType: 'good',
unreadOnly: true
}
const res = await Call<
GetFeedbackRecordIdsBodyType,
Record<string, never>,
GetFeedbackRecordIdsResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
feedbackType: 'good',
unreadOnly: true
}
);
});
expect(res.code).toBe(200);
expect(res.data?.total).toBe(1);
......@@ -166,18 +168,19 @@ describe('getFeedbackRecordIds api test', () => {
});
it('should return all bad feedback records', async () => {
const res = await Call<GetFeedbackRecordIdsBodyType, {}, GetFeedbackRecordIdsResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
feedbackType: 'bad',
unreadOnly: false
}
const res = await Call<
GetFeedbackRecordIdsBodyType,
Record<string, never>,
GetFeedbackRecordIdsResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
feedbackType: 'bad',
unreadOnly: false
}
);
});
expect(res.code).toBe(200);
expect(res.data?.total).toBe(2);
......@@ -187,18 +190,19 @@ describe('getFeedbackRecordIds api test', () => {
});
it('should return only unread bad feedback records', async () => {
const res = await Call<GetFeedbackRecordIdsBodyType, {}, GetFeedbackRecordIdsResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
feedbackType: 'bad',
unreadOnly: true
}
const res = await Call<
GetFeedbackRecordIdsBodyType,
Record<string, never>,
GetFeedbackRecordIdsResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
feedbackType: 'bad',
unreadOnly: true
}
);
});
expect(res.code).toBe(200);
expect(res.data?.total).toBe(1);
......@@ -207,18 +211,19 @@ describe('getFeedbackRecordIds api test', () => {
});
it('should return all feedback records with has_feedback type', async () => {
const res = await Call<GetFeedbackRecordIdsBodyType, {}, GetFeedbackRecordIdsResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
feedbackType: 'has_feedback',
unreadOnly: false
}
const res = await Call<
GetFeedbackRecordIdsBodyType,
Record<string, never>,
GetFeedbackRecordIdsResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
feedbackType: 'has_feedback',
unreadOnly: false
}
);
});
expect(res.code).toBe(200);
expect(res.data?.total).toBe(4);
......@@ -227,21 +232,23 @@ describe('getFeedbackRecordIds api test', () => {
expect(res.data?.dataIds).toContain('data-2');
expect(res.data?.dataIds).toContain('data-3');
expect(res.data?.dataIds).toContain('data-4');
expect(res.data?.dataIds).not.toContain('data-6');
});
it('should return only unread feedback records with has_feedback type', async () => {
const res = await Call<GetFeedbackRecordIdsBodyType, {}, GetFeedbackRecordIdsResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
feedbackType: 'has_feedback',
unreadOnly: true
}
const res = await Call<
GetFeedbackRecordIdsBodyType,
Record<string, never>,
GetFeedbackRecordIdsResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
feedbackType: 'has_feedback',
unreadOnly: true
}
);
});
expect(res.code).toBe(200);
expect(res.data?.total).toBe(2);
......@@ -251,18 +258,19 @@ describe('getFeedbackRecordIds api test', () => {
});
it('should return empty result when no appId or chatId', async () => {
const res = await Call<GetFeedbackRecordIdsBodyType, {}, GetFeedbackRecordIdsResponseType>(
handler,
{
auth: testUser,
body: {
appId: '',
chatId: '',
feedbackType: 'good',
unreadOnly: false
}
const res = await Call<
GetFeedbackRecordIdsBodyType,
Record<string, never>,
GetFeedbackRecordIdsResponseType
>(handler, {
auth: testUser,
body: {
appId: '',
chatId: '',
feedbackType: 'good',
unreadOnly: false
}
);
});
expect(res.code).toBe(200);
expect(res.data?.total).toBe(0);
......@@ -272,18 +280,19 @@ describe('getFeedbackRecordIds api test', () => {
it('should fail when user does not have permission', async () => {
const unauthorizedUser = await getUser(`unauthorized-user-get-ids-${Math.random()}`);
const res = await Call<GetFeedbackRecordIdsBodyType, {}, GetFeedbackRecordIdsResponseType>(
handler,
{
auth: unauthorizedUser,
body: {
appId,
chatId,
feedbackType: 'good',
unreadOnly: false
}
const res = await Call<
GetFeedbackRecordIdsBodyType,
Record<string, never>,
GetFeedbackRecordIdsResponseType
>(handler, {
auth: unauthorizedUser,
body: {
appId,
chatId,
feedbackType: 'good',
unreadOnly: false
}
);
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
......
......@@ -69,7 +69,7 @@ describe('updateFeedbackReadStatus api test', () => {
it('should mark feedback as read', async () => {
const res = await Call<
UpdateFeedbackReadStatusBodyType,
{},
Record<string, never>,
UpdateFeedbackReadStatusResponseType
>(handler, {
auth: testUser,
......@@ -101,7 +101,7 @@ describe('updateFeedbackReadStatus api test', () => {
const res = await Call<
UpdateFeedbackReadStatusBodyType,
{},
Record<string, never>,
UpdateFeedbackReadStatusResponseType
>(handler, {
auth: testUser,
......@@ -132,7 +132,7 @@ describe('updateFeedbackReadStatus api test', () => {
const res = await Call<
UpdateFeedbackReadStatusBodyType,
{},
Record<string, never>,
UpdateFeedbackReadStatusResponseType
>(handler, {
auth: unauthorizedUser,
......@@ -149,6 +149,82 @@ describe('updateFeedbackReadStatus api test', () => {
});
it('should only update AI role chat items', async () => {
const sharedDataId = getNanoid();
await MongoChatItem.create([
{
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
appId,
chatId,
dataId: sharedDataId,
obj: ChatRoleEnum.Human,
value: [
{
type: 'text',
text: {
content: 'Test question'
}
}
],
userBadFeedback: 'Human feedback should stay unchanged',
isFeedbackRead: false
},
{
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
appId,
chatId,
dataId: sharedDataId,
obj: ChatRoleEnum.AI,
value: [
{
type: 'text',
text: {
content: 'Test response'
}
}
],
isFeedbackRead: false
}
]);
const res = await Call<
UpdateFeedbackReadStatusBodyType,
Record<string, never>,
UpdateFeedbackReadStatusResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
dataId: sharedDataId,
isRead: true
}
});
expect(res.code).toBe(200);
const humanChatItem = await MongoChatItem.findOne({
appId,
chatId,
dataId: sharedDataId,
obj: ChatRoleEnum.Human
});
const aiChatItem = await MongoChatItem.findOne({
appId,
chatId,
dataId: sharedDataId,
obj: ChatRoleEnum.AI
});
expect(humanChatItem?.isFeedbackRead).toBe(false);
expect(aiChatItem?.isFeedbackRead).toBe(true);
});
it('should not update human chat items without feedback', async () => {
const humanDataId = getNanoid();
// Create a human message
......@@ -173,7 +249,7 @@ describe('updateFeedbackReadStatus api test', () => {
const res = await Call<
UpdateFeedbackReadStatusBodyType,
{},
Record<string, never>,
UpdateFeedbackReadStatusResponseType
>(handler, {
auth: testUser,
......@@ -200,7 +276,7 @@ describe('updateFeedbackReadStatus api test', () => {
it('should handle non-existent dataId gracefully', async () => {
const res = await Call<
UpdateFeedbackReadStatusBodyType,
{},
Record<string, never>,
UpdateFeedbackReadStatusResponseType
>(handler, {
auth: testUser,
......
......@@ -79,18 +79,19 @@ describe('updateUserFeedback api test', () => {
});
it('should add good feedback', async () => {
const res = await Call<UpdateUserFeedbackBodyType, {}, UpdateUserFeedbackResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
dataId,
userGoodFeedback: 'Great answer!'
}
const res = await Call<
UpdateUserFeedbackBodyType,
Record<string, never>,
UpdateUserFeedbackResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
dataId,
userGoodFeedback: 'Great answer!'
}
);
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
......@@ -116,18 +117,19 @@ describe('updateUserFeedback api test', () => {
});
it('should add bad feedback', async () => {
const res = await Call<UpdateUserFeedbackBodyType, {}, UpdateUserFeedbackResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
dataId,
userBadFeedback: 'Not helpful'
}
const res = await Call<
UpdateUserFeedbackBodyType,
Record<string, never>,
UpdateUserFeedbackResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
dataId,
userBadFeedback: 'Not helpful'
}
);
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
......@@ -160,18 +162,19 @@ describe('updateUserFeedback api test', () => {
{ goodFeedbackCount: 1 }
);
const res = await Call<UpdateUserFeedbackBodyType, {}, UpdateUserFeedbackResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
dataId,
userGoodFeedback: undefined
}
const res = await Call<
UpdateUserFeedbackBodyType,
Record<string, never>,
UpdateUserFeedbackResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
dataId,
userGoodFeedback: undefined
}
);
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
......@@ -203,18 +206,19 @@ describe('updateUserFeedback api test', () => {
{ badFeedbackCount: 1 }
);
const res = await Call<UpdateUserFeedbackBodyType, {}, UpdateUserFeedbackResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
dataId,
userBadFeedback: undefined
}
const res = await Call<
UpdateUserFeedbackBodyType,
Record<string, never>,
UpdateUserFeedbackResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
dataId,
userBadFeedback: undefined
}
);
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
......@@ -246,18 +250,19 @@ describe('updateUserFeedback api test', () => {
{ goodFeedbackCount: 1 }
);
const res = await Call<UpdateUserFeedbackBodyType, {}, UpdateUserFeedbackResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
dataId,
userGoodFeedback: 'Excellent!'
}
const res = await Call<
UpdateUserFeedbackBodyType,
Record<string, never>,
UpdateUserFeedbackResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
dataId,
userGoodFeedback: 'Excellent!'
}
);
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
......@@ -281,6 +286,52 @@ describe('updateUserFeedback api test', () => {
expect(chatLog?.goodFeedbackCount).toBe(1);
});
it('should update AI feedback when human and AI share the same dataId', async () => {
await MongoChatItem.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
appId,
chatId,
dataId,
obj: ChatRoleEnum.Human,
value: [{ type: 'text', text: { content: 'Test question' } }]
});
const res = await Call<
UpdateUserFeedbackBodyType,
Record<string, never>,
UpdateUserFeedbackResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
dataId,
userBadFeedback: 'Not helpful'
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
const humanChatItem = await MongoChatItem.findOne({
appId,
chatId,
dataId,
obj: ChatRoleEnum.Human
});
const aiChatItem = await MongoChatItem.findOne({
appId,
chatId,
dataId,
obj: ChatRoleEnum.AI
});
expect(humanChatItem?.userBadFeedback).toBeUndefined();
expect(aiChatItem?.userBadFeedback).toBe('Not helpful');
});
it('should switch from good to bad feedback', async () => {
// First add good feedback
await MongoChatItem.updateOne({ appId, chatId, dataId }, { userGoodFeedback: 'Good' });
......@@ -290,19 +341,20 @@ describe('updateUserFeedback api test', () => {
);
// Remove good and add bad feedback
const res = await Call<UpdateUserFeedbackBodyType, {}, UpdateUserFeedbackResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
dataId,
userGoodFeedback: undefined,
userBadFeedback: 'Actually not good'
}
const res = await Call<
UpdateUserFeedbackBodyType,
Record<string, never>,
UpdateUserFeedbackResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
dataId,
userGoodFeedback: undefined,
userBadFeedback: 'Actually not good'
}
);
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
......@@ -329,54 +381,57 @@ describe('updateUserFeedback api test', () => {
});
it('should fail when chatId is empty', async () => {
const res = await Call<UpdateUserFeedbackBodyType, {}, UpdateUserFeedbackResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId: '',
dataId,
userGoodFeedback: 'Great!'
}
const res = await Call<
UpdateUserFeedbackBodyType,
Record<string, never>,
UpdateUserFeedbackResponseType
>(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<UpdateUserFeedbackBodyType, {}, UpdateUserFeedbackResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
dataId: '',
userGoodFeedback: 'Great!'
}
const res = await Call<
UpdateUserFeedbackBodyType,
Record<string, never>,
UpdateUserFeedbackResponseType
>(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<UpdateUserFeedbackBodyType, {}, UpdateUserFeedbackResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
dataId: 'non-existent-id',
userGoodFeedback: 'Great!'
}
const res = await Call<
UpdateUserFeedbackBodyType,
Record<string, never>,
UpdateUserFeedbackResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
dataId: 'non-existent-id',
userGoodFeedback: 'Great!'
}
);
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
......@@ -385,18 +440,19 @@ describe('updateUserFeedback api test', () => {
it('should fail when user does not have permission', async () => {
const unauthorizedUser = await getUser(`unauthorized-user-feedback-${Math.random()}`);
const res = await Call<UpdateUserFeedbackBodyType, {}, UpdateUserFeedbackResponseType>(
handler,
{
auth: unauthorizedUser,
body: {
appId,
chatId,
dataId,
userGoodFeedback: 'Great!'
}
const res = await Call<
UpdateUserFeedbackBodyType,
Record<string, never>,
UpdateUserFeedbackResponseType
>(handler, {
auth: unauthorizedUser,
body: {
appId,
chatId,
dataId,
userGoodFeedback: 'Great!'
}
);
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment