mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-21 21:50:49 +02:00

* 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>
243 lines
7.2 KiB
TypeScript
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,
|
|
};
|
|
}
|