From 4b49f10134974e4137d2656df6277a64e2a645d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=94=A7=20Ino=20de=20Bruijn=20=F0=9F=A7=AC?= Date: Tue, 21 Oct 2025 10:40:24 -0400 Subject: [PATCH 01/62] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Add=20Directory=20P?= =?UTF-8?q?ermissions=20in=20Dockerfile.multi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pre-create required runtime directories (/app/api/logs, /app/uploads, /app/client/public/images) - Set proper ownership to node user for /app directory - Run container as non-root user (USER node) Fixes EACCES permission denied errors when LibreChat tries to create the logs directory at runtime. Makes multi-stage build compatible with docker-compose user directive. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Dockerfile.multi | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Dockerfile.multi b/Dockerfile.multi index 5a610725d52..22e34aad6bb 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -81,6 +81,10 @@ COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data COPY --from=data-schemas-build /app/packages/data-schemas/dist ./packages/data-schemas/dist COPY --from=api-package-build /app/packages/api/dist ./packages/api/dist COPY --from=client-build /app/client/dist ./client/dist +# Create runtime directories with correct permissions +RUN mkdir -p /app/api/logs /app/uploads /app/client/public/images && \ + chown -R node:node /app +USER node WORKDIR /app/api EXPOSE 3080 ENV HOST=0.0.0.0 From 903d00a8ac9c7974104dd83d8283593990f8f5af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=94=A7=20Ino=20de=20Bruijn=20=F0=9F=A7=AC?= Date: Mon, 20 Oct 2025 23:13:23 -0400 Subject: [PATCH 02/62] =?UTF-8?q?=E2=9C=A8=20feat:=20Enable=20HTML=20Suppo?= =?UTF-8?q?rt=20in=20Agent=20Descriptions=20(#8291)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add dompurify (v3.2.6) dependency for HTML sanitization - Update Landing.tsx to render descriptions as sanitized HTML - Convert agent description input to textarea for multi-line support - Maintain 512 character limit with improved UX Changes enable rich HTML formatting in agent descriptions while ensuring security through DOMPurify sanitization. Source: https://github.com/danny-avila/LibreChat/pull/8291 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- client/src/components/Chat/Landing.tsx | 8 +++++--- client/src/components/SidePanel/Agents/AgentConfig.tsx | 6 +++--- package-lock.json | 3 +++ package.json | 3 +++ 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/client/src/components/Chat/Landing.tsx b/client/src/components/Chat/Landing.tsx index 7707fe7066d..b6659489fb9 100644 --- a/client/src/components/Chat/Landing.tsx +++ b/client/src/components/Chat/Landing.tsx @@ -7,6 +7,7 @@ import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; import ConvoIcon from '~/components/Endpoints/ConvoIcon'; import { useLocalize, useAuthContext } from '~/hooks'; import { getIconEndpoint, getEntity } from '~/utils'; +import DOMPurify from 'dompurify'; const containerClassName = 'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white dark:bg-presentation dark:text-white text-black dark:after:shadow-none '; @@ -199,9 +200,10 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding: )} {description && ( -
- {description} -
+
)}
diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index a81ef780a9e..9839436ef34 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -235,13 +235,13 @@ export default function AgentConfig() { name="description" control={control} render={({ field }) => ( - diff --git a/package-lock.json b/package-lock.json index 62af363289b..1a780274d37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "client", "packages/*" ], + "dependencies": { + "dompurify": "^3.2.6" + }, "devDependencies": { "@axe-core/playwright": "^4.10.1", "@eslint/compat": "^1.2.6", diff --git a/package.json b/package.json index 6c8e715a1ad..e8cfd504735 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,9 @@ "url": "https://github.com/danny-avila/LibreChat/issues" }, "homepage": "https://librechat.ai/", + "dependencies": { + "dompurify": "^3.2.6" + }, "devDependencies": { "@axe-core/playwright": "^4.10.1", "@eslint/compat": "^1.2.6", From beb686a6430c6aead00f2be8ec88b507e3650631 Mon Sep 17 00:00:00 2001 From: Ino de Bruijn Date: Tue, 3 Mar 2026 19:37:44 +0000 Subject: [PATCH 03/62] Disable PWA service worker using selfDestroying mode Uses vite-plugin-pwa's selfDestroying option which generates a special service worker that unregisters itself and clears all caches. This fixes stale cache issues causing blank pages after deployments. Co-Authored-By: Claude Opus 4.6 --- client/vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/vite.config.ts b/client/vite.config.ts index b3f6541ab3c..d1494b7e2ce 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -36,6 +36,7 @@ export default defineConfig(({ command }) => ({ react(), nodePolyfills(), VitePWA({ + selfDestroying: true, // unregister existing service workers and clear caches injectRegister: 'auto', // 'auto' | 'manual' | 'disabled' registerType: 'autoUpdate', // 'prompt' | 'autoUpdate' devOptions: { From a68aaba56d7f756331e84477192b9867bb1ff108 Mon Sep 17 00:00:00 2001 From: Foris Chen Kuang Date: Wed, 25 Feb 2026 14:36:21 -0500 Subject: [PATCH 04/62] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20product=20feedb?= =?UTF-8?q?ack=20modal=20for=20downvote=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the product feedback feature is enabled (via FEEDBACK_ISSUE_SERVICE_URL env var), clicking thumbs-down on an assistant message opens a feedback modal instead of the default tag popover. The modal collects a categorized issue report that is submitted to an external issue creation service, enabling automated GitHub issue creation from user feedback. --- .env.example | 10 + api/server/index.js | 1 + api/server/routes/config.js | 1 + api/server/routes/feedbackIssues.js | 48 ++++ api/server/routes/index.js | 2 + .../src/components/Chat/Messages/Feedback.tsx | 150 ++++++---- .../components/Chat/Messages/HoverButtons.tsx | 8 +- .../Chat/Messages/ProductFeedbackModal.tsx | 262 ++++++++++++++++++ client/src/locales/en/translation.json | 19 ++ packages/client/src/common/types.ts | 2 + packages/client/src/components/Toast.tsx | 10 + packages/client/src/hooks/useToast.ts | 4 + packages/client/src/store.ts | 2 + packages/data-provider/src/api-endpoints.ts | 3 + packages/data-provider/src/config.ts | 1 + packages/data-provider/src/data-service.ts | 32 +++ packages/data-provider/src/index.ts | 1 + .../data-provider/src/product-feedback.ts | 39 +++ .../src/react-query/react-query-service.ts | 11 + 19 files changed, 553 insertions(+), 53 deletions(-) create mode 100644 api/server/routes/feedbackIssues.js create mode 100644 client/src/components/Chat/Messages/ProductFeedbackModal.tsx create mode 100644 packages/data-provider/src/product-feedback.ts diff --git a/.env.example b/.env.example index a6ff6157cee..7d619e34fd1 100644 --- a/.env.example +++ b/.env.example @@ -844,3 +844,13 @@ 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 Issue Creation # +#======================================# + +# External service URL for creating feedback issues +# FEEDBACK_ISSUE_SERVICE_URL=https://your-service.example.com/feedback/issues + +# Auth token for the feedback issue creation service +# FEEDBACK_ISSUE_SERVICE_AUTH_TOKEN=your-auth-token diff --git a/api/server/index.js b/api/server/index.js index 193eb423ad9..3d0520c0f26 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -161,6 +161,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); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index a2dc5b79d27..d72051a348c 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -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: !!process.env.FEEDBACK_ISSUE_SERVICE_URL, }; const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10); diff --git a/api/server/routes/feedbackIssues.js b/api/server/routes/feedbackIssues.js new file mode 100644 index 00000000000..33acb112ba0 --- /dev/null +++ b/api/server/routes/feedbackIssues.js @@ -0,0 +1,48 @@ +const express = require('express'); +const { logger } = require('@librechat/data-schemas'); +const { requireJwtAuth } = require('~/server/middleware'); + +const router = express.Router(); +router.use(requireJwtAuth); + +router.post('/', async (req, res) => { + const serviceUrl = process.env.FEEDBACK_ISSUE_SERVICE_URL; + const authToken = process.env.FEEDBACK_ISSUE_SERVICE_AUTH_TOKEN; + + if (!serviceUrl) { + return res.status(501).json({ + error: 'Product feedback service is not configured', + }); + } + + try { + const response = await fetch(serviceUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), + }, + body: JSON.stringify(req.body), + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('Issue creation service error:', { status: response.status, errorText }); + return res.status(response.status).json({ + error: 'Issue creation service error', + details: errorText, + }); + } + + const data = await response.json(); + return res.json(data); + } catch (error) { + logger.error('Failed to reach issue creation service:', error); + return res.status(502).json({ + error: 'Failed to reach issue creation service', + message: error.message, + }); + } +}); + +module.exports = router; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 6a48919db33..e27d1cabc3e 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -25,6 +25,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 = { @@ -55,5 +56,6 @@ module.exports = { assistants, categories, staticRoute, + feedbackIssues, accessPermissions, }; diff --git a/client/src/components/Chat/Messages/Feedback.tsx b/client/src/components/Chat/Messages/Feedback.tsx index eea1464e66f..dd28befaf04 100644 --- a/client/src/components/Chat/Messages/Feedback.tsx +++ b/client/src/components/Chat/Messages/Feedback.tsx @@ -1,6 +1,7 @@ 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, @@ -8,6 +9,7 @@ import { OGDialogTitle, ThumbUpIcon, ThumbDownIcon, + useToastContext, } from '@librechat/client'; import { AlertCircle, @@ -19,6 +21,9 @@ 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'; @@ -26,6 +31,8 @@ interface FeedbackProps { handleFeedback: ({ feedback }: { feedback: TFeedback | undefined }) => void; feedback?: TFeedback; isLast?: boolean; + conversationId?: string; + messageId?: string; } const ICONS = { @@ -76,21 +83,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) => { @@ -119,26 +127,14 @@ function FeedbackButtons({ const handleThumbsDownClick = useCallback( (e: React.MouseEvent) => { e.preventDefault(); - if (feedback?.rating !== 'thumbsDown') { - downStore.toggle(); - return; - } - - onOther?.(); - }, - [feedback, onOther, downStore], - ); - - const handleDownOption = useCallback( - (tag: TFeedbackTag) => (e: React.MouseEvent) => { - 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 ( @@ -177,39 +173,15 @@ function FeedbackButtons({ - - - - } - /> - -
- {negativeTags.map((tag) => ( - - ))} -
-
+ + ); } @@ -229,10 +201,17 @@ export default function Feedback({ isLast = false, handleFeedback, feedback: initialFeedback, + conversationId, + messageId, }: FeedbackProps) { const localize = useLocalize(); + const { showToast } = useToastContext(); const [openDialog, setOpenDialog] = useState(false); + const [openProductFeedback, setOpenProductFeedback] = useState(false); const [feedback, setFeedback] = useState(initialFeedback); + const { data: startupConfig } = useGetStartupConfig(); + const productFeedbackEnabled = startupConfig?.productFeedbackEnabled === true; + const submitProductFeedback = useSubmitProductFeedbackMutation(); useEffect(() => { setFeedback(initialFeedback); @@ -257,6 +236,8 @@ export default function Feedback({ const handleOtherOpen = useCallback(() => setOpenDialog(true), []); + const handleReportIssueOpen = useCallback(() => setOpenProductFeedback(true), []); + const handleTextChange = (e: React.ChangeEvent) => { setFeedback((prev) => (prev ? { ...prev, text: e.target.value } : undefined)); }; @@ -275,6 +256,60 @@ export default function Feedback({ setOpenDialog(false); }, [handleFeedback]); + const handleProductFeedbackSubmit = useCallback( + (payload: ProductFeedbackPayload) => { + 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', + }, + ...(payload.user_email ? { contact: { email: payload.user_email } } : {}), + }; + + submitProductFeedback.mutate(mutationPayload, { + onSuccess: (data) => { + const issueUrl = data?.issue_url ?? ''; + const toastOptions: Parameters[0] = { + message: localize('com_ui_product_feedback_success' as Parameters[0]), + severity: NotificationSeverity.SUCCESS, + showIcon: true, + duration: 8000, + }; + + if (issueUrl) { + toastOptions.link = issueUrl; + toastOptions.linkText = localize( + 'com_ui_product_feedback_view_issue' as Parameters[0], + ); + toastOptions.duration = 10000; + } + + showToast(toastOptions); + }, + onError: () => { + showToast({ + message: localize('com_ui_product_feedback_error' as Parameters[0]), + severity: NotificationSeverity.ERROR, + showIcon: true, + }); + }, + }); + }, + [submitProductFeedback, showToast, localize], + ); + const renderSingleFeedbackButton = () => { if (!feedback) return null; const isThumbsUp = feedback.rating === 'thumbsUp'; @@ -311,6 +346,8 @@ export default function Feedback({ feedback={feedback} onFeedback={handleButtonFeedback} onOther={handleOtherOpen} + onReportIssue={handleReportIssueOpen} + productFeedbackEnabled={productFeedbackEnabled} /> )} @@ -336,6 +373,15 @@ export default function Feedback({ + {productFeedbackEnabled && conversationId && messageId && ( + + )} ); } diff --git a/client/src/components/Chat/Messages/HoverButtons.tsx b/client/src/components/Chat/Messages/HoverButtons.tsx index 5d60223d08a..f975f98481d 100644 --- a/client/src/components/Chat/Messages/HoverButtons.tsx +++ b/client/src/components/Chat/Messages/HoverButtons.tsx @@ -245,7 +245,13 @@ const HoverButtons = ({ {/* Feedback Buttons */} {!isCreatedByUser && handleFeedback != null && ( - + )} {/* Regenerate Button */} diff --git a/client/src/components/Chat/Messages/ProductFeedbackModal.tsx b/client/src/components/Chat/Messages/ProductFeedbackModal.tsx new file mode 100644 index 00000000000..aa359a61e2e --- /dev/null +++ b/client/src/components/Chat/Messages/ProductFeedbackModal.tsx @@ -0,0 +1,262 @@ +import React, { useState, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { QueryKeys } from 'librechat-data-provider'; +import type { TMessage } from 'librechat-data-provider'; +import { Button, OGDialog, OGDialogContent, OGDialogTitle } from '@librechat/client'; +import { useLocalize, useAuthContext } from '~/hooks'; +import { cn } from '~/utils'; + +interface ProductFeedbackModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + conversationId: string; + messageId: string; + onSubmit?: (payload: ProductFeedbackPayload) => void; + onSuccess?: (issueUrl: string) => void; +} + +interface ConversationMessage { + timestamp: string; + is_user: boolean; + text: string; +} + +export interface ProductFeedbackPayload { + request_id: string; + feedback_reason: string; + feedback_title: string; + feedback_details: string; + feedback_suggested_fix: string; + conversation_id: string; + message_id: string; + user_id: string | undefined; + user_email: string | undefined; + messages: ConversationMessage[]; +} + +type FeedbackReason = + | 'incorrect' + | 'unfaithful' + | 'safety_or_legal_concern' + | 'style_tone_conciseness' + | 'other'; + +const FEEDBACK_REASONS: { value: FeedbackReason; labelKey: string }[] = [ + { value: 'incorrect', labelKey: 'com_ui_product_feedback_reason_incorrect' }, + { value: 'unfaithful', labelKey: 'com_ui_product_feedback_reason_unfaithful' }, + { value: 'safety_or_legal_concern', labelKey: 'com_ui_product_feedback_reason_safety' }, + { value: 'style_tone_conciseness', labelKey: 'com_ui_product_feedback_reason_style' }, + { value: 'other', labelKey: 'com_ui_product_feedback_reason_other' }, +]; + +export default function ProductFeedbackModal({ + open, + onOpenChange, + conversationId, + messageId, + onSubmit, + onSuccess, +}: ProductFeedbackModalProps) { + const localize = useLocalize(); + const { user } = useAuthContext(); + const queryClient = useQueryClient(); + + const [feedbackReason, setFeedbackReason] = useState(''); + const [feedbackTitle, setFeedbackTitle] = useState(''); + const [feedbackDetails, setFeedbackDetails] = useState(''); + const [feedbackSuggestedFix, setFeedbackSuggestedFix] = useState(''); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const isSubmitDisabled = !feedbackReason || !feedbackTitle.trim() || isSubmitting; + + const resetForm = useCallback(() => { + setFeedbackReason(''); + setFeedbackTitle(''); + setFeedbackDetails(''); + setFeedbackSuggestedFix(''); + setError(null); + setIsSubmitting(false); + }, []); + + const handleOpenChange = useCallback( + (isOpen: boolean) => { + if (!isOpen) { + resetForm(); + } + onOpenChange(isOpen); + }, + [onOpenChange, resetForm], + ); + + const handleSubmit = useCallback(async () => { + if (isSubmitDisabled) { + return; + } + + setError(null); + setIsSubmitting(true); + + try { + const messages = + queryClient.getQueryData([QueryKeys.messages, conversationId]) ?? []; + + const lastMessages = messages.slice(-10).map((msg) => ({ + timestamp: msg.createdAt ?? new Date().toISOString(), + is_user: msg.isCreatedByUser, + text: msg.text, + })); + + const requestId = crypto.randomUUID(); + + const payload: ProductFeedbackPayload = { + request_id: requestId, + feedback_reason: feedbackReason, + feedback_title: feedbackTitle.trim(), + feedback_details: feedbackDetails.trim(), + feedback_suggested_fix: feedbackSuggestedFix.trim(), + conversation_id: conversationId, + message_id: messageId, + user_id: user?.id, + user_email: user?.email, + messages: lastMessages, + }; + + if (onSubmit) { + onSubmit(payload); + } + + resetForm(); + onOpenChange(false); + onSuccess?.(''); + } catch { + setError(localize('com_ui_product_feedback_error' as Parameters[0])); + } finally { + setIsSubmitting(false); + } + }, [ + isSubmitDisabled, + queryClient, + conversationId, + feedbackReason, + feedbackTitle, + feedbackDetails, + feedbackSuggestedFix, + messageId, + user, + onSubmit, + onSuccess, + onOpenChange, + resetForm, + localize, + ]); + + return ( + + + + {localize('com_ui_product_feedback_title' as Parameters[0])} + + +
+ {/* Feedback Reason Pills */} +
+ +
+ {FEEDBACK_REASONS.map(({ value, labelKey }) => ( + + ))} +
+
+ + {/* Feedback Title */} +
+ [0], + )} + value={feedbackTitle} + onChange={(e) => setFeedbackTitle(e.target.value)} + maxLength={200} + /> +
+ + {/* Feedback Details */} +
+ +