Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ feat: Add Message Feedback with Tag Options #5878

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
29 changes: 29 additions & 0 deletions api/models/schema/messageSchema.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,35 @@ const messageSchema = mongoose.Schema(
expiredAt: {
type: Date,
},
rating: {
type: String,
enum: ['thumbsUp', 'thumbsDown'],
default: null,
},
ratingContent: {
tags: {
type: [String],
default: [],
},
tagChoices: {
type: [String],
default: [
'Shouldn\'t have used Memory',
'Don\'t like the style',
'Not factually correct',
'Didn\'t fully follow instructions',
'Refused when it shouldn\'t have',
'Being lazy',
'Unsafe or problematic',
'Biased',
'Other',
],
},
text: {
type: String,
default: null,
},
},
},
{ timestamps: true },
);
Expand Down
59 changes: 59 additions & 0 deletions api/server/routes/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,65 @@ router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) =
}
});

router.put('/:conversationId/:messageId/feedback', validateMessageReq, async (req, res) => {
try {
const { conversationId, messageId } = req.params;
const { rating, ratingContent } = req.body;

// Define default tag choices (for thumbsDown only).
const defaultTagChoices = [
'Shouldn\'t have used Memory',
'Don\'t like the style',
'Not factually correct',
'Didn\'t fully follow instructions',
'Refused when it shouldn\'t have',
'Being lazy',
'Unsafe or problematic',
'Biased',
'Other',
];

// Update the message feedback.
const updatedMessage = await updateMessage(req, {
messageId,
rating,
ratingContent,
});

if (!updatedMessage) {
return res.status(400).json({ error: 'Failed to update feedback' });
}

// Build the response ratingContent.
// Start with whatever the updateMessage function returned.
let responseRatingContent = updatedMessage.ratingContent || {};

// For thumbsDown, if no tag choices are present, merge the default choices.
if (rating === 'thumbsDown') {
if (!responseRatingContent.tagChoices || responseRatingContent.tagChoices.length === 0) {
responseRatingContent.tagChoices = defaultTagChoices;
}
if (ratingContent && ratingContent.tags && !responseRatingContent.tags) {
responseRatingContent.tags = ratingContent.tags;
}
if (ratingContent && ratingContent.text && !responseRatingContent.text) {
responseRatingContent.text = ratingContent.text;
}
}

// Return all the feedback details.
return res.status(200).json({
messageId: updatedMessage.messageId,
conversationId: updatedMessage.conversationId,
rating: updatedMessage.rating || rating,
ratingContent: responseRatingContent,
});
} catch (error) {
logger.error('Error updating message feedback:', error);
res.status(500).json({ error: 'Internal server error' });
}
});

router.delete('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
try {
const { messageId } = req.params;
Expand Down
150 changes: 150 additions & 0 deletions client/src/components/Chat/Messages/FeedbackTagOptions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { cn } from '~/utils';
import {
OGDialog,
OGDialogContent,
OGDialogTrigger,
OGDialogHeader,
OGDialogTitle,
} from '~/components';

type FeedbackTagOptionsProps = {
tagChoices: string[];
onSelectTag: (tag: string, text?: string) => void;
};

const FeedbackTagOptions: React.FC<FeedbackTagOptionsProps> = ({ tagChoices, onSelectTag }) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [text, setText] = useState('');
const [isDismissed, setIsDismissed] = useState(false);

const inlineOptions = tagChoices.slice(0, 3);
const hasMore = tagChoices.length > 3;

const handleInlineTagClick = (tag: string) => {
onSelectTag(tag);
};

const handleSubmit = () => {
if (selectedTag) {
onSelectTag(selectedTag, text);
setIsDialogOpen(false);
}
};

return (
<>
{!isDismissed && (
<div className="mt-3 w-full relative">
<div className="min-h-[96px] w-full">
<div className="relative mt-2 flex w-full flex-col gap-3 rounded-lg border border-token-border-light p-4">
<div className="flex justify-between items-center">
<div className="text-sm text-token-text-secondary">Tell us more:</div>

Check failure

Code scanning / ESLint

disallow literal string Error

disallow literal string: Tell us more:
<button
type="button"
onClick={() => setIsDismissed(true)}
className="text-xl text-token-text-secondary hover:text-token-text-primary"
aria-label="Dismiss feedback options"
>
&times;
</button>
</div>
<div className="flex flex-wrap gap-3">
{inlineOptions.map((tag) => (
<button
key={tag}
type="button"
onClick={() => handleInlineTagClick(tag)}
className="rounded-lg border border-token-border-light px-3 py-1 text-sm text-token-text-secondary hover:text-token-text-primary hover:bg-token-main-surface-secondary"
>
{tag}
</button>
))}
{hasMore && (
<button
type="button"
onClick={() => {
setIsDialogOpen(true);
setSelectedTag(null);
setText('');
}}
className="rounded-lg border border-token-border-light px-3 py-1 text-sm text-token-text-secondary hover:text-token-text-primary hover:bg-token-main-surface-secondary"
>
More...
</button>
)}
</div>
</div>
</div>
</div>
)}

{/* Dialog for additional feedback */}
<OGDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<OGDialogTrigger asChild>
{/* Invisible trigger */}
<span />
</OGDialogTrigger>
<OGDialogContent className="w-11/12 max-w-xl">
<OGDialogHeader>
<OGDialogTitle className="text-lg font-semibold leading-6 text-token-text-primary">
Provide additional feedback
</OGDialogTitle>
Comment on lines +91 to +93

Check failure

Code scanning / ESLint

disallow literal string Error

disallow literal string:
Provide additional feedback
</OGDialogHeader>
<div className="flex-grow overflow-y-auto p-4 sm:p-6">
<div className="flex flex-col gap-6">
<div className="flex flex-wrap gap-3">
{tagChoices.map((tag) => (
<button
key={tag}
type="button"
onClick={() => setSelectedTag(tag)}
className={cn(
'relative rounded-lg border border-token-border-light px-3 py-1 text-sm',
selectedTag === tag
? 'bg-token-main-surface-secondary text-token-text-primary'
: 'text-token-text-secondary hover:text-token-text-primary hover:bg-token-main-surface-secondary'
)}
>
{tag}
{selectedTag === tag && (
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-white text-xs">
</span>
Comment on lines +112 to +114

Check failure

Code scanning / ESLint

disallow literal string Error

disallow literal string:

)}
</button>
))}
</div>
</div>
<div className="mt-6">
<input
id="feedback"
aria-label="Additional feedback"
type="text"
placeholder="Additional Feedback (Optional)"
className="block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring focus:ring-blue-200"
value={text}
onChange={(e) => setText(e.target.value)}
/>
</div>
</div>
<div className="flex w-full flex-row items-center justify-end p-4 border-t border-token-border-light">
<div className="flex flex-col gap-3 sm:flex-row-reverse">
<button
type="button"
onClick={handleSubmit}
className={cn('btn btn-primary', !selectedTag && 'opacity-50 cursor-not-allowed')}
disabled={!selectedTag}
>
Submit
</button>
Comment on lines +139 to +141

Check failure

Code scanning / ESLint

disallow literal string Error

disallow literal string: <button
type="button"
onClick={handleSubmit}
className={cn('btn btn-primary', !selectedTag && 'opacity-50 cursor-not-allowed')}
disabled={!selectedTag}
>
Submit
</div>
</div>
</OGDialogContent>
</OGDialog>
</>
);
};

export default FeedbackTagOptions;
55 changes: 52 additions & 3 deletions client/src/components/Chat/Messages/HoverButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import React, { useState } 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, TMessageFeedback } from 'librechat-data-provider';
import {
EditIcon,
Clipboard,
CheckMark,
ContinueIcon,
RegenerateIcon,
ThumbUpIcon,
ThumbDownIcon,
} from '~/components/svg';
import { useGenerationsByLatest, useLocalize } from '~/hooks';
import { Fork } from '~/components/Conversations';
import MessageAudio from './MessageAudio';
Expand All @@ -20,6 +28,8 @@ type THoverButtons = {
latestMessage: TMessage | null;
isLast: boolean;
index: number;
handleFeedback: (rating: 'thumbsUp' | 'thumbsDown', extraPayload?: any) => void;
rated: TMessageFeedback | undefined;
};

export default function HoverButtons({
Expand All @@ -34,6 +44,8 @@ export default function HoverButtons({
handleContinue,
latestMessage,
isLast,
handleFeedback,
rated,
}: THoverButtons) {
const localize = useLocalize();
const { endpoint: _endpoint, endpointType } = conversation ?? {};
Expand Down Expand Up @@ -64,6 +76,12 @@ export default function HoverButtons({

const { isCreatedByUser, error } = message;

const safeRated: TMessageFeedback = rated || { rating: null };

// Use != null so that both null and undefined are treated as "no rating"
const currentRating = message.rating != null ? message.rating : safeRated.rating;
const disableFeedback = message.rating != null || safeRated.rating != null;

const renderRegenerate = () => {
if (!regenerateEnabled) {
return null;
Expand Down Expand Up @@ -114,6 +132,37 @@ export default function HoverButtons({
)}
/>
)}
{!isCreatedByUser && (
<>
{currentRating !== 'thumbsDown' && (
<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',
)}
onClick={() => handleFeedback('thumbsUp')}
type="button"
title={localize('com_ui_feedback_positive')}
disabled={disableFeedback}
>
<ThumbUpIcon size="19" bold={currentRating === 'thumbsUp'} />
</button>
)}

{currentRating !== 'thumbsUp' && (
<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',
)}
onClick={() => handleFeedback('thumbsDown')}
type="button"
title={localize('com_ui_feedback_negative')}
disabled={disableFeedback}
>
<ThumbDownIcon size="19" bold={currentRating === 'thumbsDown'} />
</button>
)}
</>
)}
{isEditableEndpoint && (
<button
id={`edit-${message.messageId}`}
Expand Down Expand Up @@ -169,4 +218,4 @@ export default function HoverButtons({
) : null}
</div>
);
}
}
Loading