📈 feat: Chat rating for feedback (#5878)

* feat: working started for feedback implementation.

TODO:
- needs some refactoring.
- needs some UI animations.

* feat: working rate functionality

* feat: works now as well to reader the already rated responses from the server.

* feat: added the option to give feedback in text (optional)

* feat: added Dismiss option `x` to the `FeedbackTagOptions`

*  feat: Add rating and ratingContent fields to message schema

* 🔧 chore: Bump version to 0.0.3 in package.json

*  feat: Enhance feedback localization and update UI elements

* 🚀 feat: Implement feedback tagging system with thumbs up/down options

* 🚀 feat: Add data-provider package to unused i18n keys detection

* 🎨 style: update HoverButtons' style

* 🎨 style: Update HoverButtons and Fork components for improved styling and visibility

* 🔧 feat: Implement feedback system with rating and content options

* 🔧 feat: Enhance feedback handling with improved rating toggle and tag options

* 🔧 feat: Integrate toast notifications for feedback submission and clean up unused state

* 🔧 feat: Remove unused feedback tag options from translation file

*  refactor: clean up Feedback component and improve HoverButtons structure

*  refactor: remove unused settings switches for auto scroll, hide side panel, and user message markdown

* refactor: reorganize import order

*  refactor: enhance HoverButtons and Fork components with improved styles and animations

*  refactor: update feedback response phrases for improved user engagement

*  refactor: add CheckboxOption component and streamline fork options rendering

* Refactor feedback components and logic

- Consolidated feedback handling into a single Feedback component, removing FeedbackButtons and FeedbackTagOptions.
- Introduced new feedback tagging system with detailed tags for both thumbs up and thumbs down ratings.
- Updated feedback schema to include new tags and improved type definitions.
- Enhanced user interface for feedback collection, including a dialog for additional comments.
- Removed obsolete files and adjusted imports accordingly.
- Updated translations for new feedback tags and placeholders.

*  refactor: update feedback handling by replacing rating fields with feedback in message updates

* fix: add missing validateMessageReq middleware to feedback route and refactor feedback system

* 🗑️ chore: Remove redundant fork option explanations from translation file

* 🔧 refactor: Remove unused dependency from feedback callback

* 🔧 refactor: Simplify message update response structure and improve error logging

* Chore: removed unused tests.

---------

Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
This commit is contained in:
Ruben Talstra 2025-05-30 18:16:34 +02:00 committed by GitHub
parent 4808c5be48
commit 4cbab86b45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 1592 additions and 835 deletions

View file

@ -1,10 +1,11 @@
import React, { useState } from 'react';
import React, { useState, useMemo, memo } from 'react';
import { useRecoilState } from 'recoil';
import type { TConversation, TMessage } from 'librechat-data-provider';
import { EditIcon, Clipboard, CheckMark, ContinueIcon, RegenerateIcon } from '~/components/svg';
import type { TConversation, TMessage, TFeedback } from 'librechat-data-provider';
import { EditIcon, Clipboard, CheckMark, ContinueIcon, RegenerateIcon } from '~/components';
import { useGenerationsByLatest, useLocalize } from '~/hooks';
import { Fork } from '~/components/Conversations';
import MessageAudio from './MessageAudio';
import Feedback from './Feedback';
import { cn } from '~/utils';
import store from '~/store';
@ -20,9 +21,97 @@ type THoverButtons = {
latestMessage: TMessage | null;
isLast: boolean;
index: number;
handleFeedback: ({ feedback }: { feedback: TFeedback | undefined }) => void;
};
export default function HoverButtons({
type HoverButtonProps = {
onClick: (e?: React.MouseEvent<HTMLButtonElement>) => void;
title: string;
icon: React.ReactNode;
isActive?: boolean;
isVisible?: boolean;
isDisabled?: boolean;
isLast?: boolean;
className?: string;
buttonStyle?: string;
};
const extractMessageContent = (message: TMessage): string => {
if (typeof message.content === 'string') {
return message.content;
}
if (Array.isArray(message.content)) {
return message.content
.map((part) => {
if (typeof part === 'string') {
return part;
}
if ('text' in part) {
return part.text || '';
}
if ('think' in part) {
const think = part.think;
if (typeof think === 'string') {
return think;
}
return think && 'text' in think ? think.text || '' : '';
}
return '';
})
.join('');
}
return message.text || '';
};
const HoverButton = memo(
({
onClick,
title,
icon,
isActive = false,
isVisible = true,
isDisabled = false,
isLast = false,
className = '',
}: HoverButtonProps) => {
const buttonStyle = cn(
'hover-button rounded-lg p-1.5',
'hover:bg-gray-100 hover:text-gray-500',
'dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200',
'disabled:dark:hover:text-gray-400',
'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',
!isVisible && 'opacity-0',
'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none',
isActive && isVisible && 'active text-gray-700 dark:text-gray-200 bg-gray-100 bg-gray-700',
className,
);
return (
<button
className={buttonStyle}
onClick={onClick}
type="button"
title={title}
disabled={isDisabled}
>
{icon}
</button>
);
},
);
HoverButton.displayName = 'HoverButton';
const HoverButtons = ({
index,
isEditing,
enterEdit,
@ -34,20 +123,20 @@ export default function HoverButtons({
handleContinue,
latestMessage,
isLast,
}: THoverButtons) {
handleFeedback,
}: THoverButtons) => {
const localize = useLocalize();
const { endpoint: _endpoint, endpointType } = conversation ?? {};
const endpoint = endpointType ?? _endpoint;
const [isCopied, setIsCopied] = useState(false);
const [TextToSpeech] = useRecoilState<boolean>(store.textToSpeech);
const {
hideEditButton,
regenerateEnabled,
continueSupported,
forkingSupported,
isEditableEndpoint,
} = useGenerationsByLatest({
const endpoint = useMemo(() => {
if (!conversation) {
return '';
}
return conversation.endpointType ?? conversation.endpoint;
}, [conversation]);
const generationCapabilities = useGenerationsByLatest({
isEditing,
isSubmitting,
error: message.error,
@ -58,38 +147,44 @@ export default function HoverButtons({
isCreatedByUser: message.isCreatedByUser,
latestMessageId: latestMessage?.messageId,
});
const {
hideEditButton,
regenerateEnabled,
continueSupported,
forkingSupported,
isEditableEndpoint,
} = generationCapabilities;
if (!conversation) {
return null;
}
const { isCreatedByUser, error } = message;
const renderRegenerate = () => {
if (!regenerateEnabled) {
return null;
}
return (
<button
className={cn(
'hover-button active rounded-md p-1 hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={regenerate}
type="button"
title={localize('com_ui_regenerate')}
>
<RegenerateIcon
className="hover:text-gray-500 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400"
size="19"
/>
</button>
);
};
const buttonStyle = cn(
'hover-button rounded-lg p-1.5',
'hover:bg-gray-100 hover:text-gray-500',
'dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200',
'disabled:dark:hover:text-gray-400',
'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',
'active text-gray-700 dark:text-gray-200 bg-gray-100 bg-gray-700',
);
// If message has an error, only show regenerate button
if (error === true) {
return (
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-500 lg:justify-start">
{renderRegenerate()}
<div className="visible flex justify-center self-end lg:justify-start">
{regenerateEnabled && (
<HoverButton
onClick={regenerate}
title={localize('com_ui_regenerate')}
icon={<RegenerateIcon size="19" />}
isLast={isLast}
/>
)}
</div>
);
}
@ -101,72 +196,92 @@ export default function HoverButtons({
enterEdit();
};
const handleCopy = () => copyToClipboard(setIsCopied);
return (
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-500 lg:justify-start">
<div className="group visible flex justify-center gap-0.5 self-end focus-within:outline-none lg:justify-start">
{/* Text to Speech */}
{TextToSpeech && (
<MessageAudio
index={index}
messageId={message.messageId}
content={message.content ?? message.text}
content={extractMessageContent(message)}
isLast={isLast}
className={cn(
'ml-0 flex items-center gap-1.5 rounded-md p-1 text-xs hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
renderButton={(props) => (
<HoverButton
onClick={props.onClick}
title={props.title}
icon={props.icon}
isActive={props.isActive}
isLast={isLast}
className={props.className}
/>
)}
/>
)}
{isEditableEndpoint && (
<button
id={`edit-${message.messageId}`}
className={cn(
'hover-button rounded-md p-1 hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
isCreatedByUser ? '' : 'active',
hideEditButton ? 'opacity-0' : '',
isEditing ? 'active text-gray-700 dark:text-gray-200' : '',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={onEdit}
type="button"
title={localize('com_ui_edit')}
disabled={hideEditButton}
>
<EditIcon size="19" />
</button>
)}
<button
className={cn(
'ml-0 flex items-center gap-1.5 rounded-md p-1 text-xs hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
isSubmitting && isCreatedByUser ? 'md:opacity-0 md:group-hover:opacity-100' : '',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={() => copyToClipboard(setIsCopied)}
type="button"
{/* Copy Button */}
<HoverButton
onClick={handleCopy}
title={
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
</button>
{renderRegenerate()}
<Fork
icon={isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
isLast={isLast}
className={`ml-0 flex items-center gap-1.5 text-xs ${isSubmitting && isCreatedByUser ? 'md:opacity-0 md:group-hover:opacity-100' : ''}`}
/>
{/* Edit Button */}
{isEditableEndpoint && (
<HoverButton
onClick={onEdit}
title={localize('com_ui_edit')}
icon={<EditIcon size="19" />}
isActive={isEditing}
isVisible={!hideEditButton}
isDisabled={hideEditButton}
isLast={isLast}
className={isCreatedByUser ? '' : 'active'}
/>
)}
{/* Fork Button */}
<Fork
messageId={message.messageId}
conversationId={conversation.conversationId}
forkingSupported={forkingSupported}
latestMessageId={latestMessage?.messageId}
isLast={isLast}
/>
{continueSupported === true ? (
<button
className={cn(
'hover-button active rounded-md p-1 hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={handleContinue}
type="button"
{/* Feedback Buttons */}
{!isCreatedByUser && (
<Feedback handleFeedback={handleFeedback} feedback={message.feedback} isLast={isLast} />
)}
{/* Regenerate Button */}
{regenerateEnabled && (
<HoverButton
onClick={regenerate}
title={localize('com_ui_regenerate')}
icon={<RegenerateIcon size="19" />}
isLast={isLast}
className="active"
/>
)}
{/* Continue Button */}
{continueSupported && (
<HoverButton
onClick={(e) => e && handleContinue(e)}
title={localize('com_ui_continue')}
>
<ContinueIcon className="h-4 w-4 hover:text-gray-500 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
) : null}
icon={<ContinueIcon className="w-19 h-19 -rotate-180" />}
isLast={isLast}
className="active"
/>
)}
</div>
);
}
};
export default memo(HoverButtons);