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 @@
"tool_response_compress": "Tool response compression",
"unsupported_file_type": "Unsupported file types",
"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_citations": "View References"
}
......@@ -273,6 +273,9 @@
"tool_response_compress": "工具响应压缩",
"unsupported_file_type": "不支持的文件类型",
"variable_invisable_in_share": "外部变量在免登录链接中不可见",
"interactive.user_select.collapse_options": "收起选项",
"interactive.user_select.expand_options": "展开选项",
"interactive.user_select.selected": "已选择:{{answer}}",
"view_all_citations": "查看全部",
"view_citations": "查看引用"
}
......@@ -269,6 +269,9 @@
"tool_response_compress": "工具回應壓縮",
"unsupported_file_type": "不支援的檔案類型",
"variable_invisable_in_share": "外部變量在免登錄鏈接中不可見",
"interactive.user_select.collapse_options": "收起選項",
"interactive.user_select.expand_options": "展開選項",
"interactive.user_select.selected": "已選擇:{{answer}}",
"view_all_citations": "查看全部",
"view_citations": "檢視引用"
}
......@@ -13,6 +13,7 @@ import MyImage from '@fastgpt/web/components/common/Image/MyImage';
import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { eventBus, EventNameEnum } from '@/web/common/utils/eventbus';
import LikeFeedbackButton from './LikeFeedbackButton';
export type ChatControllerProps = {
isLastChild: boolean;
......@@ -23,6 +24,7 @@ export type ChatControllerProps = {
onMark?: () => void;
onAddUserLike?: () => void;
onAddUserDislike?: () => void;
likeFeedbackEffectTrigger?: number;
onToggleFeedbackReadStatus?: () => void;
showFeedbackContent?: boolean;
onToggleFeedbackContent?: () => void;
......@@ -47,7 +49,8 @@ const footerIconStyle = {
cursor: 'pointer',
p: '4px',
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 = ({
......@@ -58,6 +61,7 @@ const ChatController = ({
onDelete,
onAddUserDislike,
onAddUserLike,
likeFeedbackEffectTrigger,
onToggleFeedbackReadStatus,
showFeedbackContent,
onToggleFeedbackContent,
......@@ -86,6 +90,10 @@ const ChatController = ({
<MyTooltip label={label}>{children}</MyTooltip>
);
const iconStyle = isFooter ? footerIconStyle : controlIconStyle;
const getIconHoverStyle = (color: string) => ({
color,
...(isFooter ? { transform: 'translateY(-1px)' } : {})
});
const activeFeedbackStyle = isFooter
? {
color: 'primary.600'
......@@ -138,7 +146,7 @@ const ChatController = ({
{...iconStyle}
name={'copy'}
borderLeftRadius={isFooter ? undefined : 'sm'}
_hover={{ color: 'primary.600' }}
_hover={getIconHoverStyle('primary.600')}
onClick={() => copyData(chatText)}
/>
)}
......@@ -150,7 +158,7 @@ const ChatController = ({
<MyIcon
{...iconStyle}
name={'common/retryLight'}
_hover={{ color: isFooter ? 'primary.600' : 'green.500' }}
_hover={getIconHoverStyle(isFooter ? 'primary.600' : 'green.500')}
onClick={onRetry}
/>
)}
......@@ -159,7 +167,7 @@ const ChatController = ({
<MyIcon
{...iconStyle}
name={'delete'}
_hover={{ color: isFooter ? 'primary.600' : 'red.600' }}
_hover={getIconHoverStyle(isFooter ? 'primary.600' : 'red.600')}
onClick={onDelete}
/>
)}
......@@ -205,7 +213,7 @@ const ChatController = ({
fill: 'currentColor'
}
}}
_hover={{ color: isFooter ? 'primary.600' : '#E74694' }}
_hover={getIconHoverStyle(isFooter ? 'primary.600' : '#E74694')}
onClick={async () => {
setAudioPlayingChatId(chat.dataId);
const response = await playAudioByText({
......@@ -234,7 +242,7 @@ const ChatController = ({
<MyIcon
{...iconStyle}
name={'core/app/markLight'}
_hover={{ color: isFooter ? 'primary.600' : '#67c13b' }}
_hover={getIconHoverStyle(isFooter ? 'primary.600' : '#67c13b')}
onClick={onMark}
/>
)}
......@@ -297,20 +305,27 @@ const ChatController = ({
<>
{!!onAddUserLike && (
<MyTooltip label={t('chat:feedback_helpful')}>
<MyIcon
{...iconStyle}
{...(!!chat.userGoodFeedback
? activeFeedbackStyle
: {
_hover: { color: 'primary.600' }
})}
borderRight={isFooter ? undefined : !onAddUserDislike ? 'none' : 'base'}
borderRightRadius={
isFooter ? undefined : !onAddUserDislike ? 'sm' : 'none'
}
name={'core/chat/feedback/goodLight'}
onClick={onAddUserLike}
/>
{isFooter ? (
<LikeFeedbackButton
{...iconStyle}
isActive={!!chat.userGoodFeedback}
effectTrigger={likeFeedbackEffectTrigger}
onClick={onAddUserLike}
/>
) : (
<MyIcon
{...iconStyle}
{...(!!chat.userGoodFeedback
? activeFeedbackStyle
: {
_hover: getIconHoverStyle('primary.600')
})}
borderRight={!onAddUserDislike ? 'none' : 'base'}
borderRightRadius={!onAddUserDislike ? 'sm' : 'none'}
name={'core/chat/feedback/goodLight'}
onClick={onAddUserLike}
/>
)}
</MyTooltip>
)}
{!!onAddUserDislike && (
......@@ -320,7 +335,7 @@ const ChatController = ({
{...(!!chat.userBadFeedback
? activeBadFeedbackStyle
: {
_hover: { color: 'primary.600' }
_hover: getIconHoverStyle('primary.600')
})}
borderRight={isFooter ? undefined : 'none'}
borderRightRadius={isFooter ? undefined : 'sm'}
......
......@@ -38,6 +38,10 @@ export type ChatRecordsListProps = {
onMark: (chat: ChatSiteItemType, q?: string) => (() => void) | undefined;
onAddUserLike: (chat: ChatSiteItemType) => (() => void) | undefined;
onAddUserDislike: (chat: ChatSiteItemType) => (() => void) | undefined;
likeFeedbackEffect?: {
dataId: string;
trigger: number;
};
onCloseCustomFeedback: (
chat: ChatSiteItemType,
index: number
......@@ -71,6 +75,7 @@ const ChatRecordsList = ({
onMark,
onAddUserLike,
onAddUserDislike,
likeFeedbackEffect,
onCloseCustomFeedback,
onToggleFeedbackReadStatus
}: ChatRecordsListProps) => {
......@@ -213,6 +218,10 @@ const ChatRecordsList = ({
),
onAddUserLike: onAddUserLike(item),
onAddUserDislike: onAddUserDislike(item),
likeFeedbackEffectTrigger:
likeFeedbackEffect?.dataId === item.dataId
? likeFeedbackEffect.trigger
: undefined,
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 { useMemoizedFn } from 'ahooks';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
......@@ -24,6 +24,10 @@ type UseChatFeedbackActionsProps = {
};
type AdminMarkState = AdminMarkType & { dataId: string };
type LikeFeedbackEffectState = {
dataId: string;
trigger: number;
};
/**
* 管理 ChatBox 中反馈、标注和反馈已读状态相关动作。
......@@ -47,12 +51,32 @@ export const useChatFeedbackActions = ({
}: UseChatFeedbackActionsProps) => {
const [feedbackId, setFeedbackId] = useState<string>();
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 appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId);
const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId);
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 入口回调。
*
......@@ -109,6 +133,21 @@ export const useChatFeedbackActions = ({
: 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 {
updateChatUserFeedback({
......@@ -134,6 +173,7 @@ export const useChatFeedbackActions = ({
if (chat.userBadFeedback) {
return () => {
clearLikeFeedbackEffect();
if (!chat.dataId || !chatId || !appId) return;
setChatRecords((state) =>
state.map((chatItem) =>
......@@ -152,7 +192,10 @@ export const useChatFeedbackActions = ({
};
}
return () => setFeedbackId(chat.dataId);
return () => {
clearLikeFeedbackEffect();
setFeedbackId(chat.dataId);
};
});
/**
......@@ -230,6 +273,7 @@ export const useChatFeedbackActions = ({
* 并关闭 modal,避免等待下一次 records reload 才看到反馈状态。
*/
const onFeedbackSuccess = useMemoizedFn((content: string) => {
clearLikeFeedbackEffect();
setChatRecords((state) =>
state.map((item) =>
item.dataId === feedbackId
......@@ -273,6 +317,7 @@ export const useChatFeedbackActions = ({
setFeedbackId,
adminMarkData,
setAdminMarkData,
likeFeedbackEffect,
onMark,
onAddUserLike,
onAddUserDislike,
......
.statusAnimation {
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 {
0% {
opacity: 1;
......@@ -10,3 +22,17 @@
opacity: 0.11;
}
}
@keyframes likeFeedbackIconPop {
0% {
transform: scale(1);
}
35% {
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
......@@ -340,6 +340,7 @@ const ChatBox = ({
setFeedbackId,
adminMarkData,
setAdminMarkData,
likeFeedbackEffect,
onMark,
onAddUserLike,
onAddUserDislike,
......@@ -551,6 +552,7 @@ const ChatBox = ({
onMark,
onAddUserLike,
onAddUserDislike,
likeFeedbackEffect,
onCloseCustomFeedback,
onToggleFeedbackReadStatus
}),
......@@ -568,6 +570,7 @@ const ChatBox = ({
onMark,
onAddUserLike,
onAddUserDislike,
likeFeedbackEffect,
onCloseCustomFeedback,
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 LeftRadio from '@fastgpt/web/components/common/Radio/LeftRadio';
import { useTranslation } from 'next-i18next';
import React, { useCallback, useMemo } from 'react';
import { AGENT_PLAN_ASK_OTHER_OPTION_VALUE } from './constants';
import { onSendPrompt } from './utils';
import {
ChoiceCollapseToggleButton,
SelectedAnswerText,
useInteractiveChoiceCollapse
} from '../Interactive/InteractiveChoiceCollapse';
const RenderAgentPlanAskInteractive = React.memo(function RenderAgentPlanAskInteractive({
interactive,
......@@ -28,6 +33,13 @@ const RenderAgentPlanAskInteractive = React.memo(function RenderAgentPlanAskInte
effectiveAnswer && normalizedOptions.includes(effectiveAnswer) ? effectiveAnswer : '';
const answeredOther =
effectiveAnswer && !normalizedOptions.includes(effectiveAnswer) ? effectiveAnswer : '';
const {
isOptionsExpanded,
selectedAnswerPlacement,
shouldShowOptions,
scheduleCollapse,
toggleOptionsExpanded
} = useInteractiveChoiceCollapse(effectiveAnswer);
const showOtherInput = !!answeredOther || isOtherSelected;
const radioValue =
answeredOther || isOtherSelected ? AGENT_PLAN_ASK_OTHER_OPTION_VALUE : selectedOption;
......@@ -37,8 +49,9 @@ const RenderAgentPlanAskInteractive = React.memo(function RenderAgentPlanAskInte
if (!value || isDisabled) return;
setSubmittedAnswer(value);
scheduleCollapse();
onSendPrompt(value);
}, [isDisabled, otherAnswer]);
}, [isDisabled, otherAnswer, scheduleCollapse]);
const radioOptions = useMemo(
() => [
...normalizedOptions.map((option) => ({
......@@ -72,58 +85,87 @@ const RenderAgentPlanAskInteractive = React.memo(function RenderAgentPlanAskInte
</Box>
)}
{normalizedOptions.length > 0 && (
<Flex flexDirection={'column'} gap={3}>
<LeftRadio<string>
py={3}
gridGap={2}
align={'center'}
list={radioOptions}
value={radioValue}
defaultBg={'white'}
activeBg={'white'}
onChange={(value) => {
if (!value || isDisabled) return;
if (value === AGENT_PLAN_ASK_OTHER_OPTION_VALUE) {
setIsOtherSelected(true);
return;
}
setIsOtherSelected(false);
setSubmittedAnswer(value);
onSendPrompt(value);
<Box>
{selectedAnswerPlacement === 'above' && (
<Box mb={3}>
<SelectedAnswerText answer={effectiveAnswer} />
</Box>
)}
<Collapse
in={shouldShowOptions}
animateOpacity
transitionEnd={{
enter: { overflow: 'visible' },
exit: { overflow: 'hidden' }
}}
isDisabled={isDisabled}
/>
{showOtherInput && (
<Flex flexDirection={'column'} gap={2}>
<Textarea
autoFocus={!isDisabled}
bg={'white'}
rows={3}
resize={'vertical'}
value={currentOtherAnswer}
placeholder={t('common:Other')}
isDisabled={isDisabled}
onChange={(e) => setOtherAnswer(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
submitOtherAnswer();
>
<Flex w={'360px'} maxW={'100%'} flexDirection={'column'} gap={3}>
<LeftRadio<string>
px={4}
py={4}
gridGap={2}
align={'center'}
list={radioOptions}
value={radioValue}
defaultBg={'white'}
activeBg={'white'}
onChange={(value) => {
if (!value || isDisabled) return;
if (value === AGENT_PLAN_ASK_OTHER_OPTION_VALUE) {
setIsOtherSelected(true);
return;
}
setIsOtherSelected(false);
setSubmittedAnswer(value);
scheduleCollapse();
onSendPrompt(value);
}}
isDisabled={isDisabled}
/>
<Flex justifyContent={'flex-end'}>
{!isDisabled && (
<Button
flexShrink={0}
isDisabled={!otherAnswer.trim()}
onClick={submitOtherAnswer}
>
{t('common:Submit')}
</Button>
)}
</Flex>
{showOtherInput && (
<Flex flexDirection={'column'} gap={2}>
<Textarea
autoFocus={!isDisabled}
bg={'white'}
rows={3}
resize={'vertical'}
value={currentOtherAnswer}
placeholder={t('common:Other')}
isDisabled={isDisabled}
onChange={(e) => setOtherAnswer(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
submitOtherAnswer();
}
}}
/>
<Flex justifyContent={'flex-end'}>
{!isDisabled && (
<Button
flexShrink={0}
isDisabled={!otherAnswer.trim()}
onClick={submitOtherAnswer}
>
{t('common:Submit')}
</Button>
)}
</Flex>
</Flex>
)}
</Flex>
</Collapse>
{selectedAnswerPlacement === 'below' && (
<Box mt={3}>
<SelectedAnswerText answer={effectiveAnswer} />
</Box>
)}
</Flex>
<ChoiceCollapseToggleButton
answer={effectiveAnswer}
isOptionsExpanded={isOptionsExpanded}
onToggle={toggleOptionsExpanded}
mt={selectedAnswerPlacement === 'above' && !shouldShowOptions ? 0 : 3}
/>
</Box>
)}
</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