🎨 feat: UI Refresh for Enhanced UX (#6346)

*  feat: Add Expand Chat functionality and improve UI components

*  feat: Introduce Chat Badges feature with editing capabilities and UI enhancements

*  feat: re-implement file attachment functionality with new components and improved UI

*  feat: Enhance BadgeRow component with drag-and-drop functionality and add animations for better user experience

*  feat: Add useChatBadges hook and enhance Badge component with animations and toggle functionality

* feat: Improve Add/Delete Badges + style and bug fixes

*  feat: Refactor EditBadges component and optimize useChatBadges hook for improved performance and readability

*  feat: Add type definition for LucideIcon in EditBadges component

* refactor: Clean up BadgeRow component by removing outdated comment and improving code readability

* refactor: Rename app-icon class to badge-icon for consistency and improve badge styling

* feat: Add Center Chat Input toggle and update related components for improved UI/UX

* refactor: Simplify ChatView and MessagesView components for improved readability and performance

* refactor: Improve layout and positioning of scroll button in MessagesView component

* refactor: Adjust scroll button position in MessagesView component for better visibility

* refactor: Remove redundant background class from Badge component for cleaner styling

* feat: disable chat badges

* refactor: adjust positioning of scroll button and popover for improved layout

* refactor: simplify class names in ChatForm and RemoveFile components for cleaner code

* refactor: move Switcher to HeaderOptions from SidePanel

* fix(Landing): duplicate description

* feat: add SplitText component for animated text display and update Landing component to use it

* feat(Chat): add ConversationStarters component and integrate it into ChatView; remove ConvoStarter component

* feat(Chat): enhance Message component layout and styling for improved readability

* feat(ControlCombobox, Select): enhance styling and add animation for improved UI experience

* feat(Chat): update Header and HeaderNewChat components for improved layout and styling

* feat(Chat): add ModelDropdown (now includes both endpoint and model) and refactor Menu components for improved UI

* feat(ModelDropdown): add Agent Select; removed old AgentSwitcher components

* feat(ModelDropdown): add settings button for user key configuration

* fix(ModelDropdown): the model dropdown wasn't opening automatically when opening the endpoint one

* refactor(Chat): remove unused EndpointsMenu and related components to streamline codebase

* feat: enhance greeting message and improve accessibility fro ModelDropdown

* refactor(Endpoints): add new hooks and components for endpoint management

* feat(Endpoint): add support for modelSpecs

* feat(Endpoints): add mobile support

* fix: type issues

* fix(modelSpec): type issue

* fix(EndpointMenuDropdown): double overflow scroller in mobile model list

* fix: search model on mobile

* refactor: Endpoint/Model/modelSpec dropdown

* refactor: reorganize imports in Endpoint components

* refactor: remove unused translation keys from English locale

* BREAKING: moving to ariakit with new CustomMenu

* refactor: remove unnecessary comments

* refactor: remove EndpointItem, ModelDropdownButton, SpecIcon, and SpecItem components

* 🔧 fix: AI Icon bump when regenerating message

* wip: chat UI refactoring, fix issues

* chore: add recent update to useAutoSave

* feat: add access control for agent permissions in useMentions hook

* refactor: streamline ModelSelector by removing unused endpoints logic

* refactor: enhance ModelSelector and context by integrating endpointsConfig and improving type usage

* feat: update ModelSelectorContext to utilize conversation data for initial state

* feat: add selector effects for synced endpoint handling

* feat: add guard clause for conversation endpoint in useSelectorEffects hook

* fix: safely call onSelectMention and add autofocus to mention input

* chore: typing

* refactor: ModelSelector to streamline key dialog handling and improve endpoint rendering

* refactor: extract SettingsButton component for cleaner endpoint item rendering

* wip: first pass, expand set api key

* wip: first pass, expanding set key

* refactor: update EndpointItem styles for improved layout and hover effects

* refactor: adjust padding in EndpointItem for improved layout consistency

* refactor: update preset structure in useSelectMention to include spec as null

* refactor: rename setKeyDialogOpen to onOpenChange for clarity and consistency, bring focus back to button that opened dialog

* feat: add SpecIcon component for dynamic model spec icons in menu, adjust icon styling

* refactor: update getSelectedIcon to accept additional parameters and improve icon rendering logic

* fix: adjust padding in MessageRender for improved layout

* refactor: remove inline style for menu width in CustomMenu component

* refactor: enhance layout and styling in ModelSpecItem component for better responsiveness

* refactor: update getDefaultModelSpec to accept startupConfig and improve model spec retrieval logic

* refactor: improve key management and default values in ModelSelector and related components

* refactor: adjust menu width and improve responsiveness in CustomMenu and EndpointItem components

* refactor: enhance focus styles and responsiveness in EndpointItem component

* refactor: improve layout and spacing in Header and ModelSelector components for better responsiveness

* refactor: adjust button styles for consistency and improved layout in AddMultiConvo and PresetsMenu components

* fix: initial fix of assistant names

* fix: assistants handling

* chore: update version of librechat-data-provider to 0.7.75 and add 'spec' to excludedKeys

* fix: improve endpoint filtering logic based on interface configuration and access rights

* fix: remove unused HeaderOptions import and set spec to null in presets and mentions

* fix: ensure currentExample is always an object when updating examples

* fix: update interfaceConfig checks to ensure modelSelect is considered for rendering components

* fix: update model selection logic to consider interface configuration when prioritizing model specs

* fix: add missing localizations

* fix: remove unused agent and assistant selection translations

* fix: implement debounced state updates for selected values in useSelectorEffects

* style: minor style changes related to the ModelSelector

* fix: adjust maximum height for popover and set fixed height for model item

* fix: update placeholders for model and endpoint search inputs

* fix: refactor MessageRender and ContentRender components to better match each other

* fix: remove convo fallback for iconURL in MessageRender and ContentRender components

* fix: update handling of spec, iconURL, and modelLabel in conversation presets, to allow better interchangeability

* fix: replace chatGptLabel with modelLabel in OpenAI settings configuration (fully deprecate chatGptLabel)

* fix: remove console log for assistantNames in useEndpoints hook

* refactor: add cleanInput and cleanOutput options to default conversation handling

* chore: update bun.lockb

* fix: set default value for showIconInHeader in getSelectedIcon function

* refactor: enhance error handling in message processing when latest message has existing content blocks

* chore: allow import/no-cycle for messages

* fix: adjust flex properties in BookmarkMenu for better layout

* feat: support both 'prompt' and 'q' as query parameters in useQueryParams hook

* feat: re-enable Badges components

* refactor: disable edit badge component

* chore: rename assistantMap to assistantsMap for consistency

* chore: rename assistantMap to assistantsMap for consistency in Mention component

* feat: set staleTime for various queries to improve data freshness

* feat: add spec field to tQueryParamsSchema for model specification

* feat: enhance useQueryParams to handle model specs

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2025-03-25 23:50:58 +01:00 committed by GitHub
parent c4fea9cd79
commit 7f29f2f676
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
127 changed files with 4507 additions and 2163 deletions

View file

@ -1,11 +1,7 @@
import { memo, useRef, useMemo, useEffect, useState } from 'react';
import { memo, useRef, useMemo, useEffect, useState, useCallback } from 'react';
import { useWatch } from 'react-hook-form';
import { useRecoilState, useRecoilValue } from 'recoil';
import {
supportsFiles,
mergeFileConfig,
isAssistantsEndpoint,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import { isAssistantsEndpoint } from 'librechat-data-provider';
import {
useChatContext,
useChatFormContext,
@ -20,47 +16,105 @@ import {
useQueryParams,
useSubmitMessage,
} from '~/hooks';
import { cn, removeFocusRings, checkIfScrollable } from '~/utils';
import FileFormWrapper from './Files/FileFormWrapper';
import { TextareaAutosize } from '~/components/ui';
import { useGetFileConfig } from '~/data-provider';
import { TemporaryChat } from './TemporaryChat';
import { mainTextareaId, BadgeItem } from '~/common';
import AttachFileChat from './Files/AttachFileChat';
import FileFormChat from './Files/FileFormChat';
import { TextareaAutosize } from '~/components';
import { cn, removeFocusRings } from '~/utils';
import TextareaHeader from './TextareaHeader';
import PromptsCommand from './PromptsCommand';
import AudioRecorder from './AudioRecorder';
import { mainTextareaId } from '~/common';
import CollapseChat from './CollapseChat';
import StreamAudio from './StreamAudio';
import StopButton from './StopButton';
import SendButton from './SendButton';
import { BadgeRow } from './BadgeRow';
import EditBadges from './EditBadges';
import Mention from './Mention';
import store from '~/store';
const ChatForm = ({ index = 0 }) => {
const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const submitButtonRef = useRef<HTMLButtonElement>(null);
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
useQueryParams({ textAreaRef });
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const [isCollapsed, setIsCollapsed] = useState(false);
const [isScrollable, setIsScrollable] = useState(false);
const [, setIsScrollable] = useState(false);
const [visualRowCount, setVisualRowCount] = useState(1);
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false);
const [backupBadges, setBackupBadges] = useState<Pick<BadgeItem, 'id'>[]>([]);
const SpeechToText = useRecoilValue(store.speechToText);
const TextToSpeech = useRecoilValue(store.textToSpeech);
const automaticPlayback = useRecoilValue(store.automaticPlayback);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const [isTemporaryChat, setIsTemporaryChat] = useRecoilState<boolean>(store.isTemporary);
const chatDirection = useRecoilValue(store.chatDirection);
const isSearching = useRecoilValue(store.isSearching);
const [badges, setBadges] = useRecoilState(store.chatBadges);
const [isEditingBadges, setIsEditingBadges] = useRecoilState(store.isEditingBadges);
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
const [showPlusPopover, setShowPlusPopover] = useRecoilState(store.showPlusPopoverFamily(index));
const [showMentionPopover, setShowMentionPopover] = useRecoilState(
store.showMentionPopoverFamily(index),
);
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
const isRTL = chatDirection === 'rtl';
const { requiresKey } = useRequiresKey();
const methods = useChatFormContext();
const {
files,
setFiles,
conversation,
isSubmitting,
filesLoading,
newConversation,
handleStopGenerating,
} = useChatContext();
const {
addedIndex,
generateConversation,
conversation: addedConvo,
setConversation: setAddedConvo,
isSubmitting: isSubmittingAdded,
} = useAddedChatContext();
const assistantMap = useAssistantsMapContext();
const showStopAdded = useRecoilValue(store.showStopButtonByIndex(addedIndex));
const endpoint = useMemo(
() => conversation?.endpointType ?? conversation?.endpoint,
[conversation?.endpointType, conversation?.endpoint],
);
const isRTL = useMemo(() => chatDirection === 'rtl', [chatDirection.toLowerCase()]);
const invalidAssistant = useMemo(
() =>
isAssistantsEndpoint(endpoint) &&
(!(conversation?.assistant_id ?? '') ||
!assistantMap?.[endpoint ?? '']?.[conversation?.assistant_id ?? '']),
[conversation?.assistant_id, endpoint, assistantMap],
);
const disableInputs = useMemo(
() => requiresKey || invalidAssistant,
[requiresKey, invalidAssistant],
);
const handleContainerClick = useCallback(() => {
textAreaRef.current?.focus();
}, []);
const handleFocusOrClick = useCallback(() => {
if (isCollapsed) {
setIsCollapsed(false);
}
}, [isCollapsed]);
useAutoSave({
conversationId: conversation?.conversationId,
textAreaRef,
files,
setFiles,
});
const { submitMessage, submitPrompt } = useSubmitMessage();
const handleKeyUp = useHandleKeyUp({
index,
textAreaRef,
@ -71,65 +125,22 @@ const ChatForm = ({ index = 0 }) => {
textAreaRef,
submitButtonRef,
setIsScrollable,
disabled: !!(requiresKey ?? false),
disabled: disableInputs,
});
const {
files,
setFiles,
conversation,
isSubmitting,
filesLoading,
newConversation,
handleStopGenerating,
} = useChatContext();
const methods = useChatFormContext();
const {
addedIndex,
generateConversation,
conversation: addedConvo,
setConversation: setAddedConvo,
isSubmitting: isSubmittingAdded,
} = useAddedChatContext();
const showStopAdded = useRecoilValue(store.showStopButtonByIndex(addedIndex));
useAutoSave({
conversationId: useMemo(() => conversation?.conversationId, [conversation]),
textAreaRef,
files,
setFiles,
});
const assistantMap = useAssistantsMapContext();
const { submitMessage, submitPrompt } = useSubmitMessage();
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const endpoint = endpointType ?? _endpoint;
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''];
const invalidAssistant = useMemo(
() =>
isAssistantsEndpoint(conversation?.endpoint) &&
(!(conversation?.assistant_id ?? '') ||
!assistantMap?.[conversation?.endpoint ?? ''][conversation?.assistant_id ?? '']),
[conversation?.assistant_id, conversation?.endpoint, assistantMap],
);
const disableInputs = useMemo(
() => !!((requiresKey ?? false) || invalidAssistant),
[requiresKey, invalidAssistant],
);
useQueryParams({ textAreaRef });
const { ref, ...registerProps } = methods.register('text', {
required: true,
onChange: (e) => {
methods.setValue('text', e.target.value, { shouldValidate: true });
},
onChange: useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) =>
methods.setValue('text', e.target.value, { shouldValidate: true }),
[methods],
),
});
const textValue = useWatch({ control: methods.control, name: 'text' });
useEffect(() => {
if (!isSearching && textAreaRef.current && !disableInputs) {
textAreaRef.current.focus();
@ -138,33 +149,53 @@ const ChatForm = ({ index = 0 }) => {
useEffect(() => {
if (textAreaRef.current) {
checkIfScrollable(textAreaRef.current);
const style = window.getComputedStyle(textAreaRef.current);
const lineHeight = parseFloat(style.lineHeight);
setVisualRowCount(Math.floor(textAreaRef.current.scrollHeight / lineHeight));
}
}, [textValue]);
useEffect(() => {
if (isEditingBadges && backupBadges.length === 0) {
setBackupBadges([...badges]);
}
}, [isEditingBadges, badges, backupBadges.length]);
const handleSaveBadges = useCallback(() => {
setIsEditingBadges(false);
setBackupBadges([]);
}, []);
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
const isUploadDisabled: boolean = endpointFileConfig?.disabled ?? false;
const handleCancelBadges = useCallback(() => {
if (backupBadges.length > 0) {
setBadges([...backupBadges]);
}
setIsEditingBadges(false);
setBackupBadges([]);
}, [backupBadges, setBadges]);
const baseClasses = cn(
'md:py-3.5 m-0 w-full resize-none py-[13px] bg-surface-tertiary placeholder-black/50 dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]',
isCollapsed ? 'max-h-[52px]' : 'max-h-[65vh] md:max-h-[75vh]',
const isMoreThanThreeRows = visualRowCount > 3;
const baseClasses = useMemo(
() =>
cn(
'md:py-3.5 m-0 w-full resize-none py-[13px] bg-surface-chat placeholder-black/50 dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]',
isCollapsed ? 'max-h-[52px]' : 'max-h-[45vh] md:max-h-[55vh]',
isMoreThanThreeRows ? 'pl-5' : 'px-5',
),
[isCollapsed, isMoreThanThreeRows],
);
const uploadActive = endpointSupportsFiles && !isUploadDisabled;
const speechClass = isRTL
? `pr-${uploadActive ? '12' : '4'} pl-12`
: `pl-${uploadActive ? '12' : '4'} pr-12`;
return (
<form
onSubmit={methods.handleSubmit((data) => submitMessage(data))}
onSubmit={methods.handleSubmit(submitMessage)}
className={cn(
'mx-auto flex flex-row gap-3 pl-2 transition-all duration-200 last:mb-2',
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-2xl xl:max-w-3xl',
'mx-auto flex flex-row gap-3 transition-all duration-200 sm:mb-2 sm:px-2',
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
)}
>
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
<div className="flex w-full items-center">
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
<Mention
setShowMentionPopover={setShowPlusPopover}
@ -183,90 +214,101 @@ const ChatForm = ({ index = 0 }) => {
/>
)}
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
<div className="transitional-all relative flex w-full flex-grow flex-col overflow-hidden rounded-3xl bg-surface-tertiary text-text-primary duration-200">
<TemporaryChat
isTemporaryChat={isTemporaryChat}
setIsTemporaryChat={setIsTemporaryChat}
/>
<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 text-text-primary transition-all duration-200 sm:rounded-3xl',
isTextAreaFocused ? 'shadow-lg' : 'shadow-md',
)}
>
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<FileFormWrapper disableInputs={disableInputs}>
{endpoint && (
<>
<EditBadges
isEditingChatBadges={isEditingBadges}
handleCancelBadges={handleCancelBadges}
handleSaveBadges={handleSaveBadges}
setBadges={setBadges}
/>
<FileFormChat disableInputs={disableInputs} />
{endpoint && (
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = e;
}}
disabled={disableInputs}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}
tabIndex={0}
data-testid="text-input"
rows={1}
onFocus={() => {
handleFocusOrClick();
setIsTextAreaFocused(true);
}}
onBlur={setIsTextAreaFocused.bind(null, false)}
onClick={handleFocusOrClick}
style={{ height: 44, overflowY: 'auto' }}
className={cn(
baseClasses,
removeFocusRings,
'transition-[max-height] duration-200',
)}
/>
<div className="flex flex-col items-start justify-start pt-1.5">
<CollapseChat
isCollapsed={isCollapsed}
isScrollable={isScrollable}
isScrollable={isMoreThanThreeRows}
setIsCollapsed={setIsCollapsed}
/>
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
textAreaRef.current = e;
}}
disabled={disableInputs}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onHeightChange={() => {
if (textAreaRef.current) {
const scrollable = checkIfScrollable(textAreaRef.current);
setIsScrollable(scrollable);
}
}}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}
tabIndex={0}
data-testid="text-input"
rows={1}
onFocus={() => isCollapsed && setIsCollapsed(false)}
onClick={() => isCollapsed && setIsCollapsed(false)}
style={{ height: 44, overflowY: 'auto' }}
className={cn(
baseClasses,
speechClass,
removeFocusRings,
'transition-[max-height] duration-200',
)}
/>
</>
</div>
</div>
)}
<div
className={cn(
'items-between flex gap-2 pb-2',
isRTL ? 'flex-row-reverse' : 'flex-row',
)}
</FileFormWrapper>
{SpeechToText && (
<AudioRecorder
isRTL={isRTL}
methods={methods}
ask={submitMessage}
textAreaRef={textAreaRef}
disabled={!!disableInputs}
isSubmitting={isSubmitting}
/>
)}
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
</div>
<div
className={cn(
'mb-[5px] ml-[8px] flex flex-col items-end justify-end',
isRTL && 'order-first mr-[8px]',
)}
style={{ alignSelf: 'flex-end' }}
>
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
) : (
endpoint && (
<SendButton
ref={submitButtonRef}
control={methods.control}
disabled={!!(filesLoading || isSubmitting || disableInputs)}
>
<div className={`${isRTL ? 'mr-2' : 'ml-2'}`}>
<AttachFileChat disableInputs={disableInputs} />
</div>
<BadgeRow onChange={(newBadges) => setBadges(newBadges)} />
<div className="mx-auto flex" />
{SpeechToText && (
<AudioRecorder
methods={methods}
ask={submitMessage}
textAreaRef={textAreaRef}
disabled={disableInputs}
isSubmitting={isSubmitting}
/>
)
)}
)}
<div className={`${isRTL ? 'ml-2' : 'mr-2'}`}>
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
) : (
endpoint && (
<SendButton
ref={submitButtonRef}
control={methods.control}
disabled={filesLoading || isSubmitting || disableInputs}
/>
)
)}
</div>
</div>
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
</div>
</div>
</div>
</form>
);
};
});
export default memo(ChatForm);
export default ChatForm;