Commit 4dbfe20c by Xianquan Committed by GitHub

feat(chat): improve chat feedback interactions (#7160)

* feat(chat): improve like feedback animation

* feat(chat): refine agent plan choice selection

* fix(chat): correct like feedback click typing

* fix(chat): narrow like feedback icon props

* fix(chat): keep choice toggle below options

* fix(chat): tighten choice toggle height

* fix(chat): render choice toggle as compact text button

* fix(chat): remove choice toggle wrapper spacing

* fix(chat): prevent choice highlight clipping

* fix(chat): add choice toggle hover background

* fix(chat): smooth choice collapse layout

* fix(chat): restore choice highlight shadow

* fix(chat): adjust choice width and toggle spacing

* fix(chat): avoid duplicate choice toggle spacing

* fix(chat): apply action hover lift consistently
parent a4c5216c
...@@ -273,6 +273,9 @@ ...@@ -273,6 +273,9 @@
"tool_response_compress": "Tool response compression", "tool_response_compress": "Tool response compression",
"unsupported_file_type": "Unsupported file types", "unsupported_file_type": "Unsupported file types",
"variable_invisable_in_share": "External variables are not visible in login-free links", "variable_invisable_in_share": "External variables are not visible in login-free links",
"interactive.user_select.collapse_options": "Collapse options",
"interactive.user_select.expand_options": "Expand options",
"interactive.user_select.selected": "Selected: {{answer}}",
"view_all_citations": "View all", "view_all_citations": "View all",
"view_citations": "View References" "view_citations": "View References"
} }
...@@ -273,6 +273,9 @@ ...@@ -273,6 +273,9 @@
"tool_response_compress": "工具响应压缩", "tool_response_compress": "工具响应压缩",
"unsupported_file_type": "不支持的文件类型", "unsupported_file_type": "不支持的文件类型",
"variable_invisable_in_share": "外部变量在免登录链接中不可见", "variable_invisable_in_share": "外部变量在免登录链接中不可见",
"interactive.user_select.collapse_options": "收起选项",
"interactive.user_select.expand_options": "展开选项",
"interactive.user_select.selected": "已选择:{{answer}}",
"view_all_citations": "查看全部", "view_all_citations": "查看全部",
"view_citations": "查看引用" "view_citations": "查看引用"
} }
...@@ -269,6 +269,9 @@ ...@@ -269,6 +269,9 @@
"tool_response_compress": "工具回應壓縮", "tool_response_compress": "工具回應壓縮",
"unsupported_file_type": "不支援的檔案類型", "unsupported_file_type": "不支援的檔案類型",
"variable_invisable_in_share": "外部變量在免登錄鏈接中不可見", "variable_invisable_in_share": "外部變量在免登錄鏈接中不可見",
"interactive.user_select.collapse_options": "收起選項",
"interactive.user_select.expand_options": "展開選項",
"interactive.user_select.selected": "已選擇:{{answer}}",
"view_all_citations": "查看全部", "view_all_citations": "查看全部",
"view_citations": "檢視引用" "view_citations": "檢視引用"
} }
...@@ -13,6 +13,7 @@ import MyImage from '@fastgpt/web/components/common/Image/MyImage'; ...@@ -13,6 +13,7 @@ import MyImage from '@fastgpt/web/components/common/Image/MyImage';
import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext'; import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext';
import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { eventBus, EventNameEnum } from '@/web/common/utils/eventbus'; import { eventBus, EventNameEnum } from '@/web/common/utils/eventbus';
import LikeFeedbackButton from './LikeFeedbackButton';
export type ChatControllerProps = { export type ChatControllerProps = {
isLastChild: boolean; isLastChild: boolean;
...@@ -23,6 +24,7 @@ export type ChatControllerProps = { ...@@ -23,6 +24,7 @@ export type ChatControllerProps = {
onMark?: () => void; onMark?: () => void;
onAddUserLike?: () => void; onAddUserLike?: () => void;
onAddUserDislike?: () => void; onAddUserDislike?: () => void;
likeFeedbackEffectTrigger?: number;
onToggleFeedbackReadStatus?: () => void; onToggleFeedbackReadStatus?: () => void;
showFeedbackContent?: boolean; showFeedbackContent?: boolean;
onToggleFeedbackContent?: () => void; onToggleFeedbackContent?: () => void;
...@@ -47,7 +49,8 @@ const footerIconStyle = { ...@@ -47,7 +49,8 @@ const footerIconStyle = {
cursor: 'pointer', cursor: 'pointer',
p: '4px', p: '4px',
color: 'myGray.400', color: 'myGray.400',
_hover: { color: 'primary.600' } transition: 'color 180ms ease, transform 180ms ease, filter 180ms ease',
_hover: { color: 'primary.600', transform: 'translateY(-1px)' }
}; };
const ChatController = ({ const ChatController = ({
...@@ -58,6 +61,7 @@ const ChatController = ({ ...@@ -58,6 +61,7 @@ const ChatController = ({
onDelete, onDelete,
onAddUserDislike, onAddUserDislike,
onAddUserLike, onAddUserLike,
likeFeedbackEffectTrigger,
onToggleFeedbackReadStatus, onToggleFeedbackReadStatus,
showFeedbackContent, showFeedbackContent,
onToggleFeedbackContent, onToggleFeedbackContent,
...@@ -86,6 +90,10 @@ const ChatController = ({ ...@@ -86,6 +90,10 @@ const ChatController = ({
<MyTooltip label={label}>{children}</MyTooltip> <MyTooltip label={label}>{children}</MyTooltip>
); );
const iconStyle = isFooter ? footerIconStyle : controlIconStyle; const iconStyle = isFooter ? footerIconStyle : controlIconStyle;
const getIconHoverStyle = (color: string) => ({
color,
...(isFooter ? { transform: 'translateY(-1px)' } : {})
});
const activeFeedbackStyle = isFooter const activeFeedbackStyle = isFooter
? { ? {
color: 'primary.600' color: 'primary.600'
...@@ -138,7 +146,7 @@ const ChatController = ({ ...@@ -138,7 +146,7 @@ const ChatController = ({
{...iconStyle} {...iconStyle}
name={'copy'} name={'copy'}
borderLeftRadius={isFooter ? undefined : 'sm'} borderLeftRadius={isFooter ? undefined : 'sm'}
_hover={{ color: 'primary.600' }} _hover={getIconHoverStyle('primary.600')}
onClick={() => copyData(chatText)} onClick={() => copyData(chatText)}
/> />
)} )}
...@@ -150,7 +158,7 @@ const ChatController = ({ ...@@ -150,7 +158,7 @@ const ChatController = ({
<MyIcon <MyIcon
{...iconStyle} {...iconStyle}
name={'common/retryLight'} name={'common/retryLight'}
_hover={{ color: isFooter ? 'primary.600' : 'green.500' }} _hover={getIconHoverStyle(isFooter ? 'primary.600' : 'green.500')}
onClick={onRetry} onClick={onRetry}
/> />
)} )}
...@@ -159,7 +167,7 @@ const ChatController = ({ ...@@ -159,7 +167,7 @@ const ChatController = ({
<MyIcon <MyIcon
{...iconStyle} {...iconStyle}
name={'delete'} name={'delete'}
_hover={{ color: isFooter ? 'primary.600' : 'red.600' }} _hover={getIconHoverStyle(isFooter ? 'primary.600' : 'red.600')}
onClick={onDelete} onClick={onDelete}
/> />
)} )}
...@@ -205,7 +213,7 @@ const ChatController = ({ ...@@ -205,7 +213,7 @@ const ChatController = ({
fill: 'currentColor' fill: 'currentColor'
} }
}} }}
_hover={{ color: isFooter ? 'primary.600' : '#E74694' }} _hover={getIconHoverStyle(isFooter ? 'primary.600' : '#E74694')}
onClick={async () => { onClick={async () => {
setAudioPlayingChatId(chat.dataId); setAudioPlayingChatId(chat.dataId);
const response = await playAudioByText({ const response = await playAudioByText({
...@@ -234,7 +242,7 @@ const ChatController = ({ ...@@ -234,7 +242,7 @@ const ChatController = ({
<MyIcon <MyIcon
{...iconStyle} {...iconStyle}
name={'core/app/markLight'} name={'core/app/markLight'}
_hover={{ color: isFooter ? 'primary.600' : '#67c13b' }} _hover={getIconHoverStyle(isFooter ? 'primary.600' : '#67c13b')}
onClick={onMark} onClick={onMark}
/> />
)} )}
...@@ -297,20 +305,27 @@ const ChatController = ({ ...@@ -297,20 +305,27 @@ const ChatController = ({
<> <>
{!!onAddUserLike && ( {!!onAddUserLike && (
<MyTooltip label={t('chat:feedback_helpful')}> <MyTooltip label={t('chat:feedback_helpful')}>
{isFooter ? (
<LikeFeedbackButton
{...iconStyle}
isActive={!!chat.userGoodFeedback}
effectTrigger={likeFeedbackEffectTrigger}
onClick={onAddUserLike}
/>
) : (
<MyIcon <MyIcon
{...iconStyle} {...iconStyle}
{...(!!chat.userGoodFeedback {...(!!chat.userGoodFeedback
? activeFeedbackStyle ? activeFeedbackStyle
: { : {
_hover: { color: 'primary.600' } _hover: getIconHoverStyle('primary.600')
})} })}
borderRight={isFooter ? undefined : !onAddUserDislike ? 'none' : 'base'} borderRight={!onAddUserDislike ? 'none' : 'base'}
borderRightRadius={ borderRightRadius={!onAddUserDislike ? 'sm' : 'none'}
isFooter ? undefined : !onAddUserDislike ? 'sm' : 'none'
}
name={'core/chat/feedback/goodLight'} name={'core/chat/feedback/goodLight'}
onClick={onAddUserLike} onClick={onAddUserLike}
/> />
)}
</MyTooltip> </MyTooltip>
)} )}
{!!onAddUserDislike && ( {!!onAddUserDislike && (
...@@ -320,7 +335,7 @@ const ChatController = ({ ...@@ -320,7 +335,7 @@ const ChatController = ({
{...(!!chat.userBadFeedback {...(!!chat.userBadFeedback
? activeBadFeedbackStyle ? activeBadFeedbackStyle
: { : {
_hover: { color: 'primary.600' } _hover: getIconHoverStyle('primary.600')
})} })}
borderRight={isFooter ? undefined : 'none'} borderRight={isFooter ? undefined : 'none'}
borderRightRadius={isFooter ? undefined : 'sm'} borderRightRadius={isFooter ? undefined : 'sm'}
......
...@@ -38,6 +38,10 @@ export type ChatRecordsListProps = { ...@@ -38,6 +38,10 @@ export type ChatRecordsListProps = {
onMark: (chat: ChatSiteItemType, q?: string) => (() => void) | undefined; onMark: (chat: ChatSiteItemType, q?: string) => (() => void) | undefined;
onAddUserLike: (chat: ChatSiteItemType) => (() => void) | undefined; onAddUserLike: (chat: ChatSiteItemType) => (() => void) | undefined;
onAddUserDislike: (chat: ChatSiteItemType) => (() => void) | undefined; onAddUserDislike: (chat: ChatSiteItemType) => (() => void) | undefined;
likeFeedbackEffect?: {
dataId: string;
trigger: number;
};
onCloseCustomFeedback: ( onCloseCustomFeedback: (
chat: ChatSiteItemType, chat: ChatSiteItemType,
index: number index: number
...@@ -71,6 +75,7 @@ const ChatRecordsList = ({ ...@@ -71,6 +75,7 @@ const ChatRecordsList = ({
onMark, onMark,
onAddUserLike, onAddUserLike,
onAddUserDislike, onAddUserDislike,
likeFeedbackEffect,
onCloseCustomFeedback, onCloseCustomFeedback,
onToggleFeedbackReadStatus onToggleFeedbackReadStatus
}: ChatRecordsListProps) => { }: ChatRecordsListProps) => {
...@@ -213,6 +218,10 @@ const ChatRecordsList = ({ ...@@ -213,6 +218,10 @@ const ChatRecordsList = ({
), ),
onAddUserLike: onAddUserLike(item), onAddUserLike: onAddUserLike(item),
onAddUserDislike: onAddUserDislike(item), onAddUserDislike: onAddUserDislike(item),
likeFeedbackEffectTrigger:
likeFeedbackEffect?.dataId === item.dataId
? likeFeedbackEffect.trigger
: undefined,
onToggleFeedbackReadStatus: onToggleFeedbackReadStatus(item) onToggleFeedbackReadStatus: onToggleFeedbackReadStatus(item)
}} }}
> >
......
import React, { useCallback, useEffect, useRef } 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';
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
gravity: number;
life: number;
size: number;
rotation: number;
vr: number;
color: string;
};
type LikeFeedbackButtonProps = Pick<BoxProps, 'cursor' | 'onClick'> &
Pick<IconProps, 'w' | 'h' | 'boxSize' | 'p'> & {
isActive: boolean;
effectTrigger?: number;
};
const blueColors = ['#3370ff', '#4f82ff', '#7ca3ff'];
const accentColor = '#efdefd';
const getParticles = (x: number, y: number): Particle[] =>
Array.from({ length: 10 }, (_, index) => {
const spread = -Math.PI * 0.72 + Math.PI * 0.44 * (index / 9);
const speed = 2 + Math.random() * 2.4;
return {
x,
y,
vx: Math.cos(spread) * speed,
vy: Math.sin(spread) * speed,
gravity: 0.09 + Math.random() * 0.03,
life: 26 + Math.random() * 8,
size: 3 + Math.random() * 3,
rotation: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 0.14,
color:
Math.random() < 0.28
? accentColor
: blueColors[Math.floor(Math.random() * blueColors.length)]
};
});
/**
* 渲染点赞按钮的局部成功反馈。
*
* hover、图标弹跳和 canvas 粒子参数都对齐 prototype,只有新的 effectTrigger 会播放撒花。
*/
const LikeFeedbackButton = ({
isActive,
effectTrigger,
cursor,
onClick,
w,
h,
boxSize,
p
}: 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 resizeCanvas = useCallback(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) return;
const ratio = window.devicePixelRatio || 1;
canvas.width = window.innerWidth * ratio;
canvas.height = window.innerHeight * ratio;
canvas.style.width = `${window.innerWidth}px`;
canvas.style.height = `${window.innerHeight}px`;
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
}, []);
const removeCanvas = useCallback(() => {
canvasRef.current?.remove();
canvasRef.current = null;
}, []);
const stopAnimation = useCallback(() => {
if (rafRef.current) {
window.cancelAnimationFrame(rafRef.current);
rafRef.current = undefined;
}
particlesRef.current = [];
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
ctx?.clearRect(0, 0, window.innerWidth, window.innerHeight);
removeCanvas();
}, [removeCanvas]);
const createCanvas = useCallback(() => {
if (typeof document === 'undefined') return null;
removeCanvas();
const canvas = document.createElement('canvas');
canvas.className = styles.likeFeedbackCanvas;
document.body.appendChild(canvas);
canvasRef.current = canvas;
return canvas;
}, [removeCanvas]);
useEffect(() => {
if (!effectTrigger) {
stopAnimation();
return;
}
if (playedTriggerRef.current === effectTrigger) return;
playedTriggerRef.current = effectTrigger;
stopAnimation();
const button = buttonRef.current;
if (!button) return;
const canvas = createCanvas();
if (!canvas) return;
resizeCanvas();
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) {
stopAnimation();
return;
}
const rect = button.getBoundingClientRect();
particlesRef.current = getParticles(rect.left + rect.width / 2, rect.top + rect.height / 2 - 2);
const animate = () => {
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
particlesRef.current = particlesRef.current.filter((particle) => particle.life > 0);
for (const particle of particlesRef.current) {
particle.x += particle.vx;
particle.y += particle.vy;
particle.vy += particle.gravity;
particle.life -= 1;
particle.rotation += particle.vr;
ctx.save();
ctx.translate(particle.x, particle.y);
ctx.rotate(particle.rotation);
ctx.globalAlpha = Math.max(particle.life / 45, 0);
ctx.fillStyle = particle.color;
ctx.fillRect(-particle.size / 2, -particle.size / 3, particle.size, particle.size * 0.66);
ctx.restore();
}
if (particlesRef.current.length > 0) {
rafRef.current = window.requestAnimationFrame(animate);
} else {
rafRef.current = undefined;
removeCanvas();
}
};
animate();
window.addEventListener('resize', resizeCanvas);
return () => {
window.removeEventListener('resize', resizeCanvas);
stopAnimation();
};
}, [createCanvas, effectTrigger, removeCanvas, resizeCanvas, stopAnimation]);
useEffect(() => stopAnimation, [stopAnimation]);
return (
<Box
as="span"
ref={buttonRef}
display="inline-flex"
position="relative"
w="24px"
h="24px"
alignItems="center"
justifyContent="center"
overflow="visible"
cursor={cursor ?? 'pointer'}
color={isActive ? 'primary.600' : 'myGray.400'}
filter={isActive ? 'drop-shadow(0 6px 12px rgba(51, 112, 255, 0.18))' : undefined}
transition="color 180ms ease, transform 180ms ease, filter 180ms ease"
_hover={{
color: 'primary.600',
transform: 'translateY(-1px)'
}}
onClick={onClick}
>
<MyIcon
key={effectTrigger || 'idle'}
w={w}
h={h}
boxSize={boxSize}
p={p}
cursor={undefined}
color="currentColor"
_hover={undefined}
className={effectTrigger ? styles.likeFeedbackIconPop : undefined}
name="core/chat/feedback/goodLight"
/>
</Box>
);
};
export default React.memo(LikeFeedbackButton);
import { useState, type ChangeEvent } from 'react'; import { useEffect, useRef, useState, type ChangeEvent } from 'react';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
...@@ -24,6 +24,10 @@ type UseChatFeedbackActionsProps = { ...@@ -24,6 +24,10 @@ type UseChatFeedbackActionsProps = {
}; };
type AdminMarkState = AdminMarkType & { dataId: string }; type AdminMarkState = AdminMarkType & { dataId: string };
type LikeFeedbackEffectState = {
dataId: string;
trigger: number;
};
/** /**
* 管理 ChatBox 中反馈、标注和反馈已读状态相关动作。 * 管理 ChatBox 中反馈、标注和反馈已读状态相关动作。
...@@ -47,12 +51,32 @@ export const useChatFeedbackActions = ({ ...@@ -47,12 +51,32 @@ export const useChatFeedbackActions = ({
}: UseChatFeedbackActionsProps) => { }: UseChatFeedbackActionsProps) => {
const [feedbackId, setFeedbackId] = useState<string>(); const [feedbackId, setFeedbackId] = useState<string>();
const [adminMarkData, setAdminMarkData] = useState<AdminMarkState>(); const [adminMarkData, setAdminMarkData] = useState<AdminMarkState>();
const [likeFeedbackEffect, setLikeFeedbackEffect] = useState<LikeFeedbackEffectState>();
const likeFeedbackEffectTrigger = useRef(0);
const likeFeedbackEffectTimer = useRef<ReturnType<typeof setTimeout>>();
const setChatRecords = useContextSelector(ChatRecordContext, (v) => v.setChatRecords); const setChatRecords = useContextSelector(ChatRecordContext, (v) => v.setChatRecords);
const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId); const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId);
const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId); const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId);
const outLinkAuthData = useContextSelector(WorkflowRuntimeContext, (v) => v.outLinkAuthData); const outLinkAuthData = useContextSelector(WorkflowRuntimeContext, (v) => v.outLinkAuthData);
useEffect(() => {
return () => {
if (likeFeedbackEffectTimer.current) {
clearTimeout(likeFeedbackEffectTimer.current);
}
};
}, []);
// 取消赞或进入点踩流程时立即清理一次性视觉反馈,避免非点赞动作残留撒花。
const clearLikeFeedbackEffect = useMemoizedFn(() => {
if (likeFeedbackEffectTimer.current) {
clearTimeout(likeFeedbackEffectTimer.current);
likeFeedbackEffectTimer.current = undefined;
}
setLikeFeedbackEffect(undefined);
});
/** /**
* 生成 admin mark 入口回调。 * 生成 admin mark 入口回调。
* *
...@@ -109,6 +133,21 @@ export const useChatFeedbackActions = ({ ...@@ -109,6 +133,21 @@ export const useChatFeedbackActions = ({
: chatItem : chatItem
) )
); );
if (!isGoodFeedback) {
const trigger = ++likeFeedbackEffectTrigger.current;
if (likeFeedbackEffectTimer.current) {
clearTimeout(likeFeedbackEffectTimer.current);
}
setLikeFeedbackEffect({
dataId: chat.dataId,
trigger
});
likeFeedbackEffectTimer.current = setTimeout(() => {
setLikeFeedbackEffect((state) => (state?.trigger === trigger ? undefined : state));
}, 800);
} else {
clearLikeFeedbackEffect();
}
try { try {
updateChatUserFeedback({ updateChatUserFeedback({
...@@ -134,6 +173,7 @@ export const useChatFeedbackActions = ({ ...@@ -134,6 +173,7 @@ export const useChatFeedbackActions = ({
if (chat.userBadFeedback) { if (chat.userBadFeedback) {
return () => { return () => {
clearLikeFeedbackEffect();
if (!chat.dataId || !chatId || !appId) return; if (!chat.dataId || !chatId || !appId) return;
setChatRecords((state) => setChatRecords((state) =>
state.map((chatItem) => state.map((chatItem) =>
...@@ -152,7 +192,10 @@ export const useChatFeedbackActions = ({ ...@@ -152,7 +192,10 @@ export const useChatFeedbackActions = ({
}; };
} }
return () => setFeedbackId(chat.dataId); return () => {
clearLikeFeedbackEffect();
setFeedbackId(chat.dataId);
};
}); });
/** /**
...@@ -230,6 +273,7 @@ export const useChatFeedbackActions = ({ ...@@ -230,6 +273,7 @@ export const useChatFeedbackActions = ({
* 并关闭 modal,避免等待下一次 records reload 才看到反馈状态。 * 并关闭 modal,避免等待下一次 records reload 才看到反馈状态。
*/ */
const onFeedbackSuccess = useMemoizedFn((content: string) => { const onFeedbackSuccess = useMemoizedFn((content: string) => {
clearLikeFeedbackEffect();
setChatRecords((state) => setChatRecords((state) =>
state.map((item) => state.map((item) =>
item.dataId === feedbackId item.dataId === feedbackId
...@@ -273,6 +317,7 @@ export const useChatFeedbackActions = ({ ...@@ -273,6 +317,7 @@ export const useChatFeedbackActions = ({
setFeedbackId, setFeedbackId,
adminMarkData, adminMarkData,
setAdminMarkData, setAdminMarkData,
likeFeedbackEffect,
onMark, onMark,
onAddUserLike, onAddUserLike,
onAddUserDislike, onAddUserDislike,
......
.statusAnimation { .statusAnimation {
animation: statusBox 0.8s linear infinite alternate; animation: statusBox 0.8s linear infinite alternate;
} }
.likeFeedbackIconPop {
animation: likeFeedbackIconPop 320ms ease;
}
.likeFeedbackCanvas {
position: fixed;
inset: 0;
z-index: 40;
pointer-events: none;
}
@keyframes statusBox { @keyframes statusBox {
0% { 0% {
opacity: 1; opacity: 1;
...@@ -10,3 +22,17 @@ ...@@ -10,3 +22,17 @@
opacity: 0.11; opacity: 0.11;
} }
} }
@keyframes likeFeedbackIconPop {
0% {
transform: scale(1);
}
35% {
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
...@@ -340,6 +340,7 @@ const ChatBox = ({ ...@@ -340,6 +340,7 @@ const ChatBox = ({
setFeedbackId, setFeedbackId,
adminMarkData, adminMarkData,
setAdminMarkData, setAdminMarkData,
likeFeedbackEffect,
onMark, onMark,
onAddUserLike, onAddUserLike,
onAddUserDislike, onAddUserDislike,
...@@ -551,6 +552,7 @@ const ChatBox = ({ ...@@ -551,6 +552,7 @@ const ChatBox = ({
onMark, onMark,
onAddUserLike, onAddUserLike,
onAddUserDislike, onAddUserDislike,
likeFeedbackEffect,
onCloseCustomFeedback, onCloseCustomFeedback,
onToggleFeedbackReadStatus onToggleFeedbackReadStatus
}), }),
...@@ -568,6 +570,7 @@ const ChatBox = ({ ...@@ -568,6 +570,7 @@ const ChatBox = ({
onMark, onMark,
onAddUserLike, onAddUserLike,
onAddUserDislike, onAddUserDislike,
likeFeedbackEffect,
onCloseCustomFeedback, onCloseCustomFeedback,
onToggleFeedbackReadStatus onToggleFeedbackReadStatus
] ]
......
import { Box, Button, Flex, Textarea } from '@chakra-ui/react'; import { Box, Button, Collapse, Flex, Textarea } from '@chakra-ui/react';
import type { AgentPlanAskQueryInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type'; import type { AgentPlanAskQueryInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import LeftRadio from '@fastgpt/web/components/common/Radio/LeftRadio'; import LeftRadio from '@fastgpt/web/components/common/Radio/LeftRadio';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { AGENT_PLAN_ASK_OTHER_OPTION_VALUE } from './constants'; import { AGENT_PLAN_ASK_OTHER_OPTION_VALUE } from './constants';
import { onSendPrompt } from './utils'; import { onSendPrompt } from './utils';
import {
ChoiceCollapseToggleButton,
SelectedAnswerText,
useInteractiveChoiceCollapse
} from '../Interactive/InteractiveChoiceCollapse';
const RenderAgentPlanAskInteractive = React.memo(function RenderAgentPlanAskInteractive({ const RenderAgentPlanAskInteractive = React.memo(function RenderAgentPlanAskInteractive({
interactive, interactive,
...@@ -28,6 +33,13 @@ const RenderAgentPlanAskInteractive = React.memo(function RenderAgentPlanAskInte ...@@ -28,6 +33,13 @@ const RenderAgentPlanAskInteractive = React.memo(function RenderAgentPlanAskInte
effectiveAnswer && normalizedOptions.includes(effectiveAnswer) ? effectiveAnswer : ''; effectiveAnswer && normalizedOptions.includes(effectiveAnswer) ? effectiveAnswer : '';
const answeredOther = const answeredOther =
effectiveAnswer && !normalizedOptions.includes(effectiveAnswer) ? effectiveAnswer : ''; effectiveAnswer && !normalizedOptions.includes(effectiveAnswer) ? effectiveAnswer : '';
const {
isOptionsExpanded,
selectedAnswerPlacement,
shouldShowOptions,
scheduleCollapse,
toggleOptionsExpanded
} = useInteractiveChoiceCollapse(effectiveAnswer);
const showOtherInput = !!answeredOther || isOtherSelected; const showOtherInput = !!answeredOther || isOtherSelected;
const radioValue = const radioValue =
answeredOther || isOtherSelected ? AGENT_PLAN_ASK_OTHER_OPTION_VALUE : selectedOption; answeredOther || isOtherSelected ? AGENT_PLAN_ASK_OTHER_OPTION_VALUE : selectedOption;
...@@ -37,8 +49,9 @@ const RenderAgentPlanAskInteractive = React.memo(function RenderAgentPlanAskInte ...@@ -37,8 +49,9 @@ const RenderAgentPlanAskInteractive = React.memo(function RenderAgentPlanAskInte
if (!value || isDisabled) return; if (!value || isDisabled) return;
setSubmittedAnswer(value); setSubmittedAnswer(value);
scheduleCollapse();
onSendPrompt(value); onSendPrompt(value);
}, [isDisabled, otherAnswer]); }, [isDisabled, otherAnswer, scheduleCollapse]);
const radioOptions = useMemo( const radioOptions = useMemo(
() => [ () => [
...normalizedOptions.map((option) => ({ ...normalizedOptions.map((option) => ({
...@@ -72,9 +85,24 @@ const RenderAgentPlanAskInteractive = React.memo(function RenderAgentPlanAskInte ...@@ -72,9 +85,24 @@ const RenderAgentPlanAskInteractive = React.memo(function RenderAgentPlanAskInte
</Box> </Box>
)} )}
{normalizedOptions.length > 0 && ( {normalizedOptions.length > 0 && (
<Flex flexDirection={'column'} gap={3}> <Box>
{selectedAnswerPlacement === 'above' && (
<Box mb={3}>
<SelectedAnswerText answer={effectiveAnswer} />
</Box>
)}
<Collapse
in={shouldShowOptions}
animateOpacity
transitionEnd={{
enter: { overflow: 'visible' },
exit: { overflow: 'hidden' }
}}
>
<Flex w={'360px'} maxW={'100%'} flexDirection={'column'} gap={3}>
<LeftRadio<string> <LeftRadio<string>
py={3} px={4}
py={4}
gridGap={2} gridGap={2}
align={'center'} align={'center'}
list={radioOptions} list={radioOptions}
...@@ -89,6 +117,7 @@ const RenderAgentPlanAskInteractive = React.memo(function RenderAgentPlanAskInte ...@@ -89,6 +117,7 @@ const RenderAgentPlanAskInteractive = React.memo(function RenderAgentPlanAskInte
} }
setIsOtherSelected(false); setIsOtherSelected(false);
setSubmittedAnswer(value); setSubmittedAnswer(value);
scheduleCollapse();
onSendPrompt(value); onSendPrompt(value);
}} }}
isDisabled={isDisabled} isDisabled={isDisabled}
...@@ -124,6 +153,19 @@ const RenderAgentPlanAskInteractive = React.memo(function RenderAgentPlanAskInte ...@@ -124,6 +153,19 @@ const RenderAgentPlanAskInteractive = React.memo(function RenderAgentPlanAskInte
</Flex> </Flex>
)} )}
</Flex> </Flex>
</Collapse>
{selectedAnswerPlacement === 'below' && (
<Box mt={3}>
<SelectedAnswerText answer={effectiveAnswer} />
</Box>
)}
<ChoiceCollapseToggleButton
answer={effectiveAnswer}
isOptionsExpanded={isOptionsExpanded}
onToggle={toggleOptionsExpanded}
mt={selectedAnswerPlacement === 'above' && !shouldShowOptions ? 0 : 3}
/>
</Box>
)} )}
</Flex> </Flex>
); );
......
import React from 'react';
import { Box, type BoxProps } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
type SelectedAnswerPlacement = 'above' | 'below';
/**
* 管理交互选项在提交答案后的展示状态。
* 选中后先保留选项 1 秒用于反馈,再折叠为答案摘要;刷新后如果已有答案则默认折叠。
*/
export const useInteractiveChoiceCollapse = (selectedAnswer?: string) => {
const [isOptionsExpanded, setIsOptionsExpanded] = React.useState(!selectedAnswer);
const [selectedAnswerPlacement, setSelectedAnswerPlacement] =
React.useState<SelectedAnswerPlacement>(selectedAnswer ? 'above' : 'below');
const collapseTimerRef = React.useRef<ReturnType<typeof setTimeout>>();
const placementTimerRef = React.useRef<ReturnType<typeof setTimeout>>();
const clearCollapseTimers = React.useCallback(() => {
if (collapseTimerRef.current) {
clearTimeout(collapseTimerRef.current);
collapseTimerRef.current = undefined;
}
if (placementTimerRef.current) {
clearTimeout(placementTimerRef.current);
placementTimerRef.current = undefined;
}
}, []);
React.useEffect(() => {
if (!selectedAnswer) {
clearCollapseTimers();
setIsOptionsExpanded(true);
setSelectedAnswerPlacement('below');
}
}, [clearCollapseTimers, selectedAnswer]);
React.useEffect(() => {
return () => {
clearCollapseTimers();
};
}, [clearCollapseTimers]);
const scheduleCollapse = React.useCallback(() => {
clearCollapseTimers();
setSelectedAnswerPlacement('below');
setIsOptionsExpanded(true);
collapseTimerRef.current = setTimeout(() => {
setIsOptionsExpanded(false);
collapseTimerRef.current = undefined;
placementTimerRef.current = setTimeout(() => {
setSelectedAnswerPlacement('above');
placementTimerRef.current = undefined;
}, 240);
}, 1000);
}, [clearCollapseTimers]);
const toggleOptionsExpanded = React.useCallback(() => {
clearCollapseTimers();
setSelectedAnswerPlacement('above');
setIsOptionsExpanded((state) => !state);
}, [clearCollapseTimers]);
return {
isOptionsExpanded,
selectedAnswerPlacement,
shouldShowOptions: !selectedAnswer || isOptionsExpanded,
scheduleCollapse,
toggleOptionsExpanded
};
};
export const SelectedAnswerText = React.memo(function SelectedAnswerText({
answer
}: {
answer?: string;
}) {
const { t } = useTranslation();
if (!answer) return null;
return (
<Box color={'myGray.500'} fontSize={'sm'} lineHeight={'20px'}>
{t('chat:interactive.user_select.selected', { answer })}
</Box>
);
});
export const ChoiceCollapseToggleButton = React.memo(function ChoiceCollapseToggleButton({
answer,
isOptionsExpanded,
onToggle,
mt = 3
}: {
answer?: string;
isOptionsExpanded: boolean;
onToggle: () => void;
mt?: BoxProps['mt'];
}) {
const { t } = useTranslation();
if (!answer) return null;
return (
<Box
as="button"
type="button"
display={'inline-flex'}
alignItems={'center'}
alignSelf={'flex-start'}
position={'relative'}
mt={mt}
mx={'8px'}
px={0}
h={'16px'}
minW={0}
border={0}
bg={'transparent'}
color={'primary.600'}
cursor={'pointer'}
fontSize={'11px'}
fontWeight={500}
lineHeight={'16px'}
_before={{
content: '""',
position: 'absolute',
inset: '-6px -8px',
borderRadius: '6px',
bg: 'transparent',
transition: 'background-color 150ms ease'
}}
_hover={{
_before: {
bg: 'myGray.100'
}
}}
onClick={onToggle}
>
<Box as="span" position={'relative'} zIndex={1}>
{isOptionsExpanded
? t('chat:interactive.user_select.collapse_options')
: t('chat:interactive.user_select.expand_options')}
</Box>
</Box>
);
});
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