mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-18 01:10:14 +01:00
✨ feat: Quality-of-Life Chat/Edit-Message Enhancements (#5194)
* fix: rendering error for mermaid flowchart syntax * feat: add submit button ref and enable submit on Ctrl+Enter in EditMessage component * feat: add save button and keyboard shortcuts for saving and canceling in EditMessage component * feat: collapse chat on max height * refactor: implement scrollable detection for textarea on key down events and initial render * feat: add regenerate button for error handling in HoverButtons, closes #3658 * feat: add functionality to edit latest user message with the up arrow key when the input is empty
This commit is contained in:
parent
b01c744eb8
commit
8aa1e731ca
22 changed files with 242 additions and 66 deletions
|
|
@ -47,7 +47,6 @@ const MermaidDiagram: React.FC<MermaidDiagramProps> = ({ content }) => {
|
||||||
diagramPadding: 8,
|
diagramPadding: 8,
|
||||||
htmlLabels: true,
|
htmlLabels: true,
|
||||||
useMaxWidth: true,
|
useMaxWidth: true,
|
||||||
defaultRenderer: 'dagre-d3',
|
|
||||||
padding: 15,
|
padding: 15,
|
||||||
wrappingWidth: 200,
|
wrappingWidth: 200,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { memo, useRef, useMemo, useEffect } from 'react';
|
import { memo, useRef, useMemo, useEffect, useState } from 'react';
|
||||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||||
import {
|
import {
|
||||||
supportsFiles,
|
supportsFiles,
|
||||||
|
|
@ -20,14 +20,15 @@ import {
|
||||||
useQueryParams,
|
useQueryParams,
|
||||||
useSubmitMessage,
|
useSubmitMessage,
|
||||||
} from '~/hooks';
|
} from '~/hooks';
|
||||||
|
import { cn, removeFocusRings, checkIfScrollable } from '~/utils';
|
||||||
import FileFormWrapper from './Files/FileFormWrapper';
|
import FileFormWrapper from './Files/FileFormWrapper';
|
||||||
import { TextareaAutosize } from '~/components/ui';
|
import { TextareaAutosize } from '~/components/ui';
|
||||||
import { useGetFileConfig } from '~/data-provider';
|
import { useGetFileConfig } from '~/data-provider';
|
||||||
import { cn, removeFocusRings } from '~/utils';
|
|
||||||
import TextareaHeader from './TextareaHeader';
|
import TextareaHeader from './TextareaHeader';
|
||||||
import PromptsCommand from './PromptsCommand';
|
import PromptsCommand from './PromptsCommand';
|
||||||
import AudioRecorder from './AudioRecorder';
|
import AudioRecorder from './AudioRecorder';
|
||||||
import { mainTextareaId } from '~/common';
|
import { mainTextareaId } from '~/common';
|
||||||
|
import CollapseChat from './CollapseChat';
|
||||||
import StreamAudio from './StreamAudio';
|
import StreamAudio from './StreamAudio';
|
||||||
import StopButton from './StopButton';
|
import StopButton from './StopButton';
|
||||||
import SendButton from './SendButton';
|
import SendButton from './SendButton';
|
||||||
|
|
@ -39,6 +40,9 @@ const ChatForm = ({ index = 0 }) => {
|
||||||
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
useQueryParams({ textAreaRef });
|
useQueryParams({ textAreaRef });
|
||||||
|
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
const [isScrollable, setIsScrollable] = useState(false);
|
||||||
|
|
||||||
const SpeechToText = useRecoilValue(store.speechToText);
|
const SpeechToText = useRecoilValue(store.speechToText);
|
||||||
const TextToSpeech = useRecoilValue(store.textToSpeech);
|
const TextToSpeech = useRecoilValue(store.textToSpeech);
|
||||||
const automaticPlayback = useRecoilValue(store.automaticPlayback);
|
const automaticPlayback = useRecoilValue(store.automaticPlayback);
|
||||||
|
|
@ -64,6 +68,7 @@ const ChatForm = ({ index = 0 }) => {
|
||||||
const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({
|
const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({
|
||||||
textAreaRef,
|
textAreaRef,
|
||||||
submitButtonRef,
|
submitButtonRef,
|
||||||
|
setIsScrollable,
|
||||||
disabled: !!(requiresKey ?? false),
|
disabled: !!(requiresKey ?? false),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -129,11 +134,19 @@ const ChatForm = ({ index = 0 }) => {
|
||||||
}
|
}
|
||||||
}, [isSearching, disableInputs]);
|
}, [isSearching, disableInputs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (textAreaRef.current) {
|
||||||
|
checkIfScrollable(textAreaRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
|
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
|
||||||
const isUploadDisabled: boolean = endpointFileConfig?.disabled ?? false;
|
const isUploadDisabled: boolean = endpointFileConfig?.disabled ?? false;
|
||||||
|
|
||||||
const baseClasses =
|
const baseClasses = cn(
|
||||||
'md:py-3.5 m-0 w-full resize-none bg-surface-tertiary py-[13px] placeholder-black/50 dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)] max-h-[65vh] md:max-h-[75vh]';
|
'md:py-3.5 m-0 w-full resize-none bg-surface-tertiary py-[13px] 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 uploadActive = endpointSupportsFiles && !isUploadDisabled;
|
const uploadActive = endpointSupportsFiles && !isUploadDisabled;
|
||||||
const speechClass = isRTL
|
const speechClass = isRTL
|
||||||
|
|
@ -172,25 +185,45 @@ const ChatForm = ({ index = 0 }) => {
|
||||||
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
|
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
|
||||||
<FileFormWrapper disableInputs={disableInputs}>
|
<FileFormWrapper disableInputs={disableInputs}>
|
||||||
{endpoint && (
|
{endpoint && (
|
||||||
<TextareaAutosize
|
<>
|
||||||
{...registerProps}
|
<CollapseChat
|
||||||
ref={(e) => {
|
isCollapsed={isCollapsed}
|
||||||
ref(e);
|
isScrollable={isScrollable}
|
||||||
textAreaRef.current = e;
|
setIsCollapsed={setIsCollapsed}
|
||||||
}}
|
/>
|
||||||
disabled={disableInputs}
|
<TextareaAutosize
|
||||||
onPaste={handlePaste}
|
{...registerProps}
|
||||||
onKeyDown={handleKeyDown}
|
ref={(e) => {
|
||||||
onKeyUp={handleKeyUp}
|
ref(e);
|
||||||
onCompositionStart={handleCompositionStart}
|
textAreaRef.current = e;
|
||||||
onCompositionEnd={handleCompositionEnd}
|
}}
|
||||||
id={mainTextareaId}
|
disabled={disableInputs}
|
||||||
tabIndex={0}
|
onPaste={handlePaste}
|
||||||
data-testid="text-input"
|
onKeyDown={handleKeyDown}
|
||||||
style={{ height: 44, overflowY: 'auto' }}
|
onKeyUp={handleKeyUp}
|
||||||
rows={1}
|
onHeightChange={() => {
|
||||||
className={cn(baseClasses, speechClass, removeFocusRings)}
|
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',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</FileFormWrapper>
|
</FileFormWrapper>
|
||||||
{SpeechToText && (
|
{SpeechToText && (
|
||||||
|
|
|
||||||
41
client/src/components/Chat/Input/CollapseChat.tsx
Normal file
41
client/src/components/Chat/Input/CollapseChat.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Minimize2 } from 'lucide-react';
|
||||||
|
import { TooltipAnchor } from '~/components/ui';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
const CollapseChat = ({
|
||||||
|
isScrollable,
|
||||||
|
isCollapsed,
|
||||||
|
setIsCollapsed,
|
||||||
|
}: {
|
||||||
|
isScrollable: boolean;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}) => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
if (!isScrollable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipAnchor
|
||||||
|
role="button"
|
||||||
|
description={localize('com_ui_collapse_chat')}
|
||||||
|
aria-label={localize('com_ui_collapse_chat')}
|
||||||
|
onClick={() => setIsCollapsed(true)}
|
||||||
|
className={cn(
|
||||||
|
'absolute right-2 top-2 z-10 size-[35px] rounded-full p-2 transition-colors',
|
||||||
|
'hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Minimize2 className="h-full w-full" />
|
||||||
|
</TooltipAnchor>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CollapseChat;
|
||||||
|
|
@ -5,7 +5,7 @@ import { useForm } from 'react-hook-form';
|
||||||
import { useUpdateMessageMutation } from 'librechat-data-provider/react-query';
|
import { useUpdateMessageMutation } from 'librechat-data-provider/react-query';
|
||||||
import type { TEditProps } from '~/common';
|
import type { TEditProps } from '~/common';
|
||||||
import { useChatContext, useAddedChatContext } from '~/Providers';
|
import { useChatContext, useAddedChatContext } from '~/Providers';
|
||||||
import { TextareaAutosize } from '~/components/ui';
|
import { TextareaAutosize, TooltipAnchor } from '~/components/ui';
|
||||||
import { cn, removeFocusRings } from '~/utils';
|
import { cn, removeFocusRings } from '~/utils';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import Container from './Container';
|
import Container from './Container';
|
||||||
|
|
@ -21,6 +21,8 @@ const EditMessage = ({
|
||||||
setSiblingIdx,
|
setSiblingIdx,
|
||||||
}: TEditProps) => {
|
}: TEditProps) => {
|
||||||
const { addedIndex } = useAddedChatContext();
|
const { addedIndex } = useAddedChatContext();
|
||||||
|
const saveButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const submitButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const { getMessages, setMessages, conversation } = useChatContext();
|
const { getMessages, setMessages, conversation } = useChatContext();
|
||||||
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
|
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
|
||||||
store.latestMessageFamily(addedIndex),
|
store.latestMessageFamily(addedIndex),
|
||||||
|
|
@ -127,6 +129,14 @@ const EditMessage = ({
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitButtonRef.current?.click();
|
||||||
|
}
|
||||||
|
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
saveButtonRef.current?.click();
|
||||||
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
enterEdit(true);
|
enterEdit(true);
|
||||||
|
|
@ -165,25 +175,42 @@ const EditMessage = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex w-full justify-center text-center">
|
<div className="mt-2 flex w-full justify-center text-center">
|
||||||
<button
|
<TooltipAnchor
|
||||||
className="btn btn-primary relative mr-2"
|
description="Ctrl + Enter / ⌘ + Enter"
|
||||||
disabled={
|
render={
|
||||||
isSubmitting || (endpoint === EModelEndpoint.google && !message.isCreatedByUser)
|
<button
|
||||||
|
ref={submitButtonRef}
|
||||||
|
className="btn btn-primary relative mr-2"
|
||||||
|
disabled={
|
||||||
|
isSubmitting || (endpoint === EModelEndpoint.google && !message.isCreatedByUser)
|
||||||
|
}
|
||||||
|
onClick={handleSubmit(resubmitMessage)}
|
||||||
|
>
|
||||||
|
{localize('com_ui_save_submit')}
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
onClick={handleSubmit(resubmitMessage)}
|
/>
|
||||||
>
|
<TooltipAnchor
|
||||||
{localize('com_ui_save_submit')}
|
description="Shift + Enter"
|
||||||
</button>
|
render={
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary relative mr-2"
|
ref={saveButtonRef}
|
||||||
disabled={isSubmitting}
|
className="btn btn-secondary relative mr-2"
|
||||||
onClick={handleSubmit(updateMessage)}
|
disabled={isSubmitting}
|
||||||
>
|
onClick={handleSubmit(updateMessage)}
|
||||||
{localize('com_ui_save')}
|
>
|
||||||
</button>
|
{localize('com_ui_save')}
|
||||||
<button className="btn btn-neutral relative" onClick={() => enterEdit(true)}>
|
</button>
|
||||||
{localize('com_ui_cancel')}
|
}
|
||||||
</button>
|
/>
|
||||||
|
<TooltipAnchor
|
||||||
|
description="Esc"
|
||||||
|
render={
|
||||||
|
<button className="btn btn-neutral relative" onClick={() => enterEdit(true)}>
|
||||||
|
{localize('com_ui_cancel')}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,34 @@ export default function HoverButtons({
|
||||||
|
|
||||||
const { isCreatedByUser, error } = message;
|
const { isCreatedByUser, error } = message;
|
||||||
|
|
||||||
if (error) {
|
const renderRegenerate = () => {
|
||||||
return null;
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error === true) {
|
||||||
|
return (
|
||||||
|
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-500 lg:justify-start">
|
||||||
|
{renderRegenerate()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const onEdit = () => {
|
const onEdit = () => {
|
||||||
|
|
@ -84,6 +110,7 @@ export default function HoverButtons({
|
||||||
)}
|
)}
|
||||||
{isEditableEndpoint && (
|
{isEditableEndpoint && (
|
||||||
<button
|
<button
|
||||||
|
id={`edit-${message.messageId}`}
|
||||||
className={cn(
|
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',
|
'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',
|
isCreatedByUser ? '' : 'active',
|
||||||
|
|
@ -113,22 +140,7 @@ export default function HoverButtons({
|
||||||
>
|
>
|
||||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
|
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
|
||||||
</button>
|
</button>
|
||||||
{regenerateEnabled ? (
|
{renderRegenerate()}
|
||||||
<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>
|
|
||||||
) : null}
|
|
||||||
<Fork
|
<Fork
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
messageId={message.messageId}
|
messageId={message.messageId}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export default function MessagesView({
|
||||||
<div className="flex-1 overflow-hidden overflow-y-auto">
|
<div className="flex-1 overflow-hidden overflow-y-auto">
|
||||||
<div className="relative h-full">
|
<div className="relative h-full">
|
||||||
<div
|
<div
|
||||||
|
className="scrollbar-gutter-stable"
|
||||||
onScroll={debouncedHandleScroll}
|
onScroll={debouncedHandleScroll}
|
||||||
ref={scrollableRef}
|
ref={scrollableRef}
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ const useHandleKeyUp = ({
|
||||||
permissionType: PermissionTypes.MULTI_CONVO,
|
permissionType: PermissionTypes.MULTI_CONVO,
|
||||||
permission: Permissions.USE,
|
permission: Permissions.USE,
|
||||||
});
|
});
|
||||||
|
const latestMessage = useRecoilValue(store.latestMessageFamily(index));
|
||||||
const setShowPromptsPopover = useSetRecoilState(store.showPromptsPopoverFamily(index));
|
const setShowPromptsPopover = useSetRecoilState(store.showPromptsPopoverFamily(index));
|
||||||
|
|
||||||
// Get the current state of command toggles
|
// Get the current state of command toggles
|
||||||
|
|
@ -94,12 +95,32 @@ const useHandleKeyUp = ({
|
||||||
[handleAtCommand, handlePlusCommand, handlePromptsCommand],
|
[handleAtCommand, handlePlusCommand, handlePromptsCommand],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleUpArrow = useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (!latestMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = document.getElementById(`edit-${latestMessage.parentMessageId}`);
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
element.click();
|
||||||
|
},
|
||||||
|
[latestMessage],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main key up handler.
|
* Main key up handler.
|
||||||
*/
|
*/
|
||||||
const handleKeyUp = useCallback(
|
const handleKeyUp = useCallback(
|
||||||
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
const text = textAreaRef.current?.value;
|
const text = textAreaRef.current?.value;
|
||||||
|
if (event.key === 'ArrowUp' && text?.length === 0) {
|
||||||
|
handleUpArrow(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (typeof text !== 'string' || text.length === 0) {
|
if (typeof text !== 'string' || text.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -115,7 +136,7 @@ const useHandleKeyUp = ({
|
||||||
handler();
|
handler();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[textAreaRef, commandHandlers],
|
[textAreaRef, commandHandlers, handleUpArrow],
|
||||||
);
|
);
|
||||||
|
|
||||||
return handleKeyUp;
|
return handleKeyUp;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,13 @@ import { useRecoilValue, useRecoilState } from 'recoil';
|
||||||
import { Constants } from 'librechat-data-provider';
|
import { Constants } from 'librechat-data-provider';
|
||||||
import type { TEndpointOption } from 'librechat-data-provider';
|
import type { TEndpointOption } from 'librechat-data-provider';
|
||||||
import type { KeyboardEvent } from 'react';
|
import type { KeyboardEvent } from 'react';
|
||||||
import { forceResize, insertTextAtCursor, getEntityName, getEntity } from '~/utils';
|
import {
|
||||||
|
forceResize,
|
||||||
|
insertTextAtCursor,
|
||||||
|
getEntityName,
|
||||||
|
getEntity,
|
||||||
|
checkIfScrollable,
|
||||||
|
} from '~/utils';
|
||||||
import { useAssistantsMapContext } from '~/Providers/AssistantsMapContext';
|
import { useAssistantsMapContext } from '~/Providers/AssistantsMapContext';
|
||||||
import { useAgentsMapContext } from '~/Providers/AgentsMapContext';
|
import { useAgentsMapContext } from '~/Providers/AgentsMapContext';
|
||||||
import useGetSender from '~/hooks/Conversations/useGetSender';
|
import useGetSender from '~/hooks/Conversations/useGetSender';
|
||||||
|
|
@ -20,10 +26,12 @@ type KeyEvent = KeyboardEvent<HTMLTextAreaElement>;
|
||||||
export default function useTextarea({
|
export default function useTextarea({
|
||||||
textAreaRef,
|
textAreaRef,
|
||||||
submitButtonRef,
|
submitButtonRef,
|
||||||
|
setIsScrollable,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
textAreaRef: React.RefObject<HTMLTextAreaElement>;
|
textAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||||
submitButtonRef: React.RefObject<HTMLButtonElement>;
|
submitButtonRef: React.RefObject<HTMLButtonElement>;
|
||||||
|
setIsScrollable: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
@ -170,6 +178,10 @@ export default function useTextarea({
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyEvent) => {
|
(e: KeyEvent) => {
|
||||||
|
if (textAreaRef.current && checkIfScrollable(textAreaRef.current)) {
|
||||||
|
const scrollable = checkIfScrollable(textAreaRef.current);
|
||||||
|
scrollable && setIsScrollable(scrollable);
|
||||||
|
}
|
||||||
if (e.key === 'Enter' && isSubmitting) {
|
if (e.key === 'Enter' && isSubmitting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -209,7 +221,15 @@ export default function useTextarea({
|
||||||
submitButtonRef.current?.click();
|
submitButtonRef.current?.click();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isSubmitting, checkHealth, filesLoading, enterToSend, textAreaRef, submitButtonRef],
|
[
|
||||||
|
isSubmitting,
|
||||||
|
checkHealth,
|
||||||
|
filesLoading,
|
||||||
|
enterToSend,
|
||||||
|
setIsScrollable,
|
||||||
|
textAreaRef,
|
||||||
|
submitButtonRef,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCompositionStart = () => {
|
const handleCompositionStart = () => {
|
||||||
|
|
|
||||||
|
|
@ -913,4 +913,5 @@ export default {
|
||||||
com_endpoint_ai: 'الذكاء الاصطناعي',
|
com_endpoint_ai: 'الذكاء الاصطناعي',
|
||||||
com_endpoint_message_new: 'الرسالة {0} أو اكتب "@" للتبديل إلى الذكاء الاصطناعي',
|
com_endpoint_message_new: 'الرسالة {0} أو اكتب "@" للتبديل إلى الذكاء الاصطناعي',
|
||||||
com_nav_maximize_chat_space: 'تكبير مساحة الدردشة',
|
com_nav_maximize_chat_space: 'تكبير مساحة الدردشة',
|
||||||
|
com_ui_collapse_chat: 'طي الدردشة',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -945,4 +945,5 @@ export default {
|
||||||
com_ui_bookmarks_add: 'Lesezeichen hinzufügen',
|
com_ui_bookmarks_add: 'Lesezeichen hinzufügen',
|
||||||
com_endpoint_message_new: 'Nachricht {0} oder "@" eingeben, um KI zu wechseln',
|
com_endpoint_message_new: 'Nachricht {0} oder "@" eingeben, um KI zu wechseln',
|
||||||
com_nav_maximize_chat_space: 'Chat-Bereich maximieren',
|
com_nav_maximize_chat_space: 'Chat-Bereich maximieren',
|
||||||
|
com_ui_collapse_chat: 'Chat einklappen',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
// file deepcode ignore HardcodedNonCryptoSecret: No hardcoded secrets present in this file
|
// file deepcode ignore HardcodedNonCryptoSecret: No hardcoded secrets present in this file
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
com_ui_collapse_chat: 'Collapse Chat',
|
||||||
com_ui_enter_api_key: 'Enter API Key',
|
com_ui_enter_api_key: 'Enter API Key',
|
||||||
com_ui_librechat_code_api_title: 'Run AI Code',
|
com_ui_librechat_code_api_title: 'Run AI Code',
|
||||||
com_ui_librechat_code_api_subtitle: 'Secure. Multi-language. Input/Output Files.',
|
com_ui_librechat_code_api_subtitle: 'Secure. Multi-language. Input/Output Files.',
|
||||||
|
|
|
||||||
|
|
@ -1201,4 +1201,5 @@ export default {
|
||||||
com_endpoint_message_new: 'Mensaje {0} o escriba "@" para cambiar de IA',
|
com_endpoint_message_new: 'Mensaje {0} o escriba "@" para cambiar de IA',
|
||||||
com_nav_maximize_chat_space: 'Maximizar espacio del chat',
|
com_nav_maximize_chat_space: 'Maximizar espacio del chat',
|
||||||
com_endpoint_ai: 'IA',
|
com_endpoint_ai: 'IA',
|
||||||
|
com_ui_collapse_chat: 'Contraer Chat',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -963,4 +963,5 @@ export default {
|
||||||
com_nav_maximize_chat_space: 'Maximiser l\'espace de discussion',
|
com_nav_maximize_chat_space: 'Maximiser l\'espace de discussion',
|
||||||
com_endpoint_message_new: 'Message {0} ou tapez "@" pour changer d\'IA',
|
com_endpoint_message_new: 'Message {0} ou tapez "@" pour changer d\'IA',
|
||||||
com_ui_page: 'Page',
|
com_ui_page: 'Page',
|
||||||
|
com_ui_collapse_chat: 'Réduire la discussion',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -957,4 +957,5 @@ export default {
|
||||||
com_endpoint_ai: 'IA',
|
com_endpoint_ai: 'IA',
|
||||||
com_nav_maximize_chat_space: 'Massimizza spazio chat',
|
com_nav_maximize_chat_space: 'Massimizza spazio chat',
|
||||||
com_ui_page: 'Pagina',
|
com_ui_page: 'Pagina',
|
||||||
|
com_ui_collapse_chat: 'Comprimi Chat',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -911,4 +911,5 @@ export default {
|
||||||
com_endpoint_ai: 'AI',
|
com_endpoint_ai: 'AI',
|
||||||
com_endpoint_message_new: 'メッセージ {0} または「@」を入力してAIを切り替え',
|
com_endpoint_message_new: 'メッセージ {0} または「@」を入力してAIを切り替え',
|
||||||
com_nav_maximize_chat_space: 'チャット画面を最大化',
|
com_nav_maximize_chat_space: 'チャット画面を最大化',
|
||||||
|
com_ui_collapse_chat: 'チャットを折りたたむ',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1149,4 +1149,5 @@ export default {
|
||||||
com_endpoint_ai: '인공지능',
|
com_endpoint_ai: '인공지능',
|
||||||
com_nav_maximize_chat_space: '채팅창 최대화',
|
com_nav_maximize_chat_space: '채팅창 최대화',
|
||||||
com_endpoint_message_new: '메시지 {0} 또는 "@"를 입력하여 AI 전환',
|
com_endpoint_message_new: '메시지 {0} 또는 "@"를 입력하여 AI 전환',
|
||||||
|
com_ui_collapse_chat: '채팅 접기',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1175,4 +1175,5 @@ export default {
|
||||||
com_endpoint_message_new: 'Сообщение {0} или введите "@" для смены ИИ',
|
com_endpoint_message_new: 'Сообщение {0} или введите "@" для смены ИИ',
|
||||||
com_nav_maximize_chat_space: 'Развернуть чат',
|
com_nav_maximize_chat_space: 'Развернуть чат',
|
||||||
com_ui_bookmarks_add: 'Добавить закладку',
|
com_ui_bookmarks_add: 'Добавить закладку',
|
||||||
|
com_ui_collapse_chat: 'Свернуть чат',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -903,4 +903,5 @@ export default {
|
||||||
com_ui_page: '页面',
|
com_ui_page: '页面',
|
||||||
com_nav_maximize_chat_space: '最大化聊天窗口',
|
com_nav_maximize_chat_space: '最大化聊天窗口',
|
||||||
com_endpoint_message_new: '发送消息 {0} 或输入"@"切换AI',
|
com_endpoint_message_new: '发送消息 {0} 或输入"@"切换AI',
|
||||||
|
com_ui_collapse_chat: '收起聊天',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -880,4 +880,5 @@ export default {
|
||||||
com_nav_maximize_chat_space: '最大化聊天視窗',
|
com_nav_maximize_chat_space: '最大化聊天視窗',
|
||||||
com_endpoint_ai: 'AI',
|
com_endpoint_ai: 'AI',
|
||||||
com_endpoint_message_new: '輸入訊息 {0} 或輸入 "@" 以切換 AI',
|
com_endpoint_message_new: '輸入訊息 {0} 或輸入 "@" 以切換 AI',
|
||||||
|
com_ui_collapse_chat: '收合對話',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ const standardDependencies = {
|
||||||
|
|
||||||
const mermaidDependencies = Object.assign(
|
const mermaidDependencies = Object.assign(
|
||||||
{
|
{
|
||||||
mermaid: '^11.0.2',
|
mermaid: '^11.4.1',
|
||||||
'react-zoom-pan-pinch': '^3.6.1',
|
'react-zoom-pan-pinch': '^3.6.1',
|
||||||
},
|
},
|
||||||
standardDependencies,
|
standardDependencies,
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,6 @@ const MermaidDiagram: React.FC<MermaidDiagramProps> = ({ content }) => {
|
||||||
diagramPadding: 8,
|
diagramPadding: 8,
|
||||||
htmlLabels: true,
|
htmlLabels: true,
|
||||||
useMaxWidth: true,
|
useMaxWidth: true,
|
||||||
defaultRenderer: "dagre-d3",
|
|
||||||
padding: 15,
|
padding: 15,
|
||||||
wrappingWidth: 200,
|
wrappingWidth: 200,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -73,3 +73,15 @@ export function removeCharIfLast(textarea: HTMLTextAreaElement, charToRemove: st
|
||||||
|
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the textarea is scrollable.
|
||||||
|
* @param element
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const checkIfScrollable = (element: HTMLTextAreaElement | null) => {
|
||||||
|
if (!element) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return element.scrollHeight > element.clientHeight;
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue