mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 18:00:15 +01:00
* ✨ feat: Enhance Artifact Management with Version Control and UI Improvements ✨ feat: Improve mobile layout and responsiveness in Artifacts component ✨ feat: Refactor imports and remove unnecessary props in Artifact components ✨ feat: Enhance Artifacts and SidePanel components with improved mobile responsiveness and layout transitions feat: Enhance artifact panel animations and improve UI responsiveness - Updated Thinking component button styles for smoother transitions. - Implemented dynamic rendering for artifacts panel with animation effects. - Refactored localization keys for consistency across multiple languages. - Added new CSS animations for iOS-inspired smooth transitions. - Improved Tailwind CSS configuration to support enhanced animation effects. ✨ feat: Add fullWidth and icon support to Radio component for enhanced flexibility refactor: Remove unused PreviewProps import in ArtifactPreview component refactor: Improve button class handling and blur effect constants in Artifact components ✨ feat: Refactor Artifacts component structure and add mobile/desktop variants for improved UI chore: Bump @librechat/client version to 0.3.2 refactor: Update button styles and transition durations for improved UI responsiveness refactor: revert back localization key refactor: remove unused scaling and animation properties for cleaner CSS refactor: remove unused animation properties for cleaner configuration * ✨ refactor: Simplify className usage in ArtifactTabs, ArtifactsHeader, and SidePanelGroup components * refactor: Remove cycleArtifact function from useArtifacts hook * ✨ feat: Implement Chromium resize lag fix with performance optimizations and new ArtifactsPanel component * ✨ feat: Update Badge component for responsive design and improve tap scaling behavior * chore: Update react-resizable-panels dependency to version 3.0.6 * ✨ feat: Refactor Artifacts components for improved structure and performance; remove unused files and optimize styles * ✨ style: Update text color for improved visibility in Artifacts component * ✨ style: Remove text color class for improved Spinner styling in Artifacts component * refactor: Split EditorContext into MutationContext and CodeContext to optimize re-renders; update related components to use new hooks * refactor: Optimize debounced mutation handling in CodeEditor component using refs to maintain current values and reduce re-renders * fix: Correct endpoint for message artifacts by changing URL segment from 'artifacts' to 'artifact' * feat: Enhance useEditArtifact mutation with optimistic updates and rollback on error; improve type safety with context management * fix: proper switch to preview as soon as artifact becomes enclosed * refactor: Remove optimistic updates from useEditArtifact mutation to prevent errors; simplify onMutate logic * test: Add comprehensive unit tests for useArtifacts hook to validate artifact handling, tab switching, and state management * test: Enhance unit tests for useArtifacts hook to cover new conversation transitions and null message handling --------- Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
import { memo, useRef, useMemo, useEffect, useState, useCallback } from 'react';
|
|
import { useWatch } from 'react-hook-form';
|
|
import { TextareaAutosize } from '@librechat/client';
|
|
import { useRecoilState, useRecoilValue } from 'recoil';
|
|
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
|
|
import {
|
|
useChatContext,
|
|
useChatFormContext,
|
|
useAddedChatContext,
|
|
useAssistantsMapContext,
|
|
} from '~/Providers';
|
|
import {
|
|
useTextarea,
|
|
useAutoSave,
|
|
useLocalize,
|
|
useRequiresKey,
|
|
useHandleKeyUp,
|
|
useQueryParams,
|
|
useSubmitMessage,
|
|
useFocusChatEffect,
|
|
} from '~/hooks';
|
|
import { mainTextareaId, BadgeItem } from '~/common';
|
|
import AttachFileChat from './Files/AttachFileChat';
|
|
import FileFormChat from './Files/FileFormChat';
|
|
import { cn, removeFocusRings } from '~/utils';
|
|
import TextareaHeader from './TextareaHeader';
|
|
import PromptsCommand from './PromptsCommand';
|
|
import AudioRecorder from './AudioRecorder';
|
|
import CollapseChat from './CollapseChat';
|
|
import StreamAudio from './StreamAudio';
|
|
import StopButton from './StopButton';
|
|
import SendButton from './SendButton';
|
|
import EditBadges from './EditBadges';
|
|
import BadgeRow from './BadgeRow';
|
|
import Mention from './Mention';
|
|
import store from '~/store';
|
|
|
|
const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
|
const submitButtonRef = useRef<HTMLButtonElement>(null);
|
|
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
|
useFocusChatEffect(textAreaRef);
|
|
const localize = useLocalize();
|
|
|
|
const [isCollapsed, setIsCollapsed] = 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 chatDirection = useRecoilValue(store.chatDirection);
|
|
const automaticPlayback = useRecoilValue(store.automaticPlayback);
|
|
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
|
const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding);
|
|
const isTemporary = useRecoilValue(store.isTemporary);
|
|
|
|
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 { 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 conversationId = useMemo(
|
|
() => conversation?.conversationId ?? Constants.NEW_CONVO,
|
|
[conversation?.conversationId],
|
|
);
|
|
|
|
const isRTL = useMemo(
|
|
() => (chatDirection != null ? chatDirection?.toLowerCase() === 'rtl' : false),
|
|
[chatDirection],
|
|
);
|
|
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(() => {
|
|
/** Check if the device is a touchscreen */
|
|
if (window.matchMedia?.('(pointer: coarse)').matches) {
|
|
return;
|
|
}
|
|
textAreaRef.current?.focus();
|
|
}, []);
|
|
|
|
const handleFocusOrClick = useCallback(() => {
|
|
if (isCollapsed) {
|
|
setIsCollapsed(false);
|
|
}
|
|
}, [isCollapsed]);
|
|
|
|
useAutoSave({
|
|
files,
|
|
setFiles,
|
|
textAreaRef,
|
|
conversationId,
|
|
isSubmitting: isSubmitting || isSubmittingAdded,
|
|
});
|
|
|
|
const { submitMessage, submitPrompt } = useSubmitMessage();
|
|
|
|
const handleKeyUp = useHandleKeyUp({
|
|
index,
|
|
textAreaRef,
|
|
setShowPlusPopover,
|
|
setShowMentionPopover,
|
|
});
|
|
const {
|
|
isNotAppendable,
|
|
handlePaste,
|
|
handleKeyDown,
|
|
handleCompositionStart,
|
|
handleCompositionEnd,
|
|
} = useTextarea({
|
|
textAreaRef,
|
|
submitButtonRef,
|
|
setIsScrollable,
|
|
disabled: disableInputs,
|
|
});
|
|
|
|
useQueryParams({ textAreaRef });
|
|
|
|
const { ref, ...registerProps } = methods.register('text', {
|
|
required: 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 (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([]);
|
|
}, [setIsEditingBadges, setBackupBadges]);
|
|
|
|
const handleCancelBadges = useCallback(() => {
|
|
if (backupBadges.length > 0) {
|
|
setBadges([...backupBadges]);
|
|
}
|
|
setIsEditingBadges(false);
|
|
setBackupBadges([]);
|
|
}, [backupBadges, setBadges, setIsEditingBadges]);
|
|
|
|
const isMoreThanThreeRows = visualRowCount > 3;
|
|
|
|
const baseClasses = useMemo(
|
|
() =>
|
|
cn(
|
|
'md:py-3.5 m-0 w-full resize-none py-[13px] placeholder-black/50 bg-transparent 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],
|
|
);
|
|
|
|
return (
|
|
<form
|
|
onSubmit={methods.handleSubmit(submitMessage)}
|
|
className={cn(
|
|
'mx-auto flex w-full flex-row gap-3 transition-[max-width] duration-300 sm:px-2',
|
|
maximizeChatSpace ? 'max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
|
|
centerFormOnLanding &&
|
|
(conversationId == null || conversationId === Constants.NEW_CONVO) &&
|
|
!isSubmitting &&
|
|
conversation?.messages?.length === 0
|
|
? 'transition-all duration-200 sm:mb-28'
|
|
: 'sm:mb-10',
|
|
)}
|
|
>
|
|
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
|
|
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
|
|
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
|
|
<Mention
|
|
conversation={conversation}
|
|
setShowMentionPopover={setShowPlusPopover}
|
|
newConversation={generateConversation}
|
|
textAreaRef={textAreaRef}
|
|
commandChar="+"
|
|
placeholder="com_ui_add_model_preset"
|
|
includeAssistants={false}
|
|
/>
|
|
)}
|
|
{showMentionPopover && (
|
|
<Mention
|
|
conversation={conversation}
|
|
setShowMentionPopover={setShowMentionPopover}
|
|
newConversation={newConversation}
|
|
textAreaRef={textAreaRef}
|
|
/>
|
|
)}
|
|
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
|
|
<div
|
|
onClick={handleContainerClick}
|
|
className={cn(
|
|
'relative flex w-full flex-grow flex-col overflow-hidden rounded-t-3xl border pb-4 text-text-primary transition-all duration-200 sm:rounded-3xl sm:pb-0',
|
|
isTextAreaFocused ? 'shadow-lg' : 'shadow-md',
|
|
isTemporary
|
|
? 'border-violet-800/60 bg-violet-950/10'
|
|
: 'border-border-light bg-surface-chat',
|
|
)}
|
|
>
|
|
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
|
|
<EditBadges
|
|
isEditingChatBadges={isEditingBadges}
|
|
handleCancelBadges={handleCancelBadges}
|
|
handleSaveBadges={handleSaveBadges}
|
|
setBadges={setBadges}
|
|
/>
|
|
<FileFormChat conversation={conversation} />
|
|
{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 || isNotAppendable}
|
|
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)}
|
|
aria-label={localize('com_ui_message_input')}
|
|
onClick={handleFocusOrClick}
|
|
style={{ height: 44, overflowY: 'auto' }}
|
|
className={cn(
|
|
baseClasses,
|
|
removeFocusRings,
|
|
'transition-[max-height] duration-200 disabled:cursor-not-allowed',
|
|
)}
|
|
/>
|
|
<div className="flex flex-col items-start justify-start pt-1.5">
|
|
<CollapseChat
|
|
isCollapsed={isCollapsed}
|
|
isScrollable={isMoreThanThreeRows}
|
|
setIsCollapsed={setIsCollapsed}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
'@container items-between flex gap-2 pb-2',
|
|
isRTL ? 'flex-row-reverse' : 'flex-row',
|
|
)}
|
|
>
|
|
<div className={`${isRTL ? 'mr-2' : 'ml-2'}`}>
|
|
<AttachFileChat conversation={conversation} disableInputs={disableInputs} />
|
|
</div>
|
|
<BadgeRow
|
|
showEphemeralBadges={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}
|
|
isSubmitting={isSubmitting || isSubmittingAdded}
|
|
conversationId={conversationId}
|
|
onChange={setBadges}
|
|
isInChat={
|
|
Array.isArray(conversation?.messages) && conversation.messages.length >= 1
|
|
}
|
|
/>
|
|
<div className="mx-auto flex" />
|
|
{SpeechToText && (
|
|
<AudioRecorder
|
|
methods={methods}
|
|
ask={submitMessage}
|
|
textAreaRef={textAreaRef}
|
|
disabled={disableInputs || isNotAppendable}
|
|
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 || isNotAppendable}
|
|
/>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
);
|
|
});
|
|
|
|
export default ChatForm;
|