🚀 feat: enhance UI components and refactor settings (#6625)

* 🚀 feat: Add Save Badges State functionality to chat settings

* 🚀 feat: Remove individual chat setting components and introduce a reusable ToggleSwitch component

* 🚀 feat: Replace Switches with reusable ToggleSwitch component in General settings; style: improved HoverCard

* 🚀 feat: Refactor ChatForm and Footer components for improved layout and state management

* 🚀 feat: Add deprecation warning for GPT Plugins endpoint

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2025-04-01 09:15:41 +02:00 committed by GitHub
parent 14ff66b2c3
commit a5154e1349
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 227 additions and 350 deletions

View file

@ -56,7 +56,6 @@ export default function Footer({ className }: { className?: string }) {
<React.Fragment key={`main-content-part-${index}`}>
<ReactMarkdown
components={{
a: ({ node: _n, href, children, ...otherProps }) => {
return (
<a
@ -87,7 +86,7 @@ export default function Footer({ className }: { className?: string }) {
<div
className={
className ??
'relative hidden items-center justify-center gap-2 px-2 py-2 text-center text-xs text-text-primary sm:flex md:px-[60px]'
'absolute bottom-0 left-0 right-0 hidden items-center justify-center gap-2 px-2 py-2 text-center text-xs text-text-primary sm:flex md:px-[60px]'
}
role="contentinfo"
>

View file

@ -43,12 +43,13 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false);
const [backupBadges, setBackupBadges] = useState<Pick<BadgeItem, 'id'>[]>([]);
const isSearching = useRecoilValue(store.isSearching);
const SpeechToText = useRecoilValue(store.speechToText);
const TextToSpeech = useRecoilValue(store.textToSpeech);
const chatDirection = useRecoilValue(store.chatDirection);
const automaticPlayback = useRecoilValue(store.automaticPlayback);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const chatDirection = useRecoilValue(store.chatDirection);
const isSearching = useRecoilValue(store.isSearching);
const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding);
const [badges, setBadges] = useRecoilState(store.chatBadges);
const [isEditingBadges, setIsEditingBadges] = useRecoilState(store.isEditingBadges);
@ -190,8 +191,9 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<form
onSubmit={methods.handleSubmit(submitMessage)}
className={cn(
'mx-auto flex flex-row gap-3 transition-all duration-200 sm:mb-2 sm:px-2',
'mx-auto flex flex-row gap-3 transition-all duration-200 sm:px-2',
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
centerFormOnLanding ? 'sm:mb-28' : 'sm:mb-10',
)}
>
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
@ -217,7 +219,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<div
onClick={handleContainerClick}
className={cn(
'relative flex w-full flex-grow flex-col overflow-hidden rounded-t-3xl border border-border-light bg-surface-chat pb-4 text-text-primary transition-all duration-200 sm:rounded-3xl sm:pb-0',
'relative flex w-full flex-grow flex-col overflow-hidden rounded-t-3xl border border-border-medium bg-surface-chat pb-4 text-text-primary transition-all duration-200 sm:rounded-3xl sm:pb-0',
isTextAreaFocused ? 'shadow-lg' : 'shadow-md',
)}
>

View file

@ -1,11 +1,11 @@
import { useMemo } from 'react';
import { SettingsIcon } from 'lucide-react';
import { Spinner } from '~/components';
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type { Endpoint } from '~/common';
import { CustomMenu as Menu, CustomMenuItem as MenuItem } from '../CustomMenu';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { renderEndpointModels } from './EndpointModelItem';
import { TooltipAnchor, Spinner } from '~/components';
import { filterModels } from '../utils';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -84,6 +84,18 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
>
{endpoint.label}
</span>
{/* TODO: remove this after deprecation */}
{endpoint.value === 'gptPlugins' && (
<TooltipAnchor
description={localize('com_endpoint_deprecated_info')}
aria-label={localize('com_endpoint_deprecated_info_a11y')}
render={
<span className="ml-2 rounded bg-amber-600/70 px-2 py-0.5 text-xs font-semibold text-white">
{localize('com_endpoint_deprecated')}
</span>
}
/>
)}
</div>
);

View file

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

View file

@ -1,16 +1,82 @@
import { memo } from 'react';
import MaximizeChatSpace from './MaximizeChatSpace';
import FontSizeSelector from './FontSizeSelector';
import SendMessageKeyEnter from './EnterToSend';
import CenterChatInput from './CenterChatInput';
import ShowCodeSwitch from './ShowCodeSwitch';
import { ForkSettings } from './ForkSettings';
import ChatDirection from './ChatDirection';
import ShowThinking from './ShowThinking';
import LaTeXParsing from './LaTeXParsing';
import ScrollButton from './ScrollButton';
import ModularChat from './ModularChat';
import SaveDraft from './SaveDraft';
import ToggleSwitch from '../ToggleSwitch';
import store from '~/store';
const toggleSwitchConfigs = [
{
stateAtom: store.enterToSend,
localizationKey: 'com_nav_enter_to_send',
switchId: 'enterToSend',
hoverCardText: 'com_nav_info_enter_to_send',
key: 'enterToSend',
},
{
stateAtom: store.maximizeChatSpace,
localizationKey: 'com_nav_maximize_chat_space',
switchId: 'maximizeChatSpace',
hoverCardText: undefined,
key: 'maximizeChatSpace',
},
{
stateAtom: store.centerFormOnLanding,
localizationKey: 'com_nav_center_chat_input',
switchId: 'centerFormOnLanding',
hoverCardText: undefined,
key: 'centerFormOnLanding',
},
{
stateAtom: store.showThinking,
localizationKey: 'com_nav_show_thinking',
switchId: 'showThinking',
hoverCardText: undefined,
key: 'showThinking',
},
{
stateAtom: store.showCode,
localizationKey: 'com_nav_show_code',
switchId: 'showCode',
hoverCardText: undefined,
key: 'showCode',
},
{
stateAtom: store.LaTeXParsing,
localizationKey: 'com_nav_latex_parsing',
switchId: 'latexParsing',
hoverCardText: 'com_nav_info_latex_parsing',
key: 'latexParsing',
},
{
stateAtom: store.saveDrafts,
localizationKey: 'com_nav_save_drafts',
switchId: 'saveDrafts',
hoverCardText: 'com_nav_info_save_draft',
key: 'saveDrafts',
},
{
stateAtom: store.showScrollButton,
localizationKey: 'com_nav_scroll_button',
switchId: 'showScrollButton',
hoverCardText: undefined,
key: 'showScrollButton',
},
{
stateAtom: store.saveBadgesState,
localizationKey: 'com_nav_save_badges_state',
switchId: 'showBadges',
hoverCardText: 'com_nav_info_save_badges_state',
key: 'showBadges',
},
{
stateAtom: store.modularChat,
localizationKey: 'com_nav_modular_chat',
switchId: 'modularChat',
hoverCardText: undefined,
key: 'modularChat',
},
];
function Chat() {
return (
@ -21,34 +87,17 @@ function Chat() {
<div className="pb-3">
<ChatDirection />
</div>
<div className="pb-3">
<CenterChatInput />
</div>
<div className="pb-3">
<SendMessageKeyEnter />
</div>
<div className="pb-3">
<MaximizeChatSpace />
</div>
<div className="pb-3">
<ShowCodeSwitch />
</div>
<div className="pb-3">
<SaveDraft />
</div>
<div className="pb-3">
<ScrollButton />
</div>
{toggleSwitchConfigs.map((config) => (
<div key={config.key} className="pb-3">
<ToggleSwitch
stateAtom={config.stateAtom}
localizationKey={config.localizationKey}
hoverCardText={config.hoverCardText}
switchId={config.switchId}
/>
</div>
))}
<ForkSettings />
<div className="pb-3">
<ModularChat />
</div>
<div className="pb-3">
<LaTeXParsing />
</div>
<div className="pb-3">
<ShowThinking />
</div>
</div>
);
}

View file

@ -1,37 +0,0 @@
import { useRecoilState } from 'recoil';
import HoverCardSettings from '../HoverCardSettings';
import { Switch } from '~/components/ui/Switch';
import useLocalize from '~/hooks/useLocalize';
import store from '~/store';
export default function SendMessageKeyEnter({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const [enterToSend, setEnterToSend] = useRecoilState<boolean>(store.enterToSend);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setEnterToSend(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_nav_enter_to_send')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_enter_to_send" />
</div>
<Switch
id="enterToSend"
checked={enterToSend}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="enterToSend"
/>
</div>
);
}

View file

@ -1,37 +0,0 @@
import { useRecoilState } from 'recoil';
import HoverCardSettings from '../HoverCardSettings';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function LaTeXParsingSwitch({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const [LaTeXParsing, setLaTeXParsing] = useRecoilState<boolean>(store.LaTeXParsing);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setLaTeXParsing(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_nav_latex_parsing')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_latex_parsing" />
</div>
<Switch
id="LaTeXParsing"
checked={LaTeXParsing}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="LaTeXParsing"
/>
</div>
);
}

View file

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

View file

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

View file

@ -4,16 +4,16 @@ import { Switch } from '~/components/ui';
import useLocalize from '~/hooks/useLocalize';
import store from '~/store';
export default function SaveDraft({
export default function SaveBadgesState({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const [saveDrafts, setSaveDrafts] = useRecoilState<boolean>(store.saveDrafts);
const [saveBadgesState, setSaveBadgesState] = useRecoilState<boolean>(store.saveBadgesState);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setSaveDrafts(value);
setSaveBadgesState(value);
if (onCheckedChange) {
onCheckedChange(value);
}
@ -22,15 +22,15 @@ export default function SaveDraft({
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_nav_save_drafts')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_save_draft" />
<div>{localize('com_nav_save_badges_state')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_save_badges_state" />
</div>
<Switch
id="saveDrafts"
checked={saveDrafts}
id="saveBadgesState"
checked={saveBadgesState}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="saveDrafts"
data-testid="saveBadgesState"
/>
</div>
);

View file

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

View file

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

View file

@ -1,38 +0,0 @@
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
import { render, fireEvent } from 'test/layout-test-utils';
import AutoScrollSwitch from './AutoScrollSwitch';
import { RecoilRoot } from 'recoil';
describe('AutoScrollSwitch', () => {
/**
* Mock function to set the auto-scroll state.
*/
let mockSetAutoScroll: jest.Mock<void, [boolean]> | ((value: boolean) => void) | undefined;
beforeEach(() => {
mockSetAutoScroll = jest.fn();
});
it('renders correctly', () => {
const { getByTestId } = render(
<RecoilRoot>
<AutoScrollSwitch />
</RecoilRoot>,
);
expect(getByTestId('autoScroll')).toBeInTheDocument();
});
it('calls onCheckedChange when the switch is toggled', () => {
const { getByTestId } = render(
<RecoilRoot>
<AutoScrollSwitch onCheckedChange={mockSetAutoScroll} />
</RecoilRoot>,
);
const switchElement = getByTestId('autoScroll');
fireEvent.click(switchElement);
expect(mockSetAutoScroll).toHaveBeenCalledWith(true);
});
});

View file

@ -6,9 +6,34 @@ import HideSidePanelSwitch from './HideSidePanelSwitch';
import { ThemeContext, useLocalize } from '~/hooks';
import AutoScrollSwitch from './AutoScrollSwitch';
import ArchivedChats from './ArchivedChats';
import { Dropdown } from '~/components/ui';
import ToggleSwitch from '../ToggleSwitch';
import { Dropdown } from '~/components';
import store from '~/store';
const toggleSwitchConfigs = [
{
stateAtom: store.enableUserMsgMarkdown,
localizationKey: 'com_nav_user_msg_markdown',
switchId: 'enableUserMsgMarkdown',
hoverCardText: undefined,
key: 'enableUserMsgMarkdown',
},
{
stateAtom: store.autoScroll,
localizationKey: 'com_nav_auto_scroll',
switchId: 'autoScroll',
hoverCardText: undefined,
key: 'autoScroll',
},
{
stateAtom: store.hideSidePanel,
localizationKey: 'com_nav_hide_panel',
switchId: 'hideSidePanel',
hoverCardText: undefined,
key: 'hideSidePanel',
},
];
export const ThemeSelector = ({
theme,
onChange,
@ -127,15 +152,16 @@ function General() {
<div className="pb-3">
<LangSelector langcode={langcode} onChange={changeLang} />
</div>
<div className="pb-3">
<UserMsgMarkdownSwitch />
</div>
<div className="pb-3">
<AutoScrollSwitch />
</div>
<div className="pb-3">
<HideSidePanelSwitch />
</div>
{toggleSwitchConfigs.map((config) => (
<div key={config.key} className="pb-3">
<ToggleSwitch
stateAtom={config.stateAtom}
localizationKey={config.localizationKey}
hoverCardText={config.hoverCardText}
switchId={config.switchId}
/>
</div>
))}
<div className="pb-3">
<ArchivedChats />
</div>

View file

@ -0,0 +1,49 @@
import { useRecoilState } from 'recoil';
import HoverCardSettings from './HoverCardSettings';
import useLocalize from '~/hooks/useLocalize';
import { Switch } from '~/components/ui';
import { RecoilState } from 'recoil';
interface ToggleSwitchProps {
stateAtom: RecoilState<boolean>;
localizationKey: string;
hoverCardText?: string;
switchId: string;
onCheckedChange?: (value: boolean) => void;
}
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
stateAtom,
localizationKey,
hoverCardText,
switchId,
onCheckedChange,
}) => {
const [switchState, setSwitchState] = useRecoilState<boolean>(stateAtom);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setSwitchState(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize(localizationKey as any)}</div>
{hoverCardText && <HoverCardSettings side="bottom" text={hoverCardText} />}
</div>
<Switch
id={switchId}
checked={switchState}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid={switchId}
/>
</div>
);
};
export default ToggleSwitch;

View file

@ -23,7 +23,7 @@ const HoverCardContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-64 rounded-md border border-none bg-surface-tertiary p-4 shadow-md outline-none animate-in fade-in-0',
'z-50 w-64 origin-[--radix-hover-card-content-transform-origin] rounded-xl border border-border-light bg-surface-secondary p-4 text-text-primary shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}