📈 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

@ -5,12 +5,13 @@ on:
paths:
- "client/src/**"
- "api/**"
- "packages/data-provider/src/**"
jobs:
detect-unused-i18n-keys:
runs-on: ubuntu-latest
permissions:
pull-requests: write # Required for posting PR comments
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v3

View file

@ -244,9 +244,9 @@ class ChatGPTClient extends BaseClient {
baseURL = this.langchainProxy
? constructAzureURL({
baseURL: this.langchainProxy,
azureOptions: this.azure,
})
baseURL: this.langchainProxy,
azureOptions: this.azure,
})
: this.azureEndpoint.split(/(?<!\/)\/(chat|completion)\//)[0];
if (this.options.forcePrompt) {
@ -339,7 +339,6 @@ class ChatGPTClient extends BaseClient {
opts.body = JSON.stringify(modelOptions);
if (modelOptions.stream) {
return new Promise(async (resolve, reject) => {
try {
let done = false;

View file

@ -236,11 +236,11 @@ class GoogleClient extends BaseClient {
msg.content = (
!Array.isArray(msg.content)
? [
{
type: ContentTypes.TEXT,
[ContentTypes.TEXT]: msg.content,
},
]
{
type: ContentTypes.TEXT,
[ContentTypes.TEXT]: msg.content,
},
]
: msg.content
).concat(message.image_urls);

View file

@ -52,7 +52,7 @@ const messageHistory = [
{
role: 'user',
isCreatedByUser: true,
text: 'What\'s up',
text: "What's up",
messageId: '3',
parentMessageId: '2',
},
@ -456,7 +456,7 @@ describe('BaseClient', () => {
const chatMessages2 = await TestClient.loadHistory(conversationId, '3');
expect(TestClient.currentMessages).toHaveLength(3);
expect(chatMessages2[chatMessages2.length - 1].text).toEqual('What\'s up');
expect(chatMessages2[chatMessages2.length - 1].text).toEqual("What's up");
});
/* Most of the new sendMessage logic revolving around edited/continued AI messages

View file

@ -462,17 +462,17 @@ describe('OpenAIClient', () => {
role: 'system',
name: 'example_user',
content:
'Let\'s circle back when we have more bandwidth to touch base on opportunities for increased leverage.',
"Let's circle back when we have more bandwidth to touch base on opportunities for increased leverage.",
},
{
role: 'system',
name: 'example_assistant',
content: 'Let\'s talk later when we\'re less busy about how to do better.',
content: "Let's talk later when we're less busy about how to do better.",
},
{
role: 'user',
content:
'This late pivot means we don\'t have time to boil the ocean for the client deliverable.',
"This late pivot means we don't have time to boil the ocean for the client deliverable.",
},
];

View file

@ -64,7 +64,7 @@ const DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION = `Describe the changes, enhancement
Always base this prompt on the most recently uploaded reference images.`;
const displayMessage =
'The tool displayed an image. All generated images are already plainly visible, so don\'t repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.';
"The tool displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.";
/**
* Replaces unwanted characters from the input string

View file

@ -255,6 +255,7 @@ async function updateMessage(req, message, metadata) {
text: updatedMessage.text,
isCreatedByUser: updatedMessage.isCreatedByUser,
tokenCount: updatedMessage.tokenCount,
feedback: updatedMessage.feedback,
};
} catch (err) {
logger.error('Error updating message:', err);

View file

@ -153,7 +153,7 @@ describe('Message Operations', () => {
});
describe('Conversation Hijacking Prevention', () => {
it('should not allow editing a message in another user\'s conversation', async () => {
it("should not allow editing a message in another user's conversation", async () => {
const attackerReq = { user: { id: 'attacker123' } };
const victimConversationId = 'victim-convo-123';
const victimMessageId = 'victim-msg-123';
@ -175,7 +175,7 @@ describe('Message Operations', () => {
);
});
it('should not allow deleting messages from another user\'s conversation', async () => {
it("should not allow deleting messages from another user's conversation", async () => {
const attackerReq = { user: { id: 'attacker123' } };
const victimConversationId = 'victim-convo-123';
const victimMessageId = 'victim-msg-123';
@ -193,7 +193,7 @@ describe('Message Operations', () => {
});
});
it('should not allow inserting a new message into another user\'s conversation', async () => {
it("should not allow inserting a new message into another user's conversation", async () => {
const attackerReq = { user: { id: 'attacker123' } };
const victimConversationId = uuidv4(); // Use a valid UUID

View file

@ -228,7 +228,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
// Save user message if needed
if (!client.skipSaveUserMessage) {
await saveMessage(req, userMessage, {
context: 'api/server/controllers/agents/request.js - don\'t skip saving user message',
context: "api/server/controllers/agents/request.js - don't skip saving user message",
});
}

View file

@ -327,7 +327,7 @@ const handleAbortError = async (res, req, error, data) => {
errorText = `{"type":"${ErrorTypes.INVALID_REQUEST}"}`;
}
if (error?.message?.includes('does not support \'system\'')) {
if (error?.message?.includes("does not support 'system'")) {
errorText = `{"type":"${ErrorTypes.NO_SYSTEM_MESSAGES}"}`;
}

View file

@ -253,6 +253,31 @@ router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) =
}
});
router.put('/:conversationId/:messageId/feedback', validateMessageReq, async (req, res) => {
try {
const { conversationId, messageId } = req.params;
const { feedback } = req.body;
const updatedMessage = await updateMessage(
req,
{
messageId,
feedback: feedback || null,
},
{ context: 'updateFeedback' },
);
res.json({
messageId,
conversationId,
feedback: updatedMessage.feedback,
});
} catch (error) {
logger.error('Error updating message feedback:', error);
res.status(500).json({ error: 'Failed to update feedback' });
}
});
router.delete('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
try {
const { messageId } = req.params;

View file

@ -457,11 +457,20 @@ export type VoiceOption = {
};
export type TMessageAudio = {
messageId?: string;
content?: t.TMessageContentParts[] | string;
className?: string;
isLast: boolean;
isLast?: boolean;
index: number;
messageId: string;
content: string;
className?: string;
renderButton?: (props: {
onClick: (e?: React.MouseEvent<HTMLButtonElement>) => void;
title: string;
icon: React.ReactNode;
isActive?: boolean;
isVisible?: boolean;
isDisabled?: boolean;
className?: string;
}) => React.ReactNode;
};
export type OptionWithIcon = Option & { icon?: React.ReactNode };

View file

@ -1,5 +1,5 @@
/* eslint-disable jsx-a11y/media-has-caption */
import { useEffect, useMemo } from 'react';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import type { TMessageAudio } from '~/common';
import { useLocalize, useTTSBrowser, useTTSExternal } from '~/hooks';
@ -7,7 +7,14 @@ import { VolumeIcon, VolumeMuteIcon, Spinner } from '~/components';
import { logger } from '~/utils';
import store from '~/store';
export function BrowserTTS({ isLast, index, messageId, content, className }: TMessageAudio) {
export function BrowserTTS({
isLast,
index,
messageId,
content,
className,
renderButton,
}: TMessageAudio) {
const localize = useLocalize();
const playbackRate = useRecoilValue(store.playbackRate);
@ -46,21 +53,30 @@ export function BrowserTTS({ isLast, index, messageId, content, className }: TMe
audioRef.current,
);
const handleClick = () => {
if (audioRef.current) {
audioRef.current.muted = false;
}
toggleSpeech();
};
const title = isSpeaking === true ? localize('com_ui_stop') : localize('com_ui_read_aloud');
return (
<>
<button
className={className}
onClickCapture={() => {
if (audioRef.current) {
audioRef.current.muted = false;
}
toggleSpeech();
}}
type="button"
title={isSpeaking === true ? localize('com_ui_stop') : localize('com_ui_read_aloud')}
>
{renderIcon('19')}
</button>
{renderButton ? (
renderButton({
onClick: handleClick,
title: title,
icon: renderIcon('19'),
isActive: isSpeaking,
className,
})
) : (
<button className={className} onClickCapture={handleClick} type="button" title={title}>
{renderIcon('19')}
</button>
)}
<audio
ref={audioRef}
controls

View file

@ -102,12 +102,12 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
if (endpoint.hasModels) {
const filteredModels = searchValue
? filterModels(
endpoint,
(endpoint.models || []).map((model) => model.name),
searchValue,
agentsMap,
assistantsMap,
)
endpoint,
(endpoint.models || []).map((model) => model.name),
searchValue,
agentsMap,
assistantsMap,
)
: null;
const placeholder =
isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)

View file

@ -45,10 +45,10 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
</div>
) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) &&
endpoint.icon ? (
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
{endpoint.icon}
</div>
) : null}
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
{endpoint.icon}
</div>
) : null}
<span>{modelName}</span>
</div>
{isGlobal && <EarthIcon className="ml-auto size-4 text-green-400" />}

View file

@ -102,22 +102,22 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP
const filteredModels = endpoint.label.toLowerCase().includes(lowerQuery)
? endpoint.models
: endpoint.models.filter((model) => {
let modelName = model.name;
if (
isAgentsEndpoint(endpoint.value) &&
let modelName = model.name;
if (
isAgentsEndpoint(endpoint.value) &&
endpoint.agentNames &&
endpoint.agentNames[model.name]
) {
modelName = endpoint.agentNames[model.name];
} else if (
isAssistantsEndpoint(endpoint.value) &&
) {
modelName = endpoint.agentNames[model.name];
} else if (
isAssistantsEndpoint(endpoint.value) &&
endpoint.assistantNames &&
endpoint.assistantNames[model.name]
) {
modelName = endpoint.assistantNames[model.name];
}
return modelName.toLowerCase().includes(lowerQuery);
});
) {
modelName = endpoint.assistantNames[model.name];
}
return modelName.toLowerCase().includes(lowerQuery);
});
if (!filteredModels.length) {
return null; // skip if no models match

View file

@ -0,0 +1,347 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import * as Ariakit from '@ariakit/react';
import { TFeedback, TFeedbackTag, getTagsForRating } from 'librechat-data-provider';
import {
AlertCircle,
PenTool,
ImageOff,
Ban,
HelpCircle,
CheckCircle,
Lightbulb,
Search,
} from 'lucide-react';
import {
Button,
OGDialog,
OGDialogContent,
OGDialogTitle,
ThumbUpIcon,
ThumbDownIcon,
} from '~/components';
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',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white',
'hover:bg-gray-100 hover:text-gray-500',
'data-[state=open]:active data-[state=open]:bg-gray-100 data-[state=open]:text-gray-500',
isActive ? 'text-gray-500 dark:text-gray-200 font-bold' : 'dark:text-gray-400/70',
'dark:hover:bg-gray-700 dark:hover:text-gray-200',
'data-[state=open]:dark:bg-gray-700 data-[state=open]:dark:text-gray-200',
'disabled:dark:hover:text-gray-400',
isLast
? ''
: 'data-[state=open]:opacity-100 md:opacity-0 md:group-focus-within:opacity-100 md:group-hover:opacity-100',
'md:group-focus-within:visible md:group-hover:visible md:group-[.final-completion]:visible',
);
}
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>
</>
);
}

View file

@ -0,0 +1,424 @@
import React, { useState, useRef } from 'react';
import { useRecoilState } from 'recoil';
import * as Ariakit from '@ariakit/react';
import { VisuallyHidden } from '@ariakit/react';
import { GitFork, InfoIcon } from 'lucide-react';
import { ForkOptions } from 'librechat-data-provider';
import { GitCommit, GitBranchPlus, ListTree } from 'lucide-react';
import { TranslationKeys, useLocalize, useNavigateToConvo } from '~/hooks';
import { useForkConvoMutation } from '~/data-provider';
import { useToastContext } from '~/Providers';
import { cn } from '~/utils';
import store from '~/store';
interface PopoverButtonProps {
children: React.ReactNode;
setting: ForkOptions;
onClick: (setting: ForkOptions) => void;
setActiveSetting: React.Dispatch<React.SetStateAction<TranslationKeys>>;
timeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
hoverInfo?: React.ReactNode | string;
hoverTitle?: React.ReactNode | string;
hoverDescription?: React.ReactNode | string;
label: string;
}
const optionLabels: Record<ForkOptions, TranslationKeys> = {
[ForkOptions.DIRECT_PATH]: 'com_ui_fork_visible',
[ForkOptions.INCLUDE_BRANCHES]: 'com_ui_fork_branches',
[ForkOptions.TARGET_LEVEL]: 'com_ui_fork_all_target',
[ForkOptions.DEFAULT]: 'com_ui_fork_from_message',
};
const chevronDown = (
<svg width="1em" height="1em" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
</svg>
);
const PopoverButton: React.FC<PopoverButtonProps> = ({
children,
setting,
onClick,
setActiveSetting,
timeoutRef,
hoverInfo,
hoverTitle,
hoverDescription,
label,
}) => {
const localize = useLocalize();
return (
<Ariakit.HovercardProvider placement="right-start">
<div className="flex flex-col items-center">
<Ariakit.HovercardAnchor
render={
<Ariakit.Button
onClick={() => onClick(setting)}
onMouseEnter={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setActiveSetting(optionLabels[setting]);
}}
onMouseLeave={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setActiveSetting(optionLabels[ForkOptions.DEFAULT]);
}, 175);
}}
className="mx-0.5 w-14 flex-1 rounded-xl border-2 border-border-medium bg-surface-secondary text-text-secondary transition duration-200 ease-in-out hover:bg-surface-hover hover:text-text-primary"
aria-label={label}
>
{children}
<VisuallyHidden>{label}</VisuallyHidden>
</Ariakit.Button>
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>
{localize('com_ui_fork_more_details_about', { 0: label })}
</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
{((hoverInfo != null && hoverInfo !== '') ||
(hoverTitle != null && hoverTitle !== '') ||
(hoverDescription != null && hoverDescription !== '')) && (
<Ariakit.Hovercard
gutter={16}
shift={40}
flip={false}
className="z-[999] w-80 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="space-y-2">
<p className="flex flex-col gap-2 text-sm text-text-secondary">
{hoverInfo && hoverInfo}
{hoverTitle && <span className="flex flex-wrap gap-1 font-bold">{hoverTitle}</span>}
{hoverDescription && hoverDescription}
</p>
</div>
</Ariakit.Hovercard>
)}
</div>
</Ariakit.HovercardProvider>
);
};
interface CheckboxOptionProps {
id: string;
checked: boolean;
onToggle: (checked: boolean) => void;
labelKey: TranslationKeys;
infoKey: TranslationKeys;
showToastOnCheck?: boolean;
}
const CheckboxOption: React.FC<CheckboxOptionProps> = ({
id,
checked,
onToggle,
labelKey,
infoKey,
showToastOnCheck = false,
}) => {
const localize = useLocalize();
const { showToast } = useToastContext();
return (
<Ariakit.HovercardProvider placement="right-start">
<div className="flex items-center">
<div className="flex h-6 w-full select-none items-center justify-start rounded-md text-sm text-text-secondary hover:text-text-primary">
<Ariakit.HovercardAnchor
render={
<div>
<Ariakit.Checkbox
id={id}
checked={checked}
onChange={(e) => {
const value = e.target.checked;
if (value && showToastOnCheck) {
showToast({
message: localize('com_ui_fork_remember_checked'),
status: 'info',
});
}
onToggle(value);
}}
className="h-4 w-4 rounded-sm border border-primary ring-offset-background transition duration-300 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
aria-label={localize(labelKey)}
/>
<label htmlFor={id} className="ml-2 cursor-pointer">
{localize(labelKey)}
</label>
</div>
}
/>
</div>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>{localize(infoKey)}</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
gutter={14}
shift={40}
flip={false}
className="z-[999] w-80 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="space-y-2">
<p className="text-sm text-text-secondary">{localize(infoKey)}</p>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
);
};
export default function Fork({
messageId,
conversationId: _convoId,
forkingSupported = false,
latestMessageId,
isLast = false,
}: {
messageId: string;
conversationId: string | null;
forkingSupported?: boolean;
latestMessageId?: string;
isLast?: boolean;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [remember, setRemember] = useState(false);
const { navigateToConvo } = useNavigateToConvo();
const [isActive, setIsActive] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const [forkSetting, setForkSetting] = useRecoilState(store.forkSetting);
const [activeSetting, setActiveSetting] = useState(optionLabels.default);
const [splitAtTarget, setSplitAtTarget] = useRecoilState(store.splitAtTarget);
const [rememberGlobal, setRememberGlobal] = useRecoilState(store.rememberDefaultFork);
const popoverStore = Ariakit.usePopoverStore({
placement: 'bottom',
});
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',
isActive && 'active text-gray-700 dark:text-gray-200 bg-gray-100 bg-gray-700',
);
const forkConvo = useForkConvoMutation({
onSuccess: (data) => {
navigateToConvo(data.conversation);
showToast({
message: localize('com_ui_fork_success'),
status: 'success',
});
},
onMutate: () => {
showToast({
message: localize('com_ui_fork_processing'),
status: 'info',
});
},
onError: () => {
showToast({
message: localize('com_ui_fork_error'),
status: 'error',
});
},
});
const conversationId = _convoId ?? '';
if (!forkingSupported || !conversationId || !messageId) {
return null;
}
const onClick = (option: string) => {
if (remember) {
setRememberGlobal(true);
setForkSetting(option);
}
forkConvo.mutate({
messageId,
conversationId,
option,
splitAtTarget,
latestMessageId,
});
};
const forkOptionsConfig = [
{
setting: ForkOptions.DIRECT_PATH,
label: localize(optionLabels[ForkOptions.DIRECT_PATH]),
icon: <GitCommit className="h-full w-full rotate-90 p-2" />,
hoverTitle: (
<>
<GitCommit className="h-5 w-5 rotate-90" />
{localize(optionLabels[ForkOptions.DIRECT_PATH])}
</>
),
hoverDescription: localize('com_ui_fork_info_visible'),
},
{
setting: ForkOptions.INCLUDE_BRANCHES,
label: localize(optionLabels[ForkOptions.INCLUDE_BRANCHES]),
icon: <GitBranchPlus className="h-full w-full rotate-180 p-2" />,
hoverTitle: (
<>
<GitBranchPlus className="h-4 w-4 rotate-180" />
{localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
</>
),
hoverDescription: localize('com_ui_fork_info_branches'),
},
{
setting: ForkOptions.TARGET_LEVEL,
label: localize(optionLabels[ForkOptions.TARGET_LEVEL]),
icon: <ListTree className="h-full w-full p-2" />,
hoverTitle: (
<>
<ListTree className="h-5 w-5" />
{`${localize(optionLabels[ForkOptions.TARGET_LEVEL])} (${localize('com_endpoint_default')})`}
</>
),
hoverDescription: localize('com_ui_fork_info_target'),
},
];
return (
<>
<Ariakit.PopoverAnchor
store={popoverStore}
render={
<button
className={buttonStyle}
onClick={(e) => {
if (rememberGlobal) {
e.preventDefault();
forkConvo.mutate({
messageId,
splitAtTarget,
conversationId,
option: forkSetting,
latestMessageId,
});
} else {
popoverStore.toggle();
setIsActive(popoverStore.getState().open);
}
}}
type="button"
aria-label={localize('com_ui_fork')}
>
<GitFork size="19" />
</button>
}
/>
<Ariakit.Popover
store={popoverStore}
gutter={10}
className={`popover-animate ${isActive ? 'open' : ''} flex w-60 flex-col gap-3 overflow-hidden rounded-2xl border border-border-medium bg-surface-secondary p-2 px-4 shadow-lg`}
style={{
outline: 'none',
pointerEvents: 'auto',
zIndex: 50,
}}
portal={true}
unmountOnHide={true}
onClose={() => setIsActive(false)}
>
<div className="flex h-8 w-full items-center justify-center text-sm text-text-primary">
{localize(activeSetting)}
<Ariakit.HovercardProvider placement="right-start">
<div className="ml-auto flex h-6 w-6 items-center justify-center gap-1">
<Ariakit.HovercardAnchor
render={
<button
className="flex h-5 w-5 cursor-help items-center rounded-full text-text-secondary"
aria-label={localize('com_ui_fork_info_button_label')}
>
<InfoIcon />
</button>
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>{localize('com_ui_fork_more_info_options')}</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
gutter={19}
shift={40}
flip={false}
className="z-[999] w-80 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="flex flex-col gap-2 space-y-2 text-sm text-text-secondary">
<span>{localize('com_ui_fork_info_1')}</span>
<span>{localize('com_ui_fork_info_2')}</span>
<span>
{localize('com_ui_fork_info_3', {
0: localize('com_ui_fork_split_target'),
})}
</span>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
</div>
<div className="flex h-full w-full items-center justify-center gap-1">
{forkOptionsConfig.map((opt) => (
<PopoverButton
key={opt.setting}
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={opt.setting}
label={opt.label}
hoverTitle={opt.hoverTitle}
hoverDescription={opt.hoverDescription}
>
{opt.icon}
</PopoverButton>
))}
</div>
<CheckboxOption
id="split-target-checkbox"
checked={splitAtTarget}
onToggle={setSplitAtTarget}
labelKey="com_ui_fork_split_target"
infoKey="com_ui_fork_info_start"
/>
<CheckboxOption
id="remember-checkbox"
checked={remember}
onToggle={(checked) => {
if (checked)
showToast({ message: localize('com_ui_fork_remember_checked'), status: 'info' });
setRemember(checked);
}}
labelKey="com_ui_fork_remember"
infoKey="com_ui_fork_info_remember"
showToastOnCheck
/>
</Ariakit.Popover>
</>
);
}

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);

View file

@ -1,6 +1,6 @@
import React, { useCallback, useMemo, memo } from 'react';
import { useRecoilValue } from 'recoil';
import { useCallback, useMemo, memo } from 'react';
import type { TMessage } from 'librechat-data-provider';
import { type TMessage } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
@ -51,13 +51,13 @@ const MessageRender = memo(
copyToClipboard,
setLatestMessage,
regenerateMessage,
handleFeedback,
} = useMessageActions({
message: msg,
currentEditId,
isMultiMessage,
setCurrentEditId,
});
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
@ -206,6 +206,7 @@ const MessageRender = memo(
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
handleFeedback={handleFeedback}
isLast={isLast}
/>
</SubRow>

View file

@ -42,7 +42,7 @@ export function TemporaryChat() {
render={
<button
onClick={handleBadgeToggle}
aria-label={localize(temporaryBadge.label)}
aria-label={localize(temporaryBadge.label)}
className={cn(
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light text-text-primary transition-all ease-in-out hover:bg-surface-tertiary',
isTemporary

View file

@ -1,408 +0,0 @@
import React, { useState, useRef } from 'react';
import { useRecoilState } from 'recoil';
import * as Ariakit from '@ariakit/react';
import { VisuallyHidden } from '@ariakit/react';
import { GitFork, InfoIcon } from 'lucide-react';
import { ForkOptions } from 'librechat-data-provider';
import { GitCommit, GitBranchPlus, ListTree } from 'lucide-react';
import { TranslationKeys, useLocalize, useNavigateToConvo } from '~/hooks';
import { useForkConvoMutation } from '~/data-provider';
import { useToastContext } from '~/Providers';
import { cn } from '~/utils';
import store from '~/store';
interface PopoverButtonProps {
children: React.ReactNode;
setting: ForkOptions;
onClick: (setting: ForkOptions) => void;
setActiveSetting: React.Dispatch<React.SetStateAction<TranslationKeys>>;
timeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
hoverInfo?: React.ReactNode | string;
hoverTitle?: React.ReactNode | string;
hoverDescription?: React.ReactNode | string;
label: string;
}
const optionLabels: Record<ForkOptions, TranslationKeys> = {
[ForkOptions.DIRECT_PATH]: 'com_ui_fork_visible',
[ForkOptions.INCLUDE_BRANCHES]: 'com_ui_fork_branches',
[ForkOptions.TARGET_LEVEL]: 'com_ui_fork_all_target',
[ForkOptions.DEFAULT]: 'com_ui_fork_from_message',
};
const chevronDown = (
<svg width="1em" height="1em" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
</svg>
);
const PopoverButton: React.FC<PopoverButtonProps> = ({
children,
setting,
onClick,
setActiveSetting,
timeoutRef,
hoverInfo,
hoverTitle,
hoverDescription,
label,
}) => {
const localize = useLocalize();
return (
<Ariakit.HovercardProvider>
<div className="flex flex-col items-center">
<Ariakit.HovercardAnchor
render={
<Ariakit.Button
onClick={() => onClick(setting)}
onMouseEnter={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setActiveSetting(optionLabels[setting]);
}}
onMouseLeave={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setActiveSetting(optionLabels[ForkOptions.DEFAULT]);
}, 175);
}}
className="mx-1 max-w-14 flex-1 rounded-lg border-2 border-border-medium bg-surface-secondary text-text-secondary transition duration-300 ease-in-out hover:border-border-xheavy hover:bg-surface-hover hover:text-text-primary"
aria-label={label}
>
{children}
<VisuallyHidden>{label}</VisuallyHidden>
</Ariakit.Button>
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>
{localize('com_ui_fork_more_details_about', { 0: label })}
</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
{((hoverInfo != null && hoverInfo !== '') ||
(hoverTitle != null && hoverTitle !== '') ||
(hoverDescription != null && hoverDescription !== '')) && (
<Ariakit.Hovercard
gutter={16}
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="space-y-2">
<p className="flex flex-col gap-2 text-sm text-text-secondary">
{hoverInfo && hoverInfo}
{hoverTitle && <span className="flex flex-wrap gap-1 font-bold">{hoverTitle}</span>}
{hoverDescription && hoverDescription}
</p>
</div>
</Ariakit.Hovercard>
)}
</div>
</Ariakit.HovercardProvider>
);
};
export default function Fork({
isLast = false,
messageId,
conversationId: _convoId,
forkingSupported = false,
latestMessageId,
}: {
isLast?: boolean;
messageId: string;
conversationId: string | null;
forkingSupported?: boolean;
latestMessageId?: string;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [remember, setRemember] = useState(false);
const { navigateToConvo } = useNavigateToConvo();
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const [forkSetting, setForkSetting] = useRecoilState(store.forkSetting);
const [activeSetting, setActiveSetting] = useState(optionLabels.default);
const [splitAtTarget, setSplitAtTarget] = useRecoilState(store.splitAtTarget);
const [rememberGlobal, setRememberGlobal] = useRecoilState(store.rememberDefaultFork);
const popoverStore = Ariakit.usePopoverStore({
placement: 'top',
});
const forkConvo = useForkConvoMutation({
onSuccess: (data) => {
navigateToConvo(data.conversation);
showToast({
message: localize('com_ui_fork_success'),
status: 'success',
});
},
onMutate: () => {
showToast({
message: localize('com_ui_fork_processing'),
status: 'info',
});
},
onError: () => {
showToast({
message: localize('com_ui_fork_error'),
status: 'error',
});
},
});
const conversationId = _convoId ?? '';
if (!forkingSupported || !conversationId || !messageId) {
return null;
}
const onClick = (option: string) => {
if (remember) {
setRememberGlobal(true);
setForkSetting(option);
}
forkConvo.mutate({
messageId,
conversationId,
option,
splitAtTarget,
latestMessageId,
});
};
return (
<>
<Ariakit.PopoverAnchor
store={popoverStore}
render={
<button
className={cn(
'hover-button active rounded-md p-1 text-gray-500 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:invisible md:group-hover:visible',
'data-[state=open]:active focus:opacity-100 data-[state=open]:bg-gray-100 data-[state=open]:text-gray-500 data-[state=open]:dark:bg-gray-700 data-[state=open]:dark:text-gray-200',
!isLast
? 'data-[state=open]:opacity-100 md:opacity-0 md:group-hover:opacity-100'
: '',
)}
onClick={(e) => {
if (rememberGlobal) {
e.preventDefault();
forkConvo.mutate({
messageId,
splitAtTarget,
conversationId,
option: forkSetting,
latestMessageId,
});
} else {
popoverStore.toggle();
}
}}
type="button"
aria-label={localize('com_ui_fork')}
>
<GitFork className="h-4 w-4 hover:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
}
/>
<Ariakit.Popover
store={popoverStore}
gutter={5}
className="flex min-h-[120px] min-w-[215px] flex-col gap-3 overflow-hidden rounded-lg border border-border-heavy bg-surface-secondary p-2 px-3 shadow-lg"
style={{
outline: 'none',
pointerEvents: 'auto',
zIndex: 50,
}}
portal={true}
unmountOnHide={true}
>
<div className="flex h-8 w-full items-center justify-center text-sm text-text-primary">
{localize(activeSetting)}
<Ariakit.HovercardProvider>
<div className="ml-auto flex h-6 w-6 items-center justify-center gap-1">
<Ariakit.HovercardAnchor
render={
<button
className="flex h-5 w-5 items-center rounded-full text-text-secondary"
aria-label={localize('com_ui_fork_info_button_label')}
>
<InfoIcon />
</button>
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>{localize('com_ui_fork_more_info_options')}</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
gutter={19}
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="flex flex-col gap-2 space-y-2 text-sm text-text-secondary">
<span>{localize('com_ui_fork_info_1')}</span>
<span>{localize('com_ui_fork_info_2')}</span>
<span>
{localize('com_ui_fork_info_3', {
0: localize('com_ui_fork_split_target'),
})}
</span>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
</div>
<div className="flex h-full w-full items-center justify-center gap-1">
<PopoverButton
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.DIRECT_PATH}
label={localize(optionLabels[ForkOptions.DIRECT_PATH])}
hoverTitle={
<>
<GitCommit className="h-5 w-5 rotate-90" />
{localize(optionLabels[ForkOptions.DIRECT_PATH])}
</>
}
hoverDescription={localize('com_ui_fork_info_visible')}
>
<GitCommit className="h-full w-full rotate-90 p-2" />
</PopoverButton>
<PopoverButton
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.INCLUDE_BRANCHES}
label={localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
hoverTitle={
<>
<GitBranchPlus className="h-4 w-4 rotate-180" />
{localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
</>
}
hoverDescription={localize('com_ui_fork_info_branches')}
>
<GitBranchPlus className="h-full w-full rotate-180 p-2" />
</PopoverButton>
<PopoverButton
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.TARGET_LEVEL}
label={localize(optionLabels[ForkOptions.TARGET_LEVEL])}
hoverTitle={
<>
<ListTree className="h-5 w-5" />
{`${localize(
optionLabels[ForkOptions.TARGET_LEVEL],
)} (${localize('com_endpoint_default')})`}
</>
}
hoverDescription={localize('com_ui_fork_info_target')}
>
<ListTree className="h-full w-full p-2" />
</PopoverButton>
</div>
<Ariakit.HovercardProvider>
<div className="flex items-center">
<Ariakit.HovercardAnchor
render={
<div className="flex h-6 w-full select-none items-center justify-start rounded-md text-sm text-text-secondary hover:text-text-primary">
<Ariakit.Checkbox
id="split-target-checkbox"
checked={splitAtTarget}
onChange={(event) => setSplitAtTarget(event.target.checked)}
className="m-2 h-4 w-4 rounded-sm border border-primary ring-offset-background transition duration-300 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
aria-label={localize('com_ui_fork_split_target')}
/>
<label htmlFor="split-target-checkbox" className="ml-2 cursor-pointer">
{localize('com_ui_fork_split_target')}
</label>
</div>
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>
{localize('com_ui_fork_more_info_split_target', {
0: localize('com_ui_fork_split_target'),
})}
</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
gutter={32}
className="z-[999] w-80 select-none rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="space-y-2">
<p className="text-sm text-text-secondary">{localize('com_ui_fork_info_start')}</p>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
<Ariakit.HovercardProvider>
<div className="flex items-center">
<Ariakit.HovercardAnchor
render={
<div
onClick={() => setRemember((prev) => !prev)}
className="flex h-6 w-full select-none items-center justify-start rounded-md text-sm text-text-secondary hover:text-text-primary"
>
<Ariakit.Checkbox
id="remember-checkbox"
checked={remember}
onChange={(event) => {
const checked = event.target.checked;
console.log('checked', checked);
if (checked) {
showToast({
message: localize('com_ui_fork_remember_checked'),
status: 'info',
});
}
return setRemember(checked);
}}
className="m-2 h-4 w-4 rounded-sm border border-primary ring-offset-background transition duration-300 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
aria-label={localize('com_ui_fork_remember')}
/>
<label htmlFor="remember-checkbox" className="ml-2 cursor-pointer">
{localize('com_ui_fork_remember')}
</label>
</div>
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>
{localize('com_ui_fork_more_info_remember', {
0: localize('com_ui_fork_remember'),
})}
</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
gutter={14}
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="space-y-2">
<p className="text-sm text-text-secondary">{localize('com_ui_fork_info_remember')}</p>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
</Ariakit.Popover>
</>
);
}

View file

@ -1,4 +1,4 @@
export { default as Fork } from './Fork';
export { default as Fork } from '../Chat/Messages/Fork';
export { default as Pages } from './Pages';
export { default as Conversations } from './Conversations';
export * from './ConvoOptions';

View file

@ -1,15 +0,0 @@
import type { TGenButtonProps } from '~/common';
import { ContinueIcon } from '~/components/svg';
import Button from './Button';
import { useLocalize } from '~/hooks';
export default function Continue({ onClick }: TGenButtonProps) {
const localize = useLocalize();
return (
<Button type="continue" onClick={onClick}>
<ContinueIcon className="text-gray-600/90 dark:text-gray-400 " />
{localize('com_ui_continue')}
</Button>
);
}

View file

@ -1,22 +0,0 @@
import { render, fireEvent } from 'test/layout-test-utils';
import Continue from '../Continue';
describe('Continue', () => {
it('should render the Continue button', () => {
const { getByText } = render(
<Continue
onClick={() => {
('');
}}
/>,
);
expect(getByText('Continue')).toBeInTheDocument();
});
it('should call onClick when the button is clicked', () => {
const handleClick = jest.fn();
const { getByText } = render(<Continue onClick={handleClick} />);
fireEvent.click(getByText('Continue'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});

View file

@ -52,6 +52,7 @@ const ContentRender = memo(
copyToClipboard,
setLatestMessage,
regenerateMessage,
handleFeedback,
} = useMessageActions({
message: msg,
searchResults,
@ -59,7 +60,6 @@ const ContentRender = memo(
isMultiMessage,
setCurrentEditId,
});
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
@ -199,6 +199,7 @@ const ContentRender = memo(
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
handleFeedback={handleFeedback}
isLast={isLast}
/>
</SubRow>

View file

@ -1,34 +0,0 @@
import { useRecoilState } from 'recoil';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function AutoScrollSwitch({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const [autoScroll, setAutoScroll] = useRecoilState<boolean>(store.autoScroll);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setAutoScroll(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div> {localize('com_nav_auto_scroll')} </div>
<Switch
id="autoScroll"
checked={autoScroll}
aria-label="Auto-Scroll to latest message on chat open"
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="autoScroll"
/>
</div>
);
}

View file

@ -1,10 +1,7 @@
import { useRecoilState } from 'recoil';
import Cookies from 'js-cookie';
import React, { useContext, useCallback } from 'react';
import UserMsgMarkdownSwitch from './UserMsgMarkdownSwitch';
import HideSidePanelSwitch from './HideSidePanelSwitch';
import Cookies from 'js-cookie';
import { useRecoilState } from 'recoil';
import { ThemeContext, useLocalize } from '~/hooks';
import AutoScrollSwitch from './AutoScrollSwitch';
import ArchivedChats from './ArchivedChats';
import ToggleSwitch from '../ToggleSwitch';
import { Dropdown } from '~/components';

View file

@ -1,35 +0,0 @@
import { useRecoilState } from 'recoil';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function HideSidePanelSwitch({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const [hideSidePanel, setHideSidePanel] = useRecoilState<boolean>(store.hideSidePanel);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setHideSidePanel(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_hide_panel')}</div>
<Switch
id="hideSidePanel"
checked={hideSidePanel}
aria-label="Hide right-most side panel"
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="hideSidePanel"
/>
</div>
);
}

View file

@ -1,35 +0,0 @@
import { useRecoilState } from 'recoil';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function UserMsgMarkdownSwitch({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const localize = useLocalize();
const [enableUserMsgMarkdown, setEnableUserMsgMarkdown] = useRecoilState<boolean>(
store.enableUserMsgMarkdown,
);
const handleCheckedChange = (value: boolean) => {
setEnableUserMsgMarkdown(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div> {localize('com_nav_user_msg_markdown')} </div>
<Switch
id="enableUserMsgMarkdown"
checked={enableUserMsgMarkdown}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="enableUserMsgMarkdown"
/>
</div>
);
}

View file

@ -14,9 +14,9 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
const endpointOptions = external
? [
{ value: 'browser', label: localize('com_nav_browser') },
{ value: 'external', label: localize('com_nav_external') },
]
{ value: 'browser', label: localize('com_nav_browser') },
{ value: 'external', label: localize('com_nav_external') },
]
: [{ value: 'browser', label: localize('com_nav_browser') }];
const handleSelect = (value: string) => {

View file

@ -14,9 +14,9 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
const endpointOptions = external
? [
{ value: 'browser', label: localize('com_nav_browser') },
{ value: 'external', label: localize('com_nav_external') },
]
{ value: 'browser', label: localize('com_nav_browser') },
{ value: 'external', label: localize('com_nav_external') },
]
: [{ value: 'browser', label: localize('com_nav_browser') }];
const handleSelect = (value: string) => {

View file

@ -10,8 +10,8 @@ export default function ContinueIcon({ className = '' }: { className?: string })
strokeLinecap="round"
strokeLinejoin="round"
className={cn('h-3 w-3 -rotate-180', className)}
height="1em"
width="1em"
height="19"
width="19"
xmlns="http://www.w3.org/2000/svg"
>
<polygon points="11 19 2 12 11 5 11 19" />

View file

@ -0,0 +1,39 @@
import { cn } from '~/utils';
export default function ThumbDownIcon({ className = '', size = '1em', bold = false }) {
return bold ? (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size}
width={size}
fill="none"
viewBox="0 0 24 24"
className={className}
>
<path
d="M11.4079 21.4961C11.1953 21.8698 10.7683 22.0649 10.348 21.9805C8.4373 21.5968 7.27541 19.6391 7.84844 17.7691L8.69697 14.9999L6.98577 14.9999C4.35915 14.9999 2.45151 12.492 3.14262 9.94747L4.50063 4.94747C4.97329 3.20722 6.54741 1.99994 8.34378 1.99994H14.0328C15.131 2.00207 16.0206 2.89668 16.0206 3.99994V14.9999H15.6827C15.3253 14.9999 14.9953 15.1922 14.818 15.5038L11.4079 21.4961Z"
fill="currentColor"
></path>
<path
d="M18.0124 14.9999C19.6624 14.9999 21 13.6568 21 11.9999V4.99994C21 3.34308 19.6624 1.99994 18.0124 1.99994H17.4794C17.8184 2.58829 18.0124 3.27136 18.0124 3.99994V14.9999Z"
fill="currentColor"
></path>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size}
width={size}
fill="none"
viewBox="0 0 24 24"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.8727 21.4961C11.6725 21.8466 11.2811 22.0423 10.8805 21.9922L10.4267 21.9355C7.95958 21.6271 6.36855 19.1665 7.09975 16.7901L7.65054 15H6.93226C4.29476 15 2.37923 12.4921 3.0732 9.94753L4.43684 4.94753C4.91145 3.20728 6.49209 2 8.29589 2H18.0045C19.6614 2 21.0045 3.34315 21.0045 5V12C21.0045 13.6569 19.6614 15 18.0045 15H16.0045C15.745 15 15.5054 15.1391 15.3766 15.3644L11.8727 21.4961ZM14.0045 4H8.29589C7.39399 4 6.60367 4.60364 6.36637 5.47376L5.00273 10.4738C4.65574 11.746 5.61351 13 6.93226 13H9.00451C9.32185 13 9.62036 13.1506 9.8089 13.4059C9.99743 13.6612 10.0536 13.9908 9.96028 14.2941L9.01131 17.3782C8.6661 18.5002 9.35608 19.6596 10.4726 19.9153L13.6401 14.3721C13.9523 13.8258 14.4376 13.4141 15.0045 13.1902V5C15.0045 4.44772 14.5568 4 14.0045 4ZM17.0045 13V5C17.0045 4.64937 16.9444 4.31278 16.8338 4H18.0045C18.5568 4 19.0045 4.44772 19.0045 5V12C19.0045 12.5523 18.5568 13 18.0045 13H17.0045Z"
fill="currentColor"
></path>
</svg>
);
}

View file

@ -0,0 +1,39 @@
import { cn } from '~/utils';
export default function ThumbUpIcon({ className = '', size = '1em', bold = false }) {
return bold ? (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size}
width={size}
fill="none"
viewBox="0 0 24 24"
className={className}
>
<path
d="M12.592 2.50386C12.8047 2.13014 13.2317 1.935 13.652 2.01942C15.5627 2.40314 16.7246 4.36079 16.1516 6.23085L15.303 9L17.0142 9C19.6409 9 21.5485 11.5079 20.8574 14.0525L19.4994 19.0525C19.0267 20.7927 17.4526 22 15.6562 22H9.96721C8.869 21.9979 7.97939 21.1033 7.97939 20V9H8.31734C8.67472 9 9.0047 8.80771 9.18201 8.49613L12.592 2.50386Z"
fill="currentColor"
></path>
<path
d="M5.98763 9C4.33761 9 3 10.3431 3 12V19C3 20.6569 4.33761 22 5.98763 22H6.52055C6.18162 21.4116 5.98763 20.7286 5.98763 20V9Z"
fill="currentColor"
></path>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size}
width={size}
fill="none"
viewBox="0 0 24 24"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.1318 2.50389C12.3321 2.15338 12.7235 1.95768 13.124 2.00775L13.5778 2.06447C16.0449 2.37286 17.636 4.83353 16.9048 7.20993L16.354 8.99999H17.0722C19.7097 8.99999 21.6253 11.5079 20.9313 14.0525L19.5677 19.0525C19.0931 20.7927 17.5124 22 15.7086 22H6C4.34315 22 3 20.6568 3 19V12C3 10.3431 4.34315 8.99999 6 8.99999H8C8.25952 8.99999 8.49914 8.86094 8.6279 8.63561L12.1318 2.50389ZM10 20H15.7086C16.6105 20 17.4008 19.3964 17.6381 18.5262L19.0018 13.5262C19.3488 12.2539 18.391 11 17.0722 11H15C14.6827 11 14.3841 10.8494 14.1956 10.5941C14.0071 10.3388 13.9509 10.0092 14.0442 9.70591L14.9932 6.62175C15.3384 5.49984 14.6484 4.34036 13.5319 4.08468L10.3644 9.62789C10.0522 10.1742 9.56691 10.5859 9 10.8098V19C9 19.5523 9.44772 20 10 20ZM7 11V19C7 19.3506 7.06015 19.6872 7.17071 20H6C5.44772 20 5 19.5523 5 19V12C5 11.4477 5.44772 11 6 11H7Z"
fill="currentColor"
></path>
</svg>
);
}

View file

@ -58,4 +58,6 @@ export { default as SpeechIcon } from './SpeechIcon';
export { default as SaveIcon } from './SaveIcon';
export { default as CircleHelpIcon } from './CircleHelpIcon';
export { default as BedrockIcon } from './BedrockIcon';
export { default as ThumbUpIcon } from './ThumbUpIcon';
export { default as ThumbDownIcon } from './ThumbDownIcon';
export { default as XAIcon } from './XAIcon';

View file

@ -113,11 +113,11 @@ export const useEndpoints = ({
hasModels,
icon: Icon
? React.createElement(Icon, {
size: 20,
className: 'text-text-primary shrink-0 icon-md',
iconURL: endpointIconURL,
endpoint: ep,
})
size: 20,
className: 'text-text-primary shrink-0 icon-md',
iconURL: endpointIconURL,
endpoint: ep,
})
: null,
};

View file

@ -1,7 +1,15 @@
import { useRecoilValue } from 'recoil';
import { useCallback, useMemo } from 'react';
import { isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
import type { SearchResultData } from 'librechat-data-provider';
import { useCallback, useMemo, useState } from 'react';
import { useUpdateFeedbackMutation } from 'librechat-data-provider/react-query';
import {
isAssistantsEndpoint,
isAgentsEndpoint,
TUpdateFeedbackRequest,
getTagByKey,
TFeedback,
toMinimalFeedback,
SearchResultData,
} from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import {
useChatContext,
@ -11,7 +19,7 @@ import {
} from '~/Providers';
import useCopyToClipboard from './useCopyToClipboard';
import { useAuthContext } from '~/hooks/AuthContext';
import useLocalize from '~/hooks/useLocalize';
import { useLocalize } from '~/hooks';
import store from '~/store';
export type TMessageActions = Pick<
@ -50,6 +58,18 @@ export default function useMessageActions(props: TMessageActions) {
const { text, content, messageId = null, isCreatedByUser } = message ?? {};
const edit = useMemo(() => messageId === currentEditId, [messageId, currentEditId]);
const [feedback, setFeedback] = useState<TFeedback | undefined>(() => {
if (message?.feedback) {
const tag = getTagByKey(message.feedback?.tag?.key);
return {
rating: message.feedback.rating,
tag,
text: message.feedback.text,
};
}
return undefined;
});
const enterEdit = useCallback(
(cancel?: boolean) => setCurrentEditId && setCurrentEditId(cancel === true ? -1 : messageId),
[messageId, setCurrentEditId],
@ -113,6 +133,38 @@ export default function useMessageActions(props: TMessageActions) {
}
}, [message, agent, assistant, UsernameDisplay, user, localize]);
const feedbackMutation = useUpdateFeedbackMutation(
conversation?.conversationId || '',
message?.messageId || '',
);
const handleFeedback = useCallback(
({ feedback: newFeedback }: { feedback: TFeedback | undefined }) => {
const payload: TUpdateFeedbackRequest = {
feedback: newFeedback ? toMinimalFeedback(newFeedback) : undefined,
};
feedbackMutation.mutate(payload, {
onSuccess: (data) => {
if (!data.feedback) {
setFeedback(undefined);
} else {
const tag = getTagByKey(data.feedback?.tag ?? undefined);
setFeedback({
rating: data.feedback.rating,
tag,
text: data.feedback.text,
});
}
},
onError: (error) => {
console.error('Failed to update feedback:', error);
},
});
},
[feedbackMutation],
);
return {
ask,
edit,
@ -128,5 +180,7 @@ export default function useMessageActions(props: TMessageActions) {
copyToClipboard,
setLatestMessage,
regenerateMessage,
handleFeedback,
feedback,
};
}

View file

@ -715,8 +715,6 @@
"com_ui_fork_info_visible": "This option forks only the visible messages; in other words, the direct path to the target message, without any branches.",
"com_ui_fork_more_details_about": "View additional information and details about the \"{{0}}\" fork option",
"com_ui_fork_more_info_options": "View detailed explanation of all fork options and their behaviors",
"com_ui_fork_more_info_remember": "View explanation of how the \"{{0}}\" option saves your preferences for future forks",
"com_ui_fork_more_info_split_target": "View explanation of how the \"{{0}}\" option affects which messages are included in your fork",
"com_ui_fork_processing": "Forking conversation...",
"com_ui_fork_remember": "Remember",
"com_ui_fork_remember_checked": "Your selection will be remembered after usage. Change this at any time in the settings.",
@ -922,6 +920,22 @@
"com_ui_version_var": "Version {{0}}",
"com_ui_versions": "Versions",
"com_ui_view_source": "View source chat",
"com_ui_feedback_more_information": "Provide additional feedback",
"com_ui_feedback_positive": "Love this",
"com_ui_feedback_negative": "Needs improvement",
"com_ui_feedback_more": "More...",
"com_ui_feedback_placeholder": "Please provide any additional feedback here",
"com_ui_feedback_tag_not_matched": "Didn't match my request",
"com_ui_feedback_tag_inaccurate": "Inaccurate or incorrect answer",
"com_ui_feedback_tag_bad_style": "Poor style or tone",
"com_ui_feedback_tag_missing_image": "Expected an image",
"com_ui_feedback_tag_unjustified_refusal": "Refused without reason",
"com_ui_feedback_tag_not_helpful": "Lacked useful information",
"com_ui_feedback_tag_other": "Other issue",
"com_ui_feedback_tag_accurate_reliable": "Accurate and Reliable",
"com_ui_feedback_tag_creative_solution": "Creative Solution",
"com_ui_feedback_tag_clear_well_written": "Clear and Well-Written",
"com_ui_feedback_tag_attention_to_detail": "Attention to Detail",
"com_ui_web_search": "Web Search",
"com_ui_web_search_api_subtitle": "Search the web for up-to-date information",
"com_ui_web_search_cohere_key": "Enter Cohere API Key",

View file

@ -2548,6 +2548,18 @@ html {
margin-right: -2px;
}
.popover-animate {
opacity: 0;
transform: scale(0.95) translateY(-0.5rem);
transition:
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.popover-animate[data-enter] {
opacity: 1;
transform: scale(1) translateY(0);
}
.popover-ui:focus-visible,
.popover-ui[data-focus-visible] {
outline: var(--bg-surface-hover);

View file

@ -275,8 +275,7 @@ describe('ActionRequest', () => {
expect(config?.headers).toEqual({
'some-header': 'header-var',
});
expect(config?.params).toEqual({
});
expect(config?.params).toEqual({});
expect(response.data.success).toBe(true);
});
@ -285,13 +284,13 @@ describe('ActionRequest', () => {
const data: Record<string, unknown> = {
'api-version': '2025-01-01',
'message': 'a body parameter',
message: 'a body parameter',
'some-header': 'header-var',
};
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
'api-version': 'query',
'message': 'body',
message: 'body',
'some-header': 'header',
};
@ -326,13 +325,13 @@ describe('ActionRequest', () => {
const data: Record<string, unknown> = {
'api-version': '2025-01-01',
'message': 'a body parameter',
message: 'a body parameter',
'some-header': 'header-var',
};
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
'api-version': 'query',
'message': 'body',
message: 'body',
'some-header': 'header',
};
@ -367,13 +366,13 @@ describe('ActionRequest', () => {
const data: Record<string, unknown> = {
'api-version': '2025-01-01',
'message': 'a body parameter',
message: 'a body parameter',
'some-header': 'header-var',
};
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
'api-version': 'query',
'message': 'body',
message: 'body',
'some-header': 'header',
};
@ -443,7 +442,6 @@ describe('ActionRequest', () => {
});
expect(response.data.success).toBe(true);
});
});
it('throws an error for unsupported HTTP method', async () => {

View file

@ -303,7 +303,8 @@ class RequestExecutor {
if (this.config.parameterLocations && this.params) {
for (const key of Object.keys(this.params)) {
// Determine parameter placement; default to "query" for GET and "body" for others.
const loc: 'query' | 'path' | 'header' | 'body' = this.config.parameterLocations[key] || (method === 'get' ? 'query' : 'body');
const loc: 'query' | 'path' | 'header' | 'body' =
this.config.parameterLocations[key] || (method === 'get' ? 'query' : 'body');
const val = this.params[key];
if (loc === 'query') {
@ -351,7 +352,15 @@ export class ActionRequest {
contentType: string,
parameterLocations?: Record<string, 'query' | 'path' | 'header' | 'body'>,
) {
this.config = new RequestConfig(domain, path, method, operation, isConsequential, contentType, parameterLocations);
this.config = new RequestConfig(
domain,
path,
method,
operation,
isConsequential,
contentType,
parameterLocations,
);
}
// Add getters to maintain backward compatibility
@ -486,12 +495,12 @@ export function openapiToFunction(
}
// Record the parameter location from the OpenAPI "in" field.
paramLocations[paramName] =
(resolvedParam.in === 'query' ||
resolvedParam.in === 'path' ||
resolvedParam.in === 'header' ||
resolvedParam.in === 'body')
? resolvedParam.in
: 'query';
resolvedParam.in === 'query' ||
resolvedParam.in === 'path' ||
resolvedParam.in === 'header' ||
resolvedParam.in === 'body'
? resolvedParam.in
: 'query';
}
}

View file

@ -272,6 +272,10 @@ export const userTerms = () => '/api/user/terms';
export const acceptUserTerms = () => '/api/user/terms/accept';
export const banner = () => '/api/banner';
// Message Feedback
export const feedback = (conversationId: string, messageId: string) =>
`/api/messages/${conversationId}/${messageId}/feedback`;
// Two-Factor Endpoints
export const enableTwoFactor = () => '/api/auth/2fa/enable';
export const verifyTwoFactor = () => '/api/auth/2fa/verify';

View file

@ -765,6 +765,15 @@ export function getBanner(): Promise<t.TBannerResponse> {
return request.get(endpoints.banner());
}
export function updateFeedback(
conversationId: string,
messageId: string,
payload: t.TUpdateFeedbackRequest,
): Promise<t.TUpdateFeedbackResponse> {
return request.put(endpoints.feedback(conversationId, messageId), payload);
}
// 2FA
export function enableTwoFactor(): Promise<t.TEnable2FAResponse> {
return request.get(endpoints.enableTwoFactor());
}

View file

@ -0,0 +1,141 @@
import { z } from 'zod';
export type TFeedbackRating = 'thumbsUp' | 'thumbsDown';
export const FEEDBACK_RATINGS = ['thumbsUp', 'thumbsDown'] as const;
export const FEEDBACK_REASON_KEYS = [
// Down
'not_matched',
'inaccurate',
'bad_style',
'missing_image',
'unjustified_refusal',
'not_helpful',
'other',
// Up
'accurate_reliable',
'creative_solution',
'clear_well_written',
'attention_to_detail',
] as const;
export type TFeedbackTagKey = (typeof FEEDBACK_REASON_KEYS)[number];
export interface TFeedbackTag {
key: TFeedbackTagKey;
label: string;
direction: TFeedbackRating;
icon: string;
}
// --- Tag Registry ---
export const FEEDBACK_TAGS: TFeedbackTag[] = [
// Thumbs Down
{
key: 'not_matched',
label: 'com_ui_feedback_tag_not_matched',
direction: 'thumbsDown',
icon: 'AlertCircle',
},
{
key: 'inaccurate',
label: 'com_ui_feedback_tag_inaccurate',
direction: 'thumbsDown',
icon: 'AlertCircle',
},
{
key: 'bad_style',
label: 'com_ui_feedback_tag_bad_style',
direction: 'thumbsDown',
icon: 'PenTool',
},
{
key: 'missing_image',
label: 'com_ui_feedback_tag_missing_image',
direction: 'thumbsDown',
icon: 'ImageOff',
},
{
key: 'unjustified_refusal',
label: 'com_ui_feedback_tag_unjustified_refusal',
direction: 'thumbsDown',
icon: 'Ban',
},
{
key: 'not_helpful',
label: 'com_ui_feedback_tag_not_helpful',
direction: 'thumbsDown',
icon: 'ThumbsDown',
},
{
key: 'other',
label: 'com_ui_feedback_tag_other',
direction: 'thumbsDown',
icon: 'HelpCircle',
},
// Thumbs Up
{
key: 'accurate_reliable',
label: 'com_ui_feedback_tag_accurate_reliable',
direction: 'thumbsUp',
icon: 'CheckCircle',
},
{
key: 'creative_solution',
label: 'com_ui_feedback_tag_creative_solution',
direction: 'thumbsUp',
icon: 'Lightbulb',
},
{
key: 'clear_well_written',
label: 'com_ui_feedback_tag_clear_well_written',
direction: 'thumbsUp',
icon: 'PenTool',
},
{
key: 'attention_to_detail',
label: 'com_ui_feedback_tag_attention_to_detail',
direction: 'thumbsUp',
icon: 'Search',
},
];
export function getTagsForRating(rating: TFeedbackRating): TFeedbackTag[] {
return FEEDBACK_TAGS.filter((tag) => tag.direction === rating);
}
export const feedbackTagKeySchema = z.enum(FEEDBACK_REASON_KEYS);
export const feedbackRatingSchema = z.enum(FEEDBACK_RATINGS);
export const feedbackSchema = z.object({
rating: feedbackRatingSchema,
tag: feedbackTagKeySchema,
text: z.string().max(1024).optional(),
});
export type TMinimalFeedback = z.infer<typeof feedbackSchema>;
export type TFeedback = {
rating: TFeedbackRating;
tag: TFeedbackTag | undefined;
text?: string;
};
export function toMinimalFeedback(feedback: TFeedback | undefined): TMinimalFeedback | undefined {
if (!feedback?.rating || !feedback?.tag || !feedback.tag.key) {
return undefined;
}
return {
rating: feedback.rating,
tag: feedback.tag.key,
text: feedback.text,
};
}
export function getTagByKey(key: TFeedbackTagKey | undefined): TFeedbackTag | undefined {
if (!key) {
return undefined;
}
return FEEDBACK_TAGS.find((tag) => tag.key === key);
}

View file

@ -1,4 +1,3 @@
/* eslint-disable max-len */
import { z } from 'zod';
import { EModelEndpoint } from './schemas';
import type { FileConfig, EndpointFileConfig } from './types/files';

View file

@ -39,4 +39,6 @@ import * as dataService from './data-service';
export * from './utils';
export * from './actions';
export { default as createPayload } from './createPayload';
/* feedback */
export * from './feedback';
export * from './parameterSettings';

View file

@ -347,3 +347,19 @@ export const useGetCustomConfigSpeechQuery = (
},
);
};
export const useUpdateFeedbackMutation = (
conversationId: string,
messageId: string,
): UseMutationResult<t.TUpdateFeedbackResponse, Error, t.TUpdateFeedbackRequest> => {
const queryClient = useQueryClient();
return useMutation(
(payload: t.TUpdateFeedbackRequest) =>
dataService.updateFeedback(conversationId, messageId, payload),
{
onSuccess: () => {
queryClient.invalidateQueries([QueryKeys.messages, messageId]);
},
},
);
};

View file

@ -1,6 +1,7 @@
import { z } from 'zod';
import { Tools } from './types/assistants';
import type { TMessageContentParts, FunctionTool, FunctionToolCall } from './types/assistants';
import { TFeedback, feedbackSchema } from './feedback';
import type { SearchResultData } from './types/web';
import type { TEphemeralAgent } from './types';
import type { TFile } from './types/files';
@ -518,6 +519,7 @@ export const tMessageSchema = z.object({
thread_id: z.string().optional(),
/* frontend components */
iconURL: z.string().nullable().optional(),
feedback: feedbackSchema.optional(),
});
export type TAttachmentMetadata = {
@ -543,6 +545,7 @@ export type TMessage = z.input<typeof tMessageSchema> & {
siblingIndex?: number;
attachments?: TAttachment[];
clientTimestamp?: string;
feedback?: TFeedback;
};
export const coerceNumber = z.union([z.number(), z.string()]).transform((val) => {

View file

@ -10,7 +10,9 @@ import type {
TConversationTag,
TBanner,
} from './schemas';
import { TMinimalFeedback } from './feedback';
import { SettingDefinition } from './generate';
export type TOpenAIMessage = OpenAI.Chat.ChatCompletionMessageParam;
export * from './schemas';
@ -547,6 +549,16 @@ export type TAcceptTermsResponse = {
export type TBannerResponse = TBanner | null;
export type TUpdateFeedbackRequest = {
feedback?: TMinimalFeedback;
};
export type TUpdateFeedbackResponse = {
messageId: string;
conversationId: string;
feedback?: TMinimalFeedback;
}
export type TBalanceResponse = {
tokenCredits: number;
// Automatic refill settings

View file

@ -264,19 +264,19 @@ describe('convertJsonSchemaToZod', () => {
properties: {
name: {
type: 'string',
description: 'The user\'s name',
description: "The user's name",
},
age: {
type: 'number',
description: 'The user\'s age',
description: "The user's age",
},
},
};
const zodSchema = convertJsonSchemaToZod(schema);
const shape = (zodSchema as z.ZodObject<any>).shape;
expect(shape.name.description).toBe('The user\'s name');
expect(shape.age.description).toBe('The user\'s age');
expect(shape.name.description).toBe("The user's name");
expect(shape.age.description).toBe("The user's age");
});
it('should preserve descriptions in nested objects', () => {
@ -290,7 +290,7 @@ describe('convertJsonSchemaToZod', () => {
properties: {
name: {
type: 'string',
description: 'The user\'s name',
description: "The user's name",
},
settings: {
type: 'object',
@ -318,7 +318,7 @@ describe('convertJsonSchemaToZod', () => {
const userShape = shape.user instanceof z.ZodObject ? shape.user.shape : {};
if ('name' in userShape && 'settings' in userShape) {
expect(userShape.name.description).toBe('The user\'s name');
expect(userShape.name.description).toBe("The user's name");
expect(userShape.settings.description).toBe('User preferences');
const settingsShape =
@ -682,10 +682,7 @@ describe('convertJsonSchemaToZod', () => {
name: { type: 'string' },
age: { type: 'number' },
},
anyOf: [
{ required: ['name'] },
{ required: ['age'] },
],
anyOf: [{ required: ['name'] }, { required: ['age'] }],
oneOf: [
{ properties: { role: { type: 'string', enum: ['admin'] } } },
{ properties: { role: { type: 'string', enum: ['user'] } } },
@ -708,7 +705,7 @@ describe('convertJsonSchemaToZod', () => {
it('should drop fields from nested schemas', () => {
// Create a schema with nested fields that should be dropped
const schema: JsonSchemaType & {
properties?: Record<string, JsonSchemaType & { anyOf?: any; oneOf?: any }>
properties?: Record<string, JsonSchemaType & { anyOf?: any; oneOf?: any }>;
} = {
type: 'object',
properties: {
@ -718,10 +715,7 @@ describe('convertJsonSchemaToZod', () => {
name: { type: 'string' },
role: { type: 'string' },
},
anyOf: [
{ required: ['name'] },
{ required: ['role'] },
],
anyOf: [{ required: ['name'] }, { required: ['role'] }],
},
settings: {
type: 'object',
@ -742,20 +736,24 @@ describe('convertJsonSchemaToZod', () => {
});
// The schema should still validate normal properties
expect(zodSchema?.parse({
user: { name: 'John', role: 'admin' },
settings: { theme: 'custom' }, // This would fail if oneOf was still present
})).toEqual({
expect(
zodSchema?.parse({
user: { name: 'John', role: 'admin' },
settings: { theme: 'custom' }, // This would fail if oneOf was still present
}),
).toEqual({
user: { name: 'John', role: 'admin' },
settings: { theme: 'custom' },
});
// But the anyOf constraint should be gone from user
// (If it was present, this would fail because neither name nor role is required)
expect(zodSchema?.parse({
user: {},
settings: { theme: 'light' },
})).toEqual({
expect(
zodSchema?.parse({
user: {},
settings: { theme: 'light' },
}),
).toEqual({
user: {},
settings: { theme: 'light' },
});
@ -803,10 +801,7 @@ describe('convertJsonSchemaToZod', () => {
anyOf: [{ minItems: 1 }],
},
},
oneOf: [
{ required: ['name', 'permissions'] },
{ required: ['name'] },
],
oneOf: [{ required: ['name', 'permissions'] }, { required: ['name'] }],
},
},
},
@ -871,10 +866,7 @@ describe('convertJsonSchemaToZod', () => {
const schema = {
type: 'object', // Add a type to satisfy JsonSchemaType
properties: {}, // Empty properties
oneOf: [
{ type: 'string' },
{ type: 'number' },
],
oneOf: [{ type: 'string' }, { type: 'number' }],
} as JsonSchemaType & { oneOf?: any };
// Convert with transformOneOfAnyOf option
@ -893,10 +885,7 @@ describe('convertJsonSchemaToZod', () => {
const schema = {
type: 'object', // Add a type to satisfy JsonSchemaType
properties: {}, // Empty properties
anyOf: [
{ type: 'string' },
{ type: 'number' },
],
anyOf: [{ type: 'string' }, { type: 'number' }],
} as JsonSchemaType & { anyOf?: any };
// Convert with transformOneOfAnyOf option
@ -956,10 +945,7 @@ describe('convertJsonSchemaToZod', () => {
properties: {
value: { type: 'string' },
},
oneOf: [
{ required: ['value'] },
{ properties: { optional: { type: 'boolean' } } },
],
oneOf: [{ required: ['value'] }, { properties: { optional: { type: 'boolean' } } }],
} as JsonSchemaType & { oneOf?: any };
// Convert with transformOneOfAnyOf option
@ -1013,9 +999,12 @@ describe('convertJsonSchemaToZod', () => {
},
},
} as JsonSchemaType & {
properties?: Record<string, JsonSchemaType & {
properties?: Record<string, JsonSchemaType & { oneOf?: any }>
}>
properties?: Record<
string,
JsonSchemaType & {
properties?: Record<string, JsonSchemaType & { oneOf?: any }>;
}
>;
};
// Convert with transformOneOfAnyOf option
@ -1024,14 +1013,16 @@ describe('convertJsonSchemaToZod', () => {
});
// The schema should validate nested unions
expect(zodSchema?.parse({
user: {
contact: {
type: 'email',
email: 'test@example.com',
expect(
zodSchema?.parse({
user: {
contact: {
type: 'email',
email: 'test@example.com',
},
},
},
})).toEqual({
}),
).toEqual({
user: {
contact: {
type: 'email',
@ -1040,14 +1031,16 @@ describe('convertJsonSchemaToZod', () => {
},
});
expect(zodSchema?.parse({
user: {
contact: {
type: 'phone',
phone: '123-456-7890',
expect(
zodSchema?.parse({
user: {
contact: {
type: 'phone',
phone: '123-456-7890',
},
},
},
})).toEqual({
}),
).toEqual({
user: {
contact: {
type: 'phone',
@ -1057,14 +1050,16 @@ describe('convertJsonSchemaToZod', () => {
});
// Should reject invalid contact types
expect(() => zodSchema?.parse({
user: {
contact: {
type: 'email',
phone: '123-456-7890', // Missing email, has phone instead
expect(() =>
zodSchema?.parse({
user: {
contact: {
type: 'email',
phone: '123-456-7890', // Missing email, has phone instead
},
},
},
})).toThrow();
}),
).toThrow();
});
it('should work with dropFields option', () => {
@ -1072,10 +1067,7 @@ describe('convertJsonSchemaToZod', () => {
const schema = {
type: 'object', // Add a type to satisfy JsonSchemaType
properties: {}, // Empty properties
oneOf: [
{ type: 'string' },
{ type: 'number' },
],
oneOf: [{ type: 'string' }, { type: 'number' }],
deprecated: true, // Field to drop
} as JsonSchemaType & { oneOf?: any; deprecated?: boolean };

View file

@ -1,4 +1,5 @@
import mongoose, { Schema, Document } from 'mongoose';
import { TFeedbackRating, TFeedbackTag } from 'librechat-data-provider';
// @ts-ignore
export interface IMessage extends Document {
@ -20,6 +21,11 @@ export interface IMessage extends Document {
unfinished?: boolean;
error?: boolean;
finish_reason?: string;
feedback?: {
rating: TFeedbackRating;
tag: TFeedbackTag | undefined;
text?: string;
};
_meiliIndex?: boolean;
files?: unknown[];
plugin?: {
@ -110,6 +116,25 @@ const messageSchema: Schema<IMessage> = new Schema(
finish_reason: {
type: String,
},
feedback: {
type: {
rating: {
type: String,
enum: ['thumbsUp', 'thumbsDown'],
required: true,
},
tag: {
type: mongoose.Schema.Types.Mixed,
required: false,
},
text: {
type: String,
required: false,
},
},
default: undefined,
required: false,
},
_meiliIndex: {
type: Boolean,
required: false,