LibreChat/client/src/components/Chat/Messages/Feedback.tsx
2025-07-26 01:47:17 +02:00

341 lines
9.7 KiB
TypeScript

import React, { useState, useCallback, useMemo, useEffect } from 'react';
import * as Ariakit from '@ariakit/react';
import { TFeedback, TFeedbackTag, getTagsForRating } from 'librechat-data-provider';
import {
Button,
OGDialog,
OGDialogContent,
OGDialogTitle,
ThumbUpIcon,
ThumbDownIcon,
} from '@librechat/client';
import {
AlertCircle,
PenTool,
ImageOff,
Ban,
HelpCircle,
CheckCircle,
Lightbulb,
Search,
} from 'lucide-react';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface FeedbackProps {
handleFeedback: ({ feedback }: { feedback: TFeedback | undefined }) => void;
feedback?: TFeedback;
isLast?: boolean;
}
const ICONS = {
AlertCircle,
PenTool,
ImageOff,
Ban,
HelpCircle,
CheckCircle,
Lightbulb,
Search,
ThumbsUp: ThumbUpIcon,
ThumbsDown: ThumbDownIcon,
};
function FeedbackOptionButton({
tag,
active,
onClick,
}: {
tag: TFeedbackTag;
active?: boolean;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) {
const localize = useLocalize();
const Icon = ICONS[tag.icon as keyof typeof ICONS] || AlertCircle;
const label = localize(tag.label as Parameters<typeof localize>[0]);
return (
<button
className={cn(
'flex w-full items-center gap-3 rounded-xl p-2 text-text-secondary transition-colors duration-200 hover:bg-surface-hover hover:text-text-primary',
active && 'bg-surface-hover font-semibold text-text-primary',
)}
onClick={onClick}
type="button"
aria-label={label}
aria-pressed={active}
>
<Icon size="19" bold={active} />
<span>{label}</span>
</button>
);
}
function FeedbackButtons({
isLast,
feedback,
onFeedback,
onOther,
}: {
isLast: boolean;
feedback?: TFeedback;
onFeedback: (fb: TFeedback | undefined) => void;
onOther?: () => void;
}) {
const localize = useLocalize();
const upStore = Ariakit.usePopoverStore({ placement: 'bottom' });
const downStore = Ariakit.usePopoverStore({ placement: 'bottom' });
const positiveTags = useMemo(() => getTagsForRating('thumbsUp'), []);
const negativeTags = useMemo(() => getTagsForRating('thumbsDown'), []);
const upActive = feedback?.rating === 'thumbsUp' ? feedback.tag?.key : undefined;
const downActive = feedback?.rating === 'thumbsDown' ? feedback.tag?.key : undefined;
const handleThumbsUpClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (feedback?.rating !== 'thumbsUp') {
upStore.toggle();
return;
}
onFeedback(undefined);
},
[feedback, onFeedback, upStore],
);
const handleUpOption = useCallback(
(tag: TFeedbackTag) => (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
upStore.hide();
onFeedback({ rating: 'thumbsUp', tag });
if (tag.key === 'other') {
onOther?.();
}
},
[onFeedback, onOther, upStore],
);
const handleThumbsDownClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (feedback?.rating !== 'thumbsDown') {
downStore.toggle();
return;
}
onOther?.();
},
[feedback, onOther, downStore],
);
const handleDownOption = useCallback(
(tag: TFeedbackTag) => (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
downStore.hide();
onFeedback({ rating: 'thumbsDown', tag });
if (tag.key === 'other') {
onOther?.();
}
},
[onFeedback, onOther, downStore],
);
return (
<>
<Ariakit.PopoverAnchor
store={upStore}
render={
<button
className={buttonClasses(feedback?.rating === 'thumbsUp', isLast)}
onClick={handleThumbsUpClick}
type="button"
title={localize('com_ui_feedback_positive')}
aria-pressed={feedback?.rating === 'thumbsUp'}
aria-haspopup="menu"
>
<ThumbUpIcon size="19" bold={feedback?.rating === 'thumbsUp'} />
</button>
}
/>
<Ariakit.Popover
store={upStore}
gutter={8}
portal
unmountOnHide
className="popover-animate flex w-auto flex-col gap-1.5 overflow-hidden rounded-2xl border border-border-medium bg-surface-secondary p-1.5 shadow-lg"
>
<div className="flex flex-col items-stretch justify-center">
{positiveTags.map((tag) => (
<FeedbackOptionButton
key={tag.key}
tag={tag}
active={upActive === tag.key}
onClick={handleUpOption(tag)}
/>
))}
</div>
</Ariakit.Popover>
<Ariakit.PopoverAnchor
store={downStore}
render={
<button
className={buttonClasses(feedback?.rating === 'thumbsDown', isLast)}
onClick={handleThumbsDownClick}
type="button"
title={localize('com_ui_feedback_negative')}
aria-pressed={feedback?.rating === 'thumbsDown'}
aria-haspopup="menu"
>
<ThumbDownIcon size="19" bold={feedback?.rating === 'thumbsDown'} />
</button>
}
/>
<Ariakit.Popover
store={downStore}
gutter={8}
portal
unmountOnHide
className="popover-animate flex w-auto flex-col gap-1.5 overflow-hidden rounded-2xl border border-border-medium bg-surface-secondary p-1.5 shadow-lg"
>
<div className="flex flex-col items-stretch justify-center">
{negativeTags.map((tag) => (
<FeedbackOptionButton
key={tag.key}
tag={tag}
active={downActive === tag.key}
onClick={handleDownOption(tag)}
/>
))}
</div>
</Ariakit.Popover>
</>
);
}
function buttonClasses(isActive: boolean, isLast: boolean) {
return cn(
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
'hover:text-text-primary hover:bg-surface-hover',
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',
'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none',
isActive && 'active text-text-primary bg-surface-hover',
);
}
export default function Feedback({
isLast = false,
handleFeedback,
feedback: initialFeedback,
}: FeedbackProps) {
const localize = useLocalize();
const [openDialog, setOpenDialog] = useState(false);
const [feedback, setFeedback] = useState<TFeedback | undefined>(initialFeedback);
useEffect(() => {
setFeedback(initialFeedback);
}, [initialFeedback]);
const propagateMinimal = useCallback(
(fb: TFeedback | undefined) => {
setFeedback(fb);
handleFeedback({ feedback: fb });
},
[handleFeedback],
);
const handleButtonFeedback = useCallback(
(fb: TFeedback | undefined) => {
if (fb?.tag?.key === 'other') setOpenDialog(true);
else setOpenDialog(false);
propagateMinimal(fb);
},
[propagateMinimal],
);
const handleOtherOpen = useCallback(() => setOpenDialog(true), []);
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setFeedback((prev) => (prev ? { ...prev, text: e.target.value } : undefined));
};
const handleDialogSave = useCallback(() => {
if (feedback?.tag?.key === 'other' && !feedback?.text?.trim()) {
return;
}
propagateMinimal(feedback);
setOpenDialog(false);
}, [feedback, propagateMinimal]);
const handleDialogClear = useCallback(() => {
setFeedback(undefined);
handleFeedback({ feedback: undefined });
setOpenDialog(false);
}, [handleFeedback]);
const renderSingleFeedbackButton = () => {
if (!feedback) return null;
const isThumbsUp = feedback.rating === 'thumbsUp';
const Icon = isThumbsUp ? ThumbUpIcon : ThumbDownIcon;
const label = isThumbsUp
? localize('com_ui_feedback_positive')
: localize('com_ui_feedback_negative');
return (
<button
className={buttonClasses(true, isLast)}
onClick={() => {
if (isThumbsUp) {
handleButtonFeedback(undefined);
} else {
setOpenDialog(true);
}
}}
type="button"
title={label}
aria-pressed="true"
>
<Icon size="19" bold />
</button>
);
};
return (
<>
{feedback ? (
renderSingleFeedbackButton()
) : (
<FeedbackButtons
isLast={isLast}
feedback={feedback}
onFeedback={handleButtonFeedback}
onOther={handleOtherOpen}
/>
)}
<OGDialog open={openDialog} onOpenChange={setOpenDialog}>
<OGDialogContent className="w-11/12 max-w-lg">
<OGDialogTitle className="text-token-text-primary text-lg font-semibold leading-6">
{localize('com_ui_feedback_more_information')}
</OGDialogTitle>
<textarea
className="w-full rounded-xl border border-border-light bg-transparent p-2 text-text-primary"
value={feedback?.text || ''}
onChange={handleTextChange}
rows={4}
placeholder={localize('com_ui_feedback_placeholder')}
maxLength={500}
/>
<div className="mt-4 flex items-end justify-end gap-2">
<Button variant="destructive" onClick={handleDialogClear}>
{localize('com_ui_delete')}
</Button>
<Button variant="submit" onClick={handleDialogSave} disabled={!feedback?.text?.trim()}>
{localize('com_ui_save')}
</Button>
</div>
</OGDialogContent>
</OGDialog>
</>
);
}