LibreChat/client/src/hooks/Input/useTextarea.ts
Ruben Talstra aae413cc71
🌎 i18n: React-i18next & i18next Integration (#5720)
* better i18n support an internationalization-framework.

* removed unused package

* auto sort for translation.json

* fixed tests with the new locales function

* added new CI actions from locize

* to use locize a mention in the README.md

* to use locize a mention in the README.md

* updated README.md and added TRANSLATION.md to the repo

* updated TRANSLATION.md badges

* updated README.md to go to the TRANSLATION.md when clicking on the Translation Progress badge

* updated TRANSLATION.md and added a new issue template.

* updated TRANSLATION.md and added a new issue template.

* updated issue template to add the iso code link.

* updated the new GitHub actions for `locize`

* updated label for new issue template --> i18n

* fixed type issue

* Fix eslint

* Fix eslint with key-spacing spacing

* fix: error type

* fix: handle undefined values in SortFilterHeader component

* fix: typing in Image component

* fix: handle optional promptGroup in PromptCard component

* fix: update localize function to accept string type and remove unnecessary JSX element

* fix: update localize function to enforce TranslationKeys type for better type safety

* fix: improve type safety and handle null values in Assistants component

* fix: enhance null checks for fileId in FilesListView component

* fix: localize 'Go back' button text in FilesListView component

* fix: update aria-label for menu buttons and add translation for 'Close Menu'

* docs: add Reasoning UI section for Chain-of-Thought AI models in README

* fix: enhance type safety by adding type for message in MultiMessage component

* fix: improve null checks and optional chaining in useAutoSave hook

* fix: improve handling of optional properties in cleanupPreset function

* fix: ensure isFetchingNextPage defaults to false and improve null checks for messages in Search component

* fix: enhance type safety and null checks in useBuildMessageTree hook

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-02-09 12:05:31 -05:00

243 lines
7.2 KiB
TypeScript

import debounce from 'lodash/debounce';
import { useEffect, useRef, useCallback } from 'react';
import { useRecoilValue, useRecoilState } from 'recoil';
import type { TEndpointOption } from 'librechat-data-provider';
import type { KeyboardEvent } from 'react';
import {
forceResize,
insertTextAtCursor,
getEntityName,
getEntity,
checkIfScrollable,
} from '~/utils';
import { useAssistantsMapContext } from '~/Providers/AssistantsMapContext';
import { useAgentsMapContext } from '~/Providers/AgentsMapContext';
import useGetSender from '~/hooks/Conversations/useGetSender';
import useFileHandling from '~/hooks/Files/useFileHandling';
import { useInteractionHealthCheck } from '~/data-provider';
import { useChatContext } from '~/Providers/ChatContext';
import useLocalize from '~/hooks/useLocalize';
import { globalAudioId } from '~/common';
import store from '~/store';
type KeyEvent = KeyboardEvent<HTMLTextAreaElement>;
export default function useTextarea({
textAreaRef,
submitButtonRef,
setIsScrollable,
disabled = false,
}: {
textAreaRef: React.RefObject<HTMLTextAreaElement>;
submitButtonRef: React.RefObject<HTMLButtonElement>;
setIsScrollable: React.Dispatch<React.SetStateAction<boolean>>;
disabled?: boolean;
}) {
const localize = useLocalize();
const getSender = useGetSender();
const isComposing = useRef(false);
const agentsMap = useAgentsMapContext();
const { handleFiles } = useFileHandling();
const assistantMap = useAssistantsMapContext();
const checkHealth = useInteractionHealthCheck();
const enterToSend = useRecoilValue(store.enterToSend);
const { index, conversation, isSubmitting, filesLoading, latestMessage, setFilesLoading } =
useChatContext();
const [activePrompt, setActivePrompt] = useRecoilState(store.activePromptByIndex(index));
const { endpoint = '' } = conversation || {};
const { entity, isAgent, isAssistant } = getEntity({
endpoint,
agentsMap,
assistantMap,
agent_id: conversation?.agent_id,
assistant_id: conversation?.assistant_id,
});
const entityName = entity?.name ?? '';
const isNotAppendable =
(((latestMessage?.unfinished ?? false) && !isSubmitting) || (latestMessage?.error ?? false)) &&
!isAssistant;
// && (conversationId?.length ?? 0) > 6; // also ensures that we don't show the wrong placeholder
useEffect(() => {
const prompt = activePrompt ?? '';
if (prompt && textAreaRef.current) {
insertTextAtCursor(textAreaRef.current, prompt);
forceResize(textAreaRef.current);
setActivePrompt(undefined);
}
}, [activePrompt, setActivePrompt, textAreaRef]);
useEffect(() => {
const currentValue = textAreaRef.current?.value ?? '';
if (currentValue) {
return;
}
const getPlaceholderText = () => {
if (disabled) {
return localize('com_endpoint_config_placeholder');
}
const currentEndpoint = conversation?.endpoint ?? '';
const currentAgentId = conversation?.agent_id ?? '';
const currentAssistantId = conversation?.assistant_id ?? '';
if (isAgent && (!currentAgentId || !agentsMap?.[currentAgentId])) {
return localize('com_endpoint_agent_placeholder');
} else if (
isAssistant &&
(!currentAssistantId || !assistantMap?.[currentEndpoint]?.[currentAssistantId])
) {
return localize('com_endpoint_assistant_placeholder');
}
if (isNotAppendable) {
return localize('com_endpoint_message_not_appendable');
}
const sender = isAssistant || isAgent
? getEntityName({ name: entityName, isAgent, localize })
: getSender(conversation as TEndpointOption);
return `${localize(
'com_endpoint_message_new', { 0: sender ? sender : localize('com_endpoint_ai') },
)}`;
};
const placeholder = getPlaceholderText();
if (textAreaRef.current?.getAttribute('placeholder') === placeholder) {
return;
}
const setPlaceholder = () => {
const placeholder = getPlaceholderText();
if (textAreaRef.current?.getAttribute('placeholder') !== placeholder) {
textAreaRef.current?.setAttribute('placeholder', placeholder);
forceResize(textAreaRef.current);
}
};
const debouncedSetPlaceholder = debounce(setPlaceholder, 80);
debouncedSetPlaceholder();
return () => debouncedSetPlaceholder.cancel();
}, [
isAgent,
localize,
disabled,
getSender,
agentsMap,
entityName,
textAreaRef,
isAssistant,
assistantMap,
conversation,
latestMessage,
isNotAppendable,
]);
const handleKeyDown = useCallback(
(e: KeyEvent) => {
if (textAreaRef.current && checkIfScrollable(textAreaRef.current)) {
const scrollable = checkIfScrollable(textAreaRef.current);
scrollable && setIsScrollable(scrollable);
}
if (e.key === 'Enter' && isSubmitting) {
return;
}
checkHealth();
const isNonShiftEnter = e.key === 'Enter' && !e.shiftKey;
const isCtrlEnter = e.key === 'Enter' && (e.ctrlKey || e.metaKey);
// NOTE: isComposing and e.key behave differently in Safari compared to other browsers, forcing us to use e.keyCode instead
const isComposingInput = isComposing.current || e.key === 'Process' || e.keyCode === 229;
if (isNonShiftEnter && filesLoading) {
e.preventDefault();
}
if (isNonShiftEnter) {
e.preventDefault();
}
if (
e.key === 'Enter' &&
!enterToSend &&
!isCtrlEnter &&
textAreaRef.current &&
!isComposingInput
) {
e.preventDefault();
insertTextAtCursor(textAreaRef.current, '\n');
forceResize(textAreaRef.current);
return;
}
if ((isNonShiftEnter || isCtrlEnter) && !isComposingInput) {
const globalAudio = document.getElementById(globalAudioId) as HTMLAudioElement | undefined;
if (globalAudio) {
console.log('Unmuting global audio');
globalAudio.muted = false;
}
submitButtonRef.current?.click();
}
},
[
isSubmitting,
checkHealth,
filesLoading,
enterToSend,
setIsScrollable,
textAreaRef,
submitButtonRef,
],
);
const handleCompositionStart = () => {
isComposing.current = true;
};
const handleCompositionEnd = () => {
isComposing.current = false;
};
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const textArea = textAreaRef.current;
if (!textArea) {
return;
}
const clipboardData = e.clipboardData as DataTransfer | undefined;
if (!clipboardData) {
return;
}
if (clipboardData.files.length > 0) {
setFilesLoading(true);
const timestampedFiles: File[] = [];
for (const file of clipboardData.files) {
const newFile = new File([file], `clipboard_${+new Date()}_${file.name}`, {
type: file.type,
});
timestampedFiles.push(newFile);
}
handleFiles(timestampedFiles);
}
},
[handleFiles, setFilesLoading, textAreaRef],
);
return {
textAreaRef,
handlePaste,
handleKeyDown,
handleCompositionStart,
handleCompositionEnd,
};
}