🖱️ fix: Message Scrolling UX; refactor: Frontend UX/DX Optimizations (#3733)

* refactor(DropdownPopup): set MenuButton `as` prop to `div` to prevent React warning: validateDOMNesting(...): <button> cannot appear as a descendant of <button>

* refactor: memoize ChatGroupItem and ControlCombobox components

* refactor(OpenAIClient): await stream process finish before finalCompletion event handling

* refactor: update useSSE.ts typing to handle null and undefined values in data properties

* refactor: set abort scroll to false on SSE connection open

* refactor: improve logger functionality with filter support

* refactor: update handleScroll typing in MessageContainer component

* refactor: update logger.dir call in useChatFunctions to log 'message_stream' tag format instead of the entire submission object as first arg

* refactor: fix null check for message object in Message component

* refactor: throttle handleScroll to help prevent auto-scrolling issues on new message requests; fix type issues within useMessageProcess

* refactor: add abortScrollByIndex logging effect

* refactor: update MessageIcon and Icon components to use React.memo for performance optimization

* refactor: memoize ConvoIconURL component for performance optimization

* chore: type issues

* chore: update package version to 0.7.414
This commit is contained in:
Danny Avila 2024-08-21 18:18:45 -04:00 committed by GitHub
parent ba9c351435
commit 98b437edd5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 282 additions and 176 deletions

View file

@ -1182,7 +1182,15 @@ ${convo}
} }
let UnexpectedRoleError = false; let UnexpectedRoleError = false;
/** @type {Promise<void>} */
let streamPromise;
/** @type {(value: void | PromiseLike<void>) => void} */
let streamResolve;
if (modelOptions.stream) { if (modelOptions.stream) {
streamPromise = new Promise((resolve) => {
streamResolve = resolve;
});
const stream = await openai.beta.chat.completions const stream = await openai.beta.chat.completions
.stream({ .stream({
...modelOptions, ...modelOptions,
@ -1194,13 +1202,17 @@ ${convo}
.on('error', (err) => { .on('error', (err) => {
handleOpenAIErrors(err, errorCallback, 'stream'); handleOpenAIErrors(err, errorCallback, 'stream');
}) })
.on('finalChatCompletion', (finalChatCompletion) => { .on('finalChatCompletion', async (finalChatCompletion) => {
const finalMessage = finalChatCompletion?.choices?.[0]?.message; const finalMessage = finalChatCompletion?.choices?.[0]?.message;
if (finalMessage && finalMessage?.role !== 'assistant') { if (!finalMessage) {
return;
}
await streamPromise;
if (finalMessage?.role !== 'assistant') {
finalChatCompletion.choices[0].message.role = 'assistant'; finalChatCompletion.choices[0].message.role = 'assistant';
} }
if (finalMessage && !finalMessage?.content?.trim()) { if (typeof finalMessage.content !== 'string' || finalMessage.content.trim() === '') {
finalChatCompletion.choices[0].message.content = intermediateReply; finalChatCompletion.choices[0].message.content = intermediateReply;
} }
}) })
@ -1223,6 +1235,8 @@ ${convo}
await sleep(streamRate); await sleep(streamRate);
} }
streamResolve();
if (!UnexpectedRoleError) { if (!UnexpectedRoleError) {
chatCompletion = await stream.finalChatCompletion().catch((err) => { chatCompletion = await stream.finalChatCompletion().catch((err) => {
handleOpenAIErrors(err, errorCallback, 'finalChatCompletion'); handleOpenAIErrors(err, errorCallback, 'finalChatCompletion');

View file

@ -6,7 +6,13 @@ import MessageRender from './ui/MessageRender';
import MultiMessage from './MultiMessage'; import MultiMessage from './MultiMessage';
const MessageContainer = React.memo( const MessageContainer = React.memo(
({ handleScroll, children }: { handleScroll: () => void; children: React.ReactNode }) => { ({
handleScroll,
children,
}: {
handleScroll: (event?: unknown) => void;
children: React.ReactNode;
}) => {
return ( return (
<div <div
className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent" className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent"
@ -30,11 +36,11 @@ export default function Message(props: TMessageProps) {
} = useMessageProcess({ message: props.message }); } = useMessageProcess({ message: props.message });
const { message, currentEditId, setCurrentEditId } = props; const { message, currentEditId, setCurrentEditId } = props;
if (!message) { if (!message || typeof message !== 'object') {
return null; return null;
} }
const { children, messageId = null } = message ?? {}; const { children, messageId = null } = message;
return ( return (
<> <>

View file

@ -1,4 +1,4 @@
import { useMemo, memo } from 'react'; import React, { useMemo, memo } from 'react';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query'; import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { TMessage, TPreset, Assistant } from 'librechat-data-provider'; import type { TMessage, TPreset, Assistant } from 'librechat-data-provider';
import type { TMessageProps } from '~/common'; import type { TMessageProps } from '~/common';
@ -6,55 +6,66 @@ import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { getEndpointField, getIconEndpoint } from '~/utils'; import { getEndpointField, getIconEndpoint } from '~/utils';
import Icon from '~/components/Endpoints/Icon'; import Icon from '~/components/Endpoints/Icon';
function MessageIcon( const MessageIcon = memo(
props: Pick<TMessageProps, 'message' | 'conversation'> & { (
assistant?: Assistant; props: Pick<TMessageProps, 'message' | 'conversation'> & {
}, assistant?: Assistant;
) { },
const { data: endpointsConfig } = useGetEndpointsQuery(); ) => {
const { message, conversation, assistant } = props; const { data: endpointsConfig } = useGetEndpointsQuery();
const { message, conversation, assistant } = props;
const assistantName = assistant ? (assistant.name as string | undefined) : ''; const assistantName = useMemo(() => assistant?.name ?? '', [assistant]);
const assistantAvatar = assistant ? (assistant.metadata?.avatar as string | undefined) : ''; const assistantAvatar = useMemo(() => assistant?.metadata?.avatar ?? '', [assistant]);
const isCreatedByUser = useMemo(() => message?.isCreatedByUser ?? false, [message]);
const messageSettings = useMemo( const messageSettings = useMemo(
() => ({ () => ({
...(conversation ?? {}), ...(conversation ?? {}),
...({ ...({
...(message ?? {}), ...(message ?? {}),
iconURL: message?.iconURL ?? '', iconURL: message?.iconURL ?? '',
} as TMessage), } as TMessage),
}), }),
[conversation, message], [conversation, message],
); );
const iconURL = messageSettings.iconURL; const iconURL = messageSettings.iconURL;
let endpoint = messageSettings.endpoint; const endpoint = useMemo(
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint }); () => getIconEndpoint({ endpointsConfig, iconURL, endpoint: messageSettings.endpoint }),
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); [endpointsConfig, iconURL, messageSettings.endpoint],
);
const endpointIconURL = useMemo(
() => getEndpointField(endpointsConfig, endpoint, 'iconURL'),
[endpointsConfig, endpoint],
);
if (isCreatedByUser !== true && iconURL != null && iconURL.includes('http')) {
return (
<ConvoIconURL
preset={messageSettings as typeof messageSettings & TPreset}
context="message"
assistantAvatar={assistantAvatar}
endpointIconURL={endpointIconURL}
assistantName={assistantName}
/>
);
}
if (message?.isCreatedByUser !== true && iconURL != null && iconURL.includes('http')) {
return ( return (
<ConvoIconURL <Icon
preset={messageSettings as typeof messageSettings & TPreset} isCreatedByUser={isCreatedByUser}
context="message" endpoint={endpoint}
assistantAvatar={assistantAvatar} iconURL={!assistant ? endpointIconURL : assistantAvatar}
endpointIconURL={endpointIconURL} model={message?.model ?? conversation?.model}
assistantName={assistantName} assistantName={assistantName}
size={28.8}
/> />
); );
} },
);
return ( MessageIcon.displayName = 'MessageIcon';
<Icon
{...messageSettings}
endpoint={endpoint}
iconURL={!assistant ? endpointIconURL : assistantAvatar}
model={message?.model ?? conversation?.model}
assistantName={assistantName}
size={28.8}
/>
);
}
export default memo(MessageIcon); export default MessageIcon;

View file

@ -1,4 +1,4 @@
import React from 'react'; import React, { memo } from 'react';
import type { TPreset } from 'librechat-data-provider'; import type { TPreset } from 'librechat-data-provider';
import type { IconMapProps } from '~/common'; import type { IconMapProps } from '~/common';
import { icons } from '~/components/Chat/Menus/Endpoints/Icons'; import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
@ -41,7 +41,7 @@ const ConvoIconURL: React.FC<ConvoIconURLProps> = ({
}, },
) => React.JSX.Element; ) => React.JSX.Element;
const isURL = iconURL && (iconURL.includes('http') || iconURL.startsWith('/images/')); const isURL = !!(iconURL && (iconURL.includes('http') || iconURL.startsWith('/images/')));
if (!isURL) { if (!isURL) {
Icon = icons[iconURL] ?? icons.unknown; Icon = icons[iconURL] ?? icons.unknown;
@ -77,4 +77,4 @@ const ConvoIconURL: React.FC<ConvoIconURLProps> = ({
); );
}; };
export default ConvoIconURL; export default memo(ConvoIconURL);

View file

@ -1,4 +1,5 @@
import { memo } from 'react'; import React, { memo } from 'react';
import type { TUser } from 'librechat-data-provider';
import type { IconProps } from '~/common'; import type { IconProps } from '~/common';
import MessageEndpointIcon from './MessageEndpointIcon'; import MessageEndpointIcon from './MessageEndpointIcon';
import { useAuthContext } from '~/hooks/AuthContext'; import { useAuthContext } from '~/hooks/AuthContext';
@ -7,7 +8,44 @@ import useLocalize from '~/hooks/useLocalize';
import { UserIcon } from '~/components/svg'; import { UserIcon } from '~/components/svg';
import { cn } from '~/utils'; import { cn } from '~/utils';
const Icon: React.FC<IconProps> = (props) => { type UserAvatarProps = {
size: number;
user?: TUser;
avatarSrc: string;
username: string;
className?: string;
};
const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAvatarProps) => (
<div
title={username}
style={{
width: size,
height: size,
}}
className={cn('relative flex items-center justify-center', className ?? '')}
>
{!(user?.avatar ?? '') && (!(user?.username ?? '') || user?.username.trim() === '') ? (
<div
style={{
backgroundColor: 'rgb(121, 137, 255)',
width: '20px',
height: '20px',
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
}}
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
>
<UserIcon />
</div>
) : (
<img className="rounded-full" src={user?.avatar ?? avatarSrc} alt="avatar" />
)}
</div>
));
UserAvatar.displayName = 'UserAvatar';
const Icon: React.FC<IconProps> = memo((props) => {
const { user } = useAuthContext(); const { user } = useAuthContext();
const { size = 30, isCreatedByUser } = props; const { size = 30, isCreatedByUser } = props;
@ -15,36 +53,20 @@ const Icon: React.FC<IconProps> = (props) => {
const localize = useLocalize(); const localize = useLocalize();
if (isCreatedByUser) { if (isCreatedByUser) {
const username = user?.name || user?.username || localize('com_nav_user'); const username = user?.name ?? user?.username ?? localize('com_nav_user');
return ( return (
<div <UserAvatar
title={username} size={size}
style={{ user={user}
width: size, avatarSrc={avatarSrc}
height: size, username={username}
}} className={props.className}
className={cn('relative flex items-center justify-center', props.className ?? '')} />
>
{!user?.avatar && !user?.username ? (
<div
style={{
backgroundColor: 'rgb(121, 137, 255)',
width: '20px',
height: '20px',
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
}}
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
>
<UserIcon />
</div>
) : (
<img className="rounded-full" src={user?.avatar || avatarSrc} alt="avatar" />
)}
</div>
); );
} }
return <MessageEndpointIcon {...props} />; return <MessageEndpointIcon {...props} />;
}; });
export default memo(Icon); Icon.displayName = 'Icon';
export default Icon;

View file

@ -1,4 +1,4 @@
import { useState, useMemo } from 'react'; import { useState, useMemo, memo } from 'react';
import { Menu as MenuIcon, Edit as EditIcon, EarthIcon, TextSearch } from 'lucide-react'; import { Menu as MenuIcon, Edit as EditIcon, EarthIcon, TextSearch } from 'lucide-react';
import type { TPromptGroup } from 'librechat-data-provider'; import type { TPromptGroup } from 'librechat-data-provider';
import { import {
@ -14,7 +14,7 @@ import PreviewPrompt from '~/components/Prompts/PreviewPrompt';
import ListCard from '~/components/Prompts/Groups/ListCard'; import ListCard from '~/components/Prompts/Groups/ListCard';
import { detectVariables } from '~/utils'; import { detectVariables } from '~/utils';
export default function ChatGroupItem({ function ChatGroupItem({
group, group,
instanceProjectId, instanceProjectId,
}: { }: {
@ -116,3 +116,5 @@ export default function ChatGroupItem({
</> </>
); );
} }
export default memo(ChatGroupItem);

View file

@ -1,6 +1,6 @@
import * as Ariakit from '@ariakit/react'; import * as Ariakit from '@ariakit/react';
import { matchSorter } from 'match-sorter'; import { matchSorter } from 'match-sorter';
import { startTransition, useMemo, useState, useEffect, useRef } from 'react'; import { startTransition, useMemo, useState, useEffect, useRef, memo } from 'react';
import { cn } from '~/utils'; import { cn } from '~/utils';
import type { OptionWithIcon } from '~/common'; import type { OptionWithIcon } from '~/common';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
@ -17,7 +17,7 @@ interface ControlComboboxProps {
SelectIcon?: React.ReactNode; SelectIcon?: React.ReactNode;
} }
export default function ControlCombobox({ function ControlCombobox({
selectedValue, selectedValue,
displayValue, displayValue,
items, items,
@ -121,3 +121,5 @@ export default function ControlCombobox({
</div> </div>
); );
} }
export default memo(ControlCombobox);

View file

@ -37,6 +37,10 @@ const DropdownPopup: React.FC<DropdownProps> = ({
<MenuButton <MenuButton
onClick={handleButtonClick} onClick={handleButtonClick}
className={`inline-flex items-center gap-2 rounded-md ${className}`} className={`inline-flex items-center gap-2 rounded-md ${className}`}
/** This is set as `div` since triggers themselves are buttons;
* prevents React Warning: validateDOMNesting(...): <button> cannot appear as a descendant of <button>.
*/
as="div"
> >
{trigger} {trigger}
</MenuButton> </MenuButton>

View file

@ -251,8 +251,7 @@ export default function useChatFunctions({
} }
setSubmission(submission); setSubmission(submission);
logger.log('Submission:'); logger.dir('message_stream', submission, { depth: null });
logger.dir(submission, { depth: null });
}; };
const regenerate = ({ parentMessageId }) => { const regenerate = ({ parentMessageId }) => {

View file

@ -1,10 +1,10 @@
import throttle from 'lodash/throttle';
import { useEffect, useRef, useCallback, useMemo } from 'react'; import { useEffect, useRef, useCallback, useMemo } from 'react';
import { Constants, isAssistantsEndpoint } from 'librechat-data-provider'; import { Constants, isAssistantsEndpoint } from 'librechat-data-provider';
import type { TMessageProps } from '~/common'; import type { TMessageProps } from '~/common';
import { useChatContext, useAssistantsMapContext } from '~/Providers'; import { useChatContext, useAssistantsMapContext } from '~/Providers';
import useCopyToClipboard from './useCopyToClipboard'; import useCopyToClipboard from './useCopyToClipboard';
import { getTextKey, logger } from '~/utils'; import { getTextKey, logger } from '~/utils';
export default function useMessageHelpers(props: TMessageProps) { export default function useMessageHelpers(props: TMessageProps) {
const latestText = useRef<string | number>(''); const latestText = useRef<string | number>('');
const { message, currentEditId, setCurrentEditId } = props; const { message, currentEditId, setCurrentEditId } = props;
@ -64,13 +64,23 @@ export default function useMessageHelpers(props: TMessageProps) {
[messageId, setCurrentEditId], [messageId, setCurrentEditId],
); );
const handleScroll = useCallback(() => { const handleScroll = useCallback(
if (isSubmitting) { (event: unknown) => {
setAbortScroll(true); throttle(() => {
} else { logger.log(
setAbortScroll(false); 'message_scrolling',
} `useMessageHelpers: setting abort scroll to ${isSubmitting}, handleScroll event`,
}, [isSubmitting, setAbortScroll]); event,
);
if (isSubmitting) {
setAbortScroll(true);
} else {
setAbortScroll(false);
}
}, 500)();
},
[isSubmitting, setAbortScroll],
);
const assistant = useMemo(() => { const assistant = useMemo(() => {
if (!isAssistantsEndpoint(conversation?.endpoint)) { if (!isAssistantsEndpoint(conversation?.endpoint)) {

View file

@ -1,3 +1,4 @@
import throttle from 'lodash/throttle';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { Constants } from 'librechat-data-provider'; import { Constants } from 'librechat-data-provider';
import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
@ -8,8 +9,8 @@ import store from '~/store';
export default function useMessageProcess({ message }: { message?: TMessage | null }) { export default function useMessageProcess({ message }: { message?: TMessage | null }) {
const latestText = useRef<string | number>(''); const latestText = useRef<string | number>('');
const hasNoChildren = useMemo(() => !message?.children?.length, [message]);
const [siblingMessage, setSiblingMessage] = useState<TMessage | null>(null); const [siblingMessage, setSiblingMessage] = useState<TMessage | null>(null);
const hasNoChildren = useMemo(() => (message?.children?.length ?? 0) === 0, [message]);
const { const {
index, index,
@ -44,12 +45,12 @@ export default function useMessageProcess({ message }: { message?: TMessage | nu
const logInfo = { const logInfo = {
textKey, textKey,
'latestText.current': latestText.current, 'latestText.current': latestText.current,
messageId: message?.messageId, messageId: message.messageId,
convoId, convoId,
}; };
if ( if (
textKey !== latestText.current || textKey !== latestText.current ||
(convoId && (convoId != null &&
latestText.current && latestText.current &&
convoId !== latestText.current.split(Constants.COMMON_DIVIDER)[2]) convoId !== latestText.current.split(Constants.COMMON_DIVIDER)[2])
) { ) {
@ -61,18 +62,28 @@ export default function useMessageProcess({ message }: { message?: TMessage | nu
} }
}, [hasNoChildren, message, setLatestMessage, conversation?.conversationId]); }, [hasNoChildren, message, setLatestMessage, conversation?.conversationId]);
const handleScroll = useCallback(() => { const handleScroll = useCallback(
if (isSubmittingFamily) { (event: unknown | TouchEvent | WheelEvent) => {
setAbortScroll(true); throttle(() => {
} else { logger.log(
setAbortScroll(false); 'message_scrolling',
} `useMessageProcess: setting abort scroll to ${isSubmittingFamily}, handleScroll event`,
}, [isSubmittingFamily, setAbortScroll]); event,
);
if (isSubmittingFamily) {
setAbortScroll(true);
} else {
setAbortScroll(false);
}
}, 500)();
},
[isSubmittingFamily, setAbortScroll],
);
const showSibling = useMemo( const showSibling = useMemo(
() => () =>
(hasNoChildren && latestMultiMessage && !latestMultiMessage?.children?.length) || (hasNoChildren && latestMultiMessage && (latestMultiMessage.children?.length ?? 0) === 0) ||
siblingMessage, !!siblingMessage,
[hasNoChildren, latestMultiMessage, siblingMessage], [hasNoChildren, latestMultiMessage, siblingMessage],
); );
@ -83,8 +94,8 @@ export default function useMessageProcess({ message }: { message?: TMessage | nu
latestMultiMessage.conversationId === message?.conversationId latestMultiMessage.conversationId === message?.conversationId
) { ) {
const newSibling = Object.assign({}, latestMultiMessage, { const newSibling = Object.assign({}, latestMultiMessage, {
parentMessageId: message?.parentMessageId, parentMessageId: message.parentMessageId,
depth: message?.depth, depth: message.depth,
}); });
setSiblingMessage(newSibling); setSiblingMessage(newSibling);
} }

View file

@ -6,10 +6,10 @@ import type {
Text, Text,
TMessage, TMessage,
ImageFile, ImageFile,
TSubmission,
ContentPart, ContentPart,
PartMetadata, PartMetadata,
TContentData, TContentData,
EventSubmission,
TMessageContentParts, TMessageContentParts,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import { addFileToCache } from '~/utils'; import { addFileToCache } from '~/utils';
@ -21,7 +21,7 @@ type TUseContentHandler = {
type TContentHandler = { type TContentHandler = {
data: TContentData; data: TContentData;
submission: TSubmission; submission: EventSubmission;
}; };
export default function useContentHandler({ setMessages, getMessages }: TUseContentHandler) { export default function useContentHandler({ setMessages, getMessages }: TUseContentHandler) {
@ -43,7 +43,7 @@ export default function useContentHandler({ setMessages, getMessages }: TUseCont
let response = messageMap.get(messageId); let response = messageMap.get(messageId);
if (!response) { if (!response) {
response = { response = {
...initialResponse, ...(initialResponse as TMessage),
parentMessageId: userMessage?.messageId ?? '', parentMessageId: userMessage?.messageId ?? '',
conversationId, conversationId,
messageId, messageId,

View file

@ -14,7 +14,7 @@ import {
import type { import type {
TMessage, TMessage,
TConversation, TConversation,
TSubmission, EventSubmission,
ConversationData, ConversationData,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import type { SetterOrUpdater, Resetter } from 'recoil'; import type { SetterOrUpdater, Resetter } from 'recoil';
@ -76,7 +76,7 @@ export default function useEventHandlers({
const contentHandler = useContentHandler({ setMessages, getMessages }); const contentHandler = useContentHandler({ setMessages, getMessages });
const messageHandler = useCallback( const messageHandler = useCallback(
(data: string | undefined, submission: TSubmission) => { (data: string | undefined, submission: EventSubmission) => {
const { const {
messages, messages,
userMessage, userMessage,
@ -122,7 +122,7 @@ export default function useEventHandlers({
); );
const cancelHandler = useCallback( const cancelHandler = useCallback(
(data: TResData, submission: TSubmission) => { (data: TResData, submission: EventSubmission) => {
const { requestMessage, responseMessage, conversation } = data; const { requestMessage, responseMessage, conversation } = data;
const { messages, isRegenerate = false } = submission; const { messages, isRegenerate = false } = submission;
@ -171,7 +171,7 @@ export default function useEventHandlers({
); );
const syncHandler = useCallback( const syncHandler = useCallback(
(data: TSyncData, submission: TSubmission) => { (data: TSyncData, submission: EventSubmission) => {
const { conversationId, thread_id, responseMessage, requestMessage } = data; const { conversationId, thread_id, responseMessage, requestMessage } = data;
const { initialResponse, messages: _messages, userMessage } = submission; const { initialResponse, messages: _messages, userMessage } = submission;
@ -252,7 +252,7 @@ export default function useEventHandlers({
); );
const createdHandler = useCallback( const createdHandler = useCallback(
(data: TResData, submission: TSubmission) => { (data: TResData, submission: EventSubmission) => {
const { messages, userMessage, isRegenerate = false } = submission; const { messages, userMessage, isRegenerate = false } = submission;
const initialResponse = { const initialResponse = {
...submission.initialResponse, ...submission.initialResponse,
@ -329,7 +329,7 @@ export default function useEventHandlers({
); );
const finalHandler = useCallback( const finalHandler = useCallback(
(data: TFinalResData, submission: TSubmission) => { (data: TFinalResData, submission: EventSubmission) => {
const { requestMessage, responseMessage, conversation, runMessages } = data; const { requestMessage, responseMessage, conversation, runMessages } = data;
const { messages, conversation: submissionConvo, isRegenerate = false } = submission; const { messages, conversation: submissionConvo, isRegenerate = false } = submission;
@ -418,7 +418,7 @@ export default function useEventHandlers({
); );
const errorHandler = useCallback( const errorHandler = useCallback(
({ data, submission }: { data?: TResData; submission: TSubmission }) => { ({ data, submission }: { data?: TResData; submission: EventSubmission }) => {
const { messages, userMessage, initialResponse } = submission; const { messages, userMessage, initialResponse } = submission;
setCompleted((prev) => new Set(prev.add(initialResponse.messageId))); setCompleted((prev) => new Set(prev.add(initialResponse.messageId)));
@ -500,7 +500,7 @@ export default function useEventHandlers({
); );
const abortConversation = useCallback( const abortConversation = useCallback(
async (conversationId = '', submission: TSubmission, messages?: TMessage[]) => { async (conversationId = '', submission: EventSubmission, messages?: TMessage[]) => {
const runAbortKey = `${conversationId}:${messages?.[messages.length - 1]?.messageId ?? ''}`; const runAbortKey = `${conversationId}:${messages?.[messages.length - 1]?.messageId ?? ''}`;
console.log({ conversationId, submission, messages, runAbortKey }); console.log({ conversationId, submission, messages, runAbortKey });
const { endpoint: _endpoint, endpointType } = submission.conversation || {}; const { endpoint: _endpoint, endpointType } = submission.conversation || {};

View file

@ -9,7 +9,7 @@ import {
isAssistantsEndpoint, isAssistantsEndpoint,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import { useGetUserBalance, useGetStartupConfig } from 'librechat-data-provider/react-query'; import { useGetUserBalance, useGetStartupConfig } from 'librechat-data-provider/react-query';
import type { TSubmission } from 'librechat-data-provider'; import type { TMessage, TSubmission, EventSubmission } from 'librechat-data-provider';
import type { EventHandlerParams } from './useEventHandlers'; import type { EventHandlerParams } from './useEventHandlers';
import type { TResData } from '~/common'; import type { TResData } from '~/common';
import { useGenTitleMutation } from '~/data-provider'; import { useGenTitleMutation } from '~/data-provider';
@ -38,6 +38,7 @@ export default function useSSE(
const { token, isAuthenticated } = useAuthContext(); const { token, isAuthenticated } = useAuthContext();
const [completed, setCompleted] = useState(new Set()); const [completed, setCompleted] = useState(new Set());
const setAbortScroll = useSetRecoilState(store.abortScrollFamily(runIndex));
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(runIndex)); const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(runIndex));
const { const {
@ -98,54 +99,57 @@ export default function useSSE(
events.onmessage = (e: MessageEvent) => { events.onmessage = (e: MessageEvent) => {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
if (data.final) { if (data.final != null) {
const { plugins } = data; const { plugins } = data;
finalHandler(data, { ...submission, plugins }); finalHandler(data, { ...submission, plugins } as EventSubmission);
startupConfig?.checkBalance && balanceQuery.refetch(); (startupConfig?.checkBalance ?? false) && balanceQuery.refetch();
console.log('final', data); console.log('final', data);
} }
if (data.created) { if (data.created != null) {
const runId = v4(); const runId = v4();
setActiveRunId(runId); setActiveRunId(runId);
userMessage = { userMessage = {
...userMessage, ...userMessage,
...data.message, ...data.message,
overrideParentMessageId: userMessage?.overrideParentMessageId, overrideParentMessageId: userMessage.overrideParentMessageId,
}; };
createdHandler(data, { ...submission, userMessage }); createdHandler(data, { ...submission, userMessage } as EventSubmission);
} else if (data.sync) { } else if (data.sync != null) {
const runId = v4(); const runId = v4();
setActiveRunId(runId); setActiveRunId(runId);
/* synchronize messages to Assistants API as well as with real DB ID's */ /* synchronize messages to Assistants API as well as with real DB ID's */
syncHandler(data, { ...submission, userMessage }); syncHandler(data, { ...submission, userMessage } as EventSubmission);
} else if (data.type) { } else if (data.type != null) {
const { text, index } = data; const { text, index } = data;
if (text && index !== textIndex) { if (text != null && index !== textIndex) {
textIndex = index; textIndex = index;
} }
contentHandler({ data, submission }); contentHandler({ data, submission: submission as EventSubmission });
} else { } else {
const text = data.text || data.response; const text = data.text ?? data.response;
const { plugin, plugins } = data; const { plugin, plugins } = data;
const initialResponse = { const initialResponse = {
...submission.initialResponse, ...(submission.initialResponse as TMessage),
parentMessageId: data.parentMessageId, parentMessageId: data.parentMessageId,
messageId: data.messageId, messageId: data.messageId,
}; };
if (data.message) { if (data.message != null) {
messageHandler(text, { ...submission, plugin, plugins, userMessage, initialResponse }); messageHandler(text, { ...submission, plugin, plugins, userMessage, initialResponse });
} }
} }
}; };
events.onopen = () => console.log('connection is opened'); events.onopen = () => {
setAbortScroll(false);
console.log('connection is opened');
};
events.oncancel = async () => { events.oncancel = async () => {
const streamKey = submission?.initialResponse?.messageId; const streamKey = (submission as TSubmission | null)?.['initialResponse']?.messageId;
if (completed.has(streamKey)) { if (completed.has(streamKey)) {
setIsSubmitting(false); setIsSubmitting(false);
setCompleted((prev) => { setCompleted((prev) => {
@ -157,17 +161,17 @@ export default function useSSE(
setCompleted((prev) => new Set(prev.add(streamKey))); setCompleted((prev) => new Set(prev.add(streamKey)));
const latestMessages = getMessages(); const latestMessages = getMessages();
const conversationId = latestMessages?.[latestMessages?.length - 1]?.conversationId; const conversationId = latestMessages?.[latestMessages.length - 1]?.conversationId;
return await abortConversation( return await abortConversation(
conversationId ?? userMessage?.conversationId ?? submission?.conversationId, conversationId ?? userMessage.conversationId ?? submission.conversationId,
submission, submission as EventSubmission,
latestMessages, latestMessages,
); );
}; };
events.onerror = function (e: MessageEvent) { events.onerror = function (e: MessageEvent) {
console.log('error in server stream.'); console.log('error in server stream.');
startupConfig?.checkBalance && balanceQuery.refetch(); (startupConfig?.checkBalance ?? false) && balanceQuery.refetch();
let data: TResData | undefined = undefined; let data: TResData | undefined = undefined;
try { try {
@ -178,7 +182,7 @@ export default function useSSE(
setIsSubmitting(false); setIsSubmitting(false);
} }
errorHandler({ data, submission: { ...submission, userMessage } }); errorHandler({ data, submission: { ...submission, userMessage } as EventSubmission });
}; };
setIsSubmitting(true); setIsSubmitting(true);

View file

@ -75,7 +75,7 @@ const conversationByIndex = atomFamily<TConversation | null, string | number>({
const index = Number(node.key.split('__')[1]); const index = Number(node.key.split('__')[1]);
if (newValue?.assistant_id) { if (newValue?.assistant_id) {
localStorage.setItem( localStorage.setItem(
`${LocalStorageKeys.ASST_ID_PREFIX}${index}${newValue?.endpoint}`, `${LocalStorageKeys.ASST_ID_PREFIX}${index}${newValue.endpoint}`,
newValue.assistant_id, newValue.assistant_id,
); );
} }
@ -139,6 +139,17 @@ const showStopButtonByIndex = atomFamily<boolean, string | number>({
const abortScrollFamily = atomFamily({ const abortScrollFamily = atomFamily({
key: 'abortScrollByIndex', key: 'abortScrollByIndex',
default: false, default: false,
effects: [
({ onSet, node }) => {
onSet(async (newValue) => {
const key = Number(node.key.split(Constants.COMMON_DIVIDER)[1]);
logger.log('message_scrolling', 'Recoil Effect: Setting abortScrollByIndex', {
key,
newValue,
});
});
},
] as const,
}); });
const isSubmittingFamily = atomFamily({ const isSubmittingFamily = atomFamily({

View file

@ -1,37 +1,44 @@
const isDevelopment = import.meta.env.MODE === 'development'; const isDevelopment = import.meta.env.MODE === 'development';
const isLoggerEnabled = import.meta.env.VITE_ENABLE_LOGGER === 'true'; const isLoggerEnabled = import.meta.env.VITE_ENABLE_LOGGER === 'true';
const loggerFilter = import.meta.env.VITE_LOGGER_FILTER || '';
const logger = { type LogFunction = (...args: unknown[]) => void;
log: (...args: unknown[]) => {
const createLogFunction = (consoleMethod: LogFunction): LogFunction => {
return (...args: unknown[]) => {
if (isDevelopment || isLoggerEnabled) { if (isDevelopment || isLoggerEnabled) {
console.log(...args); const tag = typeof args[0] === 'string' ? args[0] : '';
if (shouldLog(tag)) {
if (tag && args.length > 1) {
consoleMethod(`[${tag}]`, ...args.slice(1));
} else {
consoleMethod(...args);
}
}
} }
}, };
warn: (...args: unknown[]) => {
if (isDevelopment || isLoggerEnabled) {
console.warn(...args);
}
},
error: (...args: unknown[]) => {
if (isDevelopment || isLoggerEnabled) {
console.error(...args);
}
},
info: (...args: unknown[]) => {
if (isDevelopment || isLoggerEnabled) {
console.info(...args);
}
},
debug: (...args: unknown[]) => {
if (isDevelopment || isLoggerEnabled) {
console.debug(...args);
}
},
dir: (...args: unknown[]) => {
if (isDevelopment || isLoggerEnabled) {
console.dir(...args);
}
},
}; };
const logger = {
log: createLogFunction(console.log),
warn: createLogFunction(console.warn),
error: createLogFunction(console.error),
info: createLogFunction(console.info),
debug: createLogFunction(console.debug),
dir: createLogFunction(console.dir),
};
function shouldLog(tag: string): boolean {
if (!loggerFilter) {
return true;
}
/* If no tag is provided, always log */
if (!tag) {
return true;
}
return loggerFilter
.split(',')
.some((filter) => tag.toLowerCase().includes(filter.trim().toLowerCase()));
}
export default logger; export default logger;

View file

@ -2,6 +2,7 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_ENABLE_LOGGER: string; readonly VITE_ENABLE_LOGGER: string;
readonly VITE_LOGGER_FILTER: string;
// Add other env variables here // Add other env variables here
} }

2
package-lock.json generated
View file

@ -31493,7 +31493,7 @@
}, },
"packages/data-provider": { "packages/data-provider": {
"name": "librechat-data-provider", "name": "librechat-data-provider",
"version": "0.7.413", "version": "0.7.414",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",

View file

@ -1,6 +1,6 @@
{ {
"name": "librechat-data-provider", "name": "librechat-data-provider",
"version": "0.7.413", "version": "0.7.414",
"description": "data services for librechat apps", "description": "data services for librechat apps",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.es.js", "module": "dist/index.es.js",

View file

@ -58,11 +58,13 @@ export type TSubmission = {
messages: TMessage[]; messages: TMessage[];
isRegenerate?: boolean; isRegenerate?: boolean;
conversationId?: string; conversationId?: string;
initialResponse: TMessage; initialResponse?: TMessage;
conversation: Partial<TConversation>; conversation: Partial<TConversation>;
endpointOption: TEndpointOption; endpointOption: TEndpointOption;
}; };
export type EventSubmission = Omit<TSubmission, 'initialResponse'> & { initialResponse: TMessage };
export type TPluginAction = { export type TPluginAction = {
pluginKey: string; pluginKey: string;
action: 'install' | 'uninstall'; action: 'install' | 'uninstall';