Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -827,3 +827,10 @@ OPENWEATHER_API_KEY=
# Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it)
# When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration
# MCP_SKIP_CODE_CHALLENGE_CHECK=false

#======================================#
# Product Feedback #
#======================================#

# Enable the product feedback feature (thumbs-down stores feedback in MongoDB for review)
# PRODUCT_FEEDBACK_ENABLED=true
1 change: 1 addition & 0 deletions api/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ const startServer = async () => {

app.use('/api/tags', routes.tags);
app.use('/api/mcp', routes.mcp);
app.use('/api/feedback/issues', routes.feedbackIssues);

app.use(ErrorController);

Expand Down
1 change: 1 addition & 0 deletions api/server/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ router.get('/', async function (req, res) {
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
: 0,
productFeedbackEnabled: isEnabled(process.env.PRODUCT_FEEDBACK_ENABLED),
};

const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);
Expand Down
50 changes: 50 additions & 0 deletions api/server/routes/feedbackIssues.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const { logger } = require('@librechat/data-schemas');
const { requireJwtAuth } = require('~/server/middleware');
const { ProductFeedback } = require('~/db/models');

const router = express.Router();
router.use(requireJwtAuth);

router.post('/', async (req, res) => {
const { feedback_reason, feedback_title, feedback_details, feedback_suggested_fix, conversation, metadata, contact } = req.body;

if (!feedback_reason || !feedback_title) {
return res.status(400).json({
error: 'feedback_reason and feedback_title are required',
});
}

const request_id = req.body.request_id || uuidv4();

try {
const record = await ProductFeedback.create({
request_id,
user: req.user.id,
username: req.user.username || req.user.name || 'unknown',
feedback_reason,
feedback_title,
feedback_details,
feedback_suggested_fix,
conversation,
metadata,
contact,
});

logger.info('[feedbackIssues] Feedback saved:', { request_id, id: record._id });

return res.json({
id: record._id.toString(),
request_id,
});
} catch (error) {
logger.error('[feedbackIssues] Failed to save feedback:', error);
return res.status(500).json({
error: 'Failed to save feedback',
message: error.message,
});
}
});

module.exports = router;
2 changes: 2 additions & 0 deletions api/server/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const tags = require('./tags');
const auth = require('./auth');
const keys = require('./keys');
const user = require('./user');
const feedbackIssues = require('./feedbackIssues');
const mcp = require('./mcp');

module.exports = {
Expand Down Expand Up @@ -51,5 +52,6 @@ module.exports = {
assistants,
categories,
staticRoute,
feedbackIssues,
accessPermissions,
};
171 changes: 119 additions & 52 deletions client/src/components/Chat/Messages/Feedback.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import * as Ariakit from '@ariakit/react';
import { TFeedback, TFeedbackTag, getTagsForRating } from 'librechat-data-provider';
import { useSubmitProductFeedbackMutation } from 'librechat-data-provider/react-query';
import {
Button,
OGDialog,
OGDialogContent,
OGDialogTitle,
ThumbUpIcon,
ThumbDownIcon,
useToastContext,
} from '@librechat/client';
import {
AlertCircle,
Expand All @@ -19,13 +21,21 @@ import {
Lightbulb,
Search,
} from 'lucide-react';
import ProductFeedbackModal, { type ProductFeedbackPayload } from './ProductFeedbackModal';
import { useGetStartupConfig } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';

interface FeedbackProps {
handleFeedback: ({ feedback }: { feedback: TFeedback | undefined }) => void;
feedback?: TFeedback;
isLast?: boolean;
conversationId?: string;
messageId?: string;
endpoint?: string;
model?: string;
agent_id?: string;
}

const ICONS = {
Expand Down Expand Up @@ -76,21 +86,22 @@ function FeedbackButtons({
feedback,
onFeedback,
onOther,
onReportIssue,
productFeedbackEnabled,
}: {
isLast: boolean;
feedback?: TFeedback;
onFeedback: (fb: TFeedback | undefined) => void;
onOther?: () => void;
onReportIssue?: () => void;
productFeedbackEnabled?: boolean;
}) {
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>) => {
Expand Down Expand Up @@ -119,26 +130,14 @@ function FeedbackButtons({
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') {
if (productFeedbackEnabled) {
onReportIssue?.();
} else {
onFeedback({ rating: 'thumbsDown' });
onOther?.();
}
},
[onFeedback, onOther, downStore],
[productFeedbackEnabled, onReportIssue, onFeedback, onOther],
);

return (
Expand Down Expand Up @@ -177,39 +176,15 @@ function FeedbackButtons({
</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"
<button
className={buttonClasses(feedback?.rating === 'thumbsDown', isLast)}
onClick={handleThumbsDownClick}
type="button"
title={localize('com_ui_feedback_negative')}
aria-pressed={feedback?.rating === 'thumbsDown'}
>
<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>
<ThumbDownIcon size="19" bold={feedback?.rating === 'thumbsDown'} />
</button>
</>
);
}
Expand All @@ -229,10 +204,20 @@ export default function Feedback({
isLast = false,
handleFeedback,
feedback: initialFeedback,
conversationId,
messageId,
endpoint,
model,
agent_id,
}: FeedbackProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [openDialog, setOpenDialog] = useState(false);
const [openProductFeedback, setOpenProductFeedback] = useState(false);
const [feedback, setFeedback] = useState<TFeedback | undefined>(initialFeedback);
const { data: startupConfig } = useGetStartupConfig();
const productFeedbackEnabled = startupConfig?.productFeedbackEnabled === true;
const submitProductFeedback = useSubmitProductFeedbackMutation();

useEffect(() => {
setFeedback(initialFeedback);
Expand All @@ -257,6 +242,8 @@ export default function Feedback({

const handleOtherOpen = useCallback(() => setOpenDialog(true), []);

const handleReportIssueOpen = useCallback(() => setOpenProductFeedback(true), []);

const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setFeedback((prev) => (prev ? { ...prev, text: e.target.value } : undefined));
};
Expand All @@ -275,6 +262,72 @@ export default function Feedback({
setOpenDialog(false);
}, [handleFeedback]);

const handleProductFeedbackSubmit = useCallback(
(payload: ProductFeedbackPayload) => {
// Store the full feedback payload as JSON in the existing feedback text field
handleFeedback({
feedback: {
rating: 'thumbsDown',
text: JSON.stringify({
request_id: payload.request_id,
feedback_reason: payload.feedback_reason,
feedback_title: payload.feedback_title,
feedback_details: payload.feedback_details,
feedback_suggested_fix: payload.feedback_suggested_fix,
conversation_id: payload.conversation_id,
message_id: payload.message_id,
messages: payload.messages,
endpoint: payload.endpoint,
model: payload.model,
agent_id: payload.agent_id,
}),
},
});

const mutationPayload = {
request_id: payload.request_id,
user: { username: payload.user_email ?? payload.user_id ?? '' },
timestamp: new Date().toISOString(),
feedback_reason: payload.feedback_reason,
feedback_title: payload.feedback_title,
feedback_details: payload.feedback_details,
feedback_suggested_fix: payload.feedback_suggested_fix,
conversation: {
conversation_id: payload.conversation_id,
message_id: payload.message_id,
last_n_messages: payload.messages,
},
metadata: {
librechat_version: '',
client: 'web',
endpoint: payload.endpoint,
model: payload.model,
agent_id: payload.agent_id,
},
...(payload.user_email ? { contact: { email: payload.user_email } } : {}),
};

submitProductFeedback.mutate(mutationPayload, {
onSuccess: () => {
showToast({
message: localize('com_ui_product_feedback_success' as Parameters<typeof localize>[0]),
severity: NotificationSeverity.SUCCESS,
showIcon: true,
duration: 5000,
});
},
onError: () => {
showToast({
message: localize('com_ui_product_feedback_error' as Parameters<typeof localize>[0]),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
});
},
[submitProductFeedback, handleFeedback, showToast, localize],
);

const renderSingleFeedbackButton = () => {
if (!feedback) return null;
const isThumbsUp = feedback.rating === 'thumbsUp';
Expand Down Expand Up @@ -311,6 +364,8 @@ export default function Feedback({
feedback={feedback}
onFeedback={handleButtonFeedback}
onOther={handleOtherOpen}
onReportIssue={handleReportIssueOpen}
productFeedbackEnabled={productFeedbackEnabled}
/>
)}
<OGDialog open={openDialog} onOpenChange={setOpenDialog}>
Expand All @@ -336,6 +391,18 @@ export default function Feedback({
</div>
</OGDialogContent>
</OGDialog>
{productFeedbackEnabled && conversationId && messageId && (
<ProductFeedbackModal
open={openProductFeedback}
onOpenChange={setOpenProductFeedback}
conversationId={conversationId}
messageId={messageId}
endpoint={endpoint}
model={model}
agent_id={agent_id}
onSubmit={handleProductFeedbackSubmit}
/>
)}
</>
);
}
11 changes: 10 additions & 1 deletion client/src/components/Chat/Messages/HoverButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,16 @@ const HoverButtons = ({

{/* Feedback Buttons */}
{!isCreatedByUser && handleFeedback != null && (
<Feedback handleFeedback={handleFeedback} feedback={message.feedback} isLast={isLast} />
<Feedback
handleFeedback={handleFeedback}
feedback={message.feedback}
isLast={isLast}
conversationId={conversation.conversationId ?? undefined}
messageId={message.messageId ?? undefined}
endpoint={endpoint || undefined}
model={conversation?.model ?? undefined}
agent_id={conversation?.agent_id ?? undefined}
/>
)}

{/* Regenerate Button */}
Expand Down
Loading