🌿 feat: Multi-response Streaming (#3191)

* chore: comment back handlePlusCommand

* chore: ignore .git dir

* refactor: pass newConversation to `useSelectMention`

refactor: pass newConversation to Mention component

refactor: useChatFunctions for modular use of `ask` and `regenerate`

refactor: set latest message only for the first index in useChatFunctions

refactor: pass setLatestMessage to useChatFunctions

refactor: Pass setSubmission to useChatFunctions for submission handling

refactor: consolidate event handlers to separate hook from useSSE

WIP: additional response handlers

feat: responsive added convo, clears on new chat/navigating to chat, assistants excluded

feat: Add conversationByKeySelector to select any conversation by index

WIP: handle second submission with messages paired to root

* style: surface-primary-contrast

* refactor: remove unnecessary console.log statement in useChatFunctions

* refactor: Consolidate imports in ChatForm and Input hooks

* refactor: compositional usage of useSSE for multiple streams

* WIP: set latest 'multi' message

* WIP: first pass, added response streaming

* pass: performant multi-message stream

* fix: styling and message render

* second pass: modular, performant multi-stream

* fix: align parentMessageId of multiMessage

* refactor: move resetting latestMultiMessage

* chore: update footer text in Chat component

* fix: stop button styling

* fix: handle abortMessage request for multi-response

* clear messages but bug with latest message reset present

* fix: add delay for additional message generation

* fix: access LAST_CONVO_SETUP by index

* style: add div to prevent layout shift before hover buttons render

* chore: Update Message component styling for card messages

* chore: move hook use order

* fix: abort middleware using unsent field from req.body

* feat: support multi-response stream from initial message

* refactor: buildTree function to improve readability and remove unused code

* feat: add logger for frontend dev

* refactor: use depth to track if message is really last in its branch

* fix(buildTree): default export

* fix: share parent message Id and avoid duplication error for multi-response streams

* fix: prevent addedConvo reset to response convo

* feat: allow setting multi message as latest message to control which to respond to

* chore: wrap setSiblingIdxRev with useCallback

* chore: styling and allow editing messages

* style: styling fixes

* feat: Add "AddMultiConvo" component to Chat Header

* feat: prevent clearing added convos on endpoint, preset, mention, or modelSpec switch

* fix: message styling fixes, mainly related to code blocks

* fix: stop button visibility logic

* fix: Handle edge case in abortMiddleware for non-existant `abortControllers`

* refactor: optimize/memoize icons

* chore(GoogleClient): change info to debug logs

* style: active message styling

* style: prevent layout shift due to placeholder row

* chore: remove unused code

* fix: Update BaseClient to handle optional request body properties

* fix(ci): `onStart` now accepts 2 args, the 2nd being responseMessageId

* chore: bump data-provider
This commit is contained in:
Danny Avila 2024-06-25 03:02:38 -04:00 committed by GitHub
parent eef894e608
commit 156c52e293
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 2697 additions and 1326 deletions

View file

@ -0,0 +1,47 @@
import { PlusCircle } from 'lucide-react';
import { isAssistantsEndpoint } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import { useChatContext, useAddedChatContext } from '~/Providers';
import { mainTextareaId } from '~/common';
import { cn } from '~/utils';
function AddMultiConvo({ className = '' }: { className?: string }) {
const { conversation } = useChatContext();
const { setConversation: setAddedConvo } = useAddedChatContext();
const clickHandler = () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { title: _t, ...convo } = conversation ?? ({} as TConversation);
setAddedConvo({
...convo,
title: '',
});
const textarea = document.getElementById(mainTextareaId);
if (textarea) {
textarea.focus();
}
};
if (!conversation) {
return null;
}
if (isAssistantsEndpoint(conversation.endpoint)) {
return null;
}
return (
<button
onClick={clickHandler}
className={cn(
'group m-1.5 flex w-fit cursor-pointer items-center rounded text-sm hover:bg-border-medium focus-visible:bg-border-medium focus-visible:outline-0',
className,
)}
>
<PlusCircle size={16} />
</button>
);
}
export default AddMultiConvo;

View file

@ -4,9 +4,9 @@ import { useForm } from 'react-hook-form';
import { useParams } from 'react-router-dom';
import { useGetMessagesByConvoId } from 'librechat-data-provider/react-query';
import type { ChatFormValues } from '~/common';
import { ChatContext, useFileMapContext, ChatFormProvider } from '~/Providers';
import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } from '~/Providers';
import { useChatHelpers, useAddedResponse, useSSE } from '~/hooks';
import MessagesView from './Messages/MessagesView';
import { useChatHelpers, useSSE } from '~/hooks';
import { Spinner } from '~/components/svg';
import Presentation from './Presentation';
import ChatForm from './Input/ChatForm';
@ -18,8 +18,8 @@ import store from '~/store';
function ChatView({ index = 0 }: { index?: number }) {
const { conversationId } = useParams();
const submissionAtIndex = useRecoilValue(store.submissionByIndex(0));
useSSE(submissionAtIndex);
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
const addedSubmission = useRecoilValue(store.submissionByIndex(index + 1));
const fileMap = useFileMapContext();
@ -32,35 +32,35 @@ function ChatView({ index = 0 }: { index?: number }) {
});
const chatHelpers = useChatHelpers(index, conversationId);
const addedChatHelpers = useAddedResponse({ rootIndex: index });
useSSE(rootSubmission, chatHelpers, false);
useSSE(addedSubmission, addedChatHelpers, true);
const methods = useForm<ChatFormValues>({
defaultValues: { text: '' },
});
return (
<ChatFormProvider
reset={methods.reset}
control={methods.control}
setValue={methods.setValue}
register={methods.register}
getValues={methods.getValues}
handleSubmit={methods.handleSubmit}
>
<ChatFormProvider {...methods}>
<ChatContext.Provider value={chatHelpers}>
<Presentation useSidePanel={true}>
{isLoading && conversationId !== 'new' ? (
<div className="flex h-screen items-center justify-center">
<Spinner className="opacity-0" />
<AddedChatContext.Provider value={addedChatHelpers}>
<Presentation useSidePanel={true}>
{isLoading && conversationId !== 'new' ? (
<div className="flex h-screen items-center justify-center">
<Spinner className="opacity-0" />
</div>
) : messagesTree && messagesTree.length !== 0 ? (
<MessagesView messagesTree={messagesTree} Header={<Header />} />
) : (
<Landing Header={<Header />} />
)}
<div className="w-full border-t-0 pl-0 pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
<ChatForm index={index} />
<Footer />
</div>
) : messagesTree && messagesTree.length !== 0 ? (
<MessagesView messagesTree={messagesTree} Header={<Header />} />
) : (
<Landing Header={<Header />} />
)}
<div className="w-full border-t-0 pl-0 pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
<ChatForm index={index} />
<Footer />
</div>
</Presentation>
</Presentation>
</AddedChatContext.Provider>
</ChatContext.Provider>
</ChatFormProvider>
);

View file

@ -47,7 +47,7 @@ export default function Footer({ className }: { className?: string }) {
: '[LibreChat ' +
Constants.VERSION +
'](https://librechat.ai) - ' +
localize('com_ui_pay_per_call')
localize('com_ui_latest_footer')
).split('|');
const mainContentRender = mainContentParts.map((text, index) => (

View file

@ -6,6 +6,7 @@ import type { ContextType } from '~/common';
import { EndpointsMenu, ModelSpecsMenu, PresetsMenu, HeaderNewChat } from './Menus';
import ExportAndShareMenu from './ExportAndShareMenu';
import HeaderOptions from './Input/HeaderOptions';
import AddMultiConvo from './AddMultiConvo';
import { useMediaQuery } from '~/hooks';
const defaultInterface = getConfigDefaults().interface;
@ -36,6 +37,7 @@ export default function Header() {
className="pl-0"
/>
)}
<AddMultiConvo />
</div>
{!isSmallScreen && (
<ExportAndShareMenu isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false} />

View file

@ -0,0 +1,66 @@
import { useMemo } from 'react';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { TConversation, TEndpointOption, TPreset } from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import useGetSender from '~/hooks/Conversations/useGetSender';
import { EndpointIcon } from '~/components/Endpoints';
import { getPresetTitle } from '~/utils';
export default function AddedConvo({
addedConvo,
setAddedConvo,
}: {
addedConvo: TConversation | null;
setAddedConvo: SetterOrUpdater<TConversation | null>;
}) {
const getSender = useGetSender();
const { data: endpointsConfig } = useGetEndpointsQuery();
const title = useMemo(() => {
const sender = getSender(addedConvo as TEndpointOption);
const title = getPresetTitle(addedConvo as TPreset);
return `+ ${sender}: ${title}`;
}, [addedConvo, getSender]);
if (!addedConvo) {
return null;
}
return (
<div className="flex items-start gap-4 py-2.5 pl-3 pr-1.5 text-sm">
<span className="mt-0 flex h-6 w-6 flex-shrink-0 items-center justify-center">
<div className="icon-md">
<EndpointIcon
conversation={addedConvo}
endpointsConfig={endpointsConfig}
containerClassName="shadow-stroke overflow-hidden rounded-full"
context="menu-item"
size={20}
/>
</div>
</span>
<span className="text-token-text-secondary line-clamp-3 flex-1 py-0.5 font-semibold">
{title}
</span>
<button
className="text-token-text-secondary flex-shrink-0"
type="button"
onClick={() => setAddedConvo(null)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
className="icon-lg"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M7.293 7.293a1 1 0 0 1 1.414 0L12 10.586l3.293-3.293a1 1 0 1 1 1.414 1.414L13.414 12l3.293 3.293a1 1 0 0 1-1.414 1.414L12 13.414l-3.293 3.293a1 1 0 0 1-1.414-1.414L10.586 12 7.293 8.707a1 1 0 0 1 0-1.414"
clipRule="evenodd"
></path>
</svg>
</button>
</div>
);
}

View file

@ -6,12 +6,23 @@ import {
isAssistantsEndpoint,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import { useChatContext, useAssistantsMapContext, useChatFormContext } from '~/Providers';
import { useRequiresKey, useTextarea, useSubmitMessage, useHandleKeyUp } from '~/hooks';
import { useAutoSave } from '~/hooks/Input/useAutoSave';
import {
useChatContext,
useAddedChatContext,
useAssistantsMapContext,
useChatFormContext,
} from '~/Providers';
import {
useTextarea,
useAutoSave,
useRequiresKey,
useHandleKeyUp,
useSubmitMessage,
} from '~/hooks';
import { TextareaAutosize } from '~/components/ui';
import { useGetFileConfig } from '~/data-provider';
import { cn, removeFocusRings } from '~/utils';
import TextareaHeader from './TextareaHeader';
import AttachFile from './Files/AttachFile';
import AudioRecorder from './AudioRecorder';
import { mainTextareaId } from '~/common';
@ -25,16 +36,19 @@ import store from '~/store';
const ChatForm = ({ index = 0 }) => {
const submitButtonRef = useRef<HTMLButtonElement>(null);
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const SpeechToText = useRecoilValue(store.SpeechToText);
const TextToSpeech = useRecoilValue(store.TextToSpeech);
const automaticPlayback = useRecoilValue(store.automaticPlayback);
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 handleKeyUp = useHandleKeyUp({ index, textAreaRef });
const { requiresKey } = useRequiresKey();
const handleKeyUp = useHandleKeyUp({ textAreaRef, setShowPlusPopover, setShowMentionPopover });
const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({
textAreaRef,
submitButtonRef,
@ -48,9 +62,18 @@ const ChatForm = ({ index = 0 }) => {
isSubmitting,
filesLoading,
setFilesLoading,
newConversation,
handleStopGenerating,
} = useChatContext();
const methods = useChatFormContext();
const {
addedIndex,
generateConversation,
conversation: addedConvo,
setConversation: setAddedConvo,
isSubmitting: isSubmittingAdded,
} = useAddedChatContext();
const showStopAdded = useRecoilValue(store.showStopButtonByIndex(addedIndex));
const { clearDraft } = useAutoSave({
conversationId: useMemo(() => conversation?.conversationId, [conversation]),
@ -96,10 +119,25 @@ const ChatForm = ({ index = 0 }) => {
>
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
<div className="flex w-full items-center">
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
<Mention
setShowMentionPopover={setShowPlusPopover}
newConversation={generateConversation}
textAreaRef={textAreaRef}
commandChar="+"
placeholder="com_ui_add"
includeAssistants={false}
/>
)}
{showMentionPopover && (
<Mention setShowMentionPopover={setShowMentionPopover} textAreaRef={textAreaRef} />
<Mention
setShowMentionPopover={setShowMentionPopover}
newConversation={newConversation}
textAreaRef={textAreaRef}
/>
)}
<div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border dark:border-gray-600 dark:text-white [&:has(textarea:focus)]:border-gray-300 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)] dark:[&:has(textarea:focus)]:border-gray-500">
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<FileRow
files={files}
setFiles={setFiles}
@ -145,7 +183,7 @@ const ChatForm = ({ index = 0 }) => {
endpointType={endpointType}
disabled={disableInputs}
/>
{isSubmitting && showStopButton ? (
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
) : (
endpoint && (

View file

@ -1,30 +1,39 @@
import { useState, useRef, useEffect } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import type { MentionOption } from '~/common';
import type { MentionOption, ConvoGenerator } from '~/common';
import useSelectMention from '~/hooks/Input/useSelectMention';
import { useAssistantsMapContext } from '~/Providers';
import useMentions from '~/hooks/Input/useMentions';
import { useLocalize, useCombobox } from '~/hooks';
import { removeAtSymbolIfLast } from '~/utils';
import { removeCharIfLast } from '~/utils';
import MentionItem from './MentionItem';
export default function Mention({
setShowMentionPopover,
newConversation,
textAreaRef,
commandChar = '@',
placeholder = 'com_ui_mention',
includeAssistants = true,
}: {
setShowMentionPopover: SetterOrUpdater<boolean>;
newConversation: ConvoGenerator;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
commandChar?: string;
placeholder?: string;
includeAssistants?: boolean;
}) {
const localize = useLocalize();
const assistantMap = useAssistantsMapContext();
const { options, presets, modelSpecs, modelsConfig, endpointsConfig, assistantListMap } =
useMentions({ assistantMap });
useMentions({ assistantMap, includeAssistants });
const { onSelectMention } = useSelectMention({
presets,
modelSpecs,
assistantMap,
endpointsConfig,
newConversation,
});
const [activeIndex, setActiveIndex] = useState(0);
@ -49,7 +58,7 @@ export default function Mention({
onSelectMention(mention);
if (textAreaRef.current) {
removeAtSymbolIfLast(textAreaRef.current);
removeCharIfLast(textAreaRef.current, commandChar);
}
};
@ -105,7 +114,7 @@ export default function Mention({
<input
autoFocus
ref={inputRef}
placeholder={localize('com_ui_mention')}
placeholder={localize(placeholder)}
className="mb-1 w-full border-0 bg-white p-2 text-sm focus:outline-none dark:bg-gray-700 dark:text-gray-200"
autoComplete="off"
value={searchValue}

View file

@ -1,33 +1,29 @@
export default function StopButton({ stop, setShowStopButton }) {
return (
<div className="absolute bottom-0 right-2 top-0 p-1 md:right-3 md:p-2">
<div className="flex h-full">
<div className="flex h-full flex-row items-center justify-center gap-3">
<button
type="button"
className="border-gizmo-gray-900 rounded-full border-2 p-1 dark:border-gray-200"
aria-label="Stop generating"
onClick={(e) => {
setShowStopButton(false);
stop(e);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
className="text-gizmo-gray-900 h-2 w-2 dark:text-gray-200"
height="16"
width="16"
>
<path
d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2z"
strokeWidth="0"
></path>
</svg>
</button>
</div>
</div>
<div className="absolute bottom-3 right-2 md:bottom-4 md:right-4">
<button
type="button"
className="border-gizmo-gray-900 rounded-full border-2 p-1 dark:border-gray-200"
aria-label="Stop generating"
onClick={(e) => {
setShowStopButton(false);
stop(e);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
className="text-gizmo-gray-900 h-2 w-2 dark:text-gray-200"
height="16"
width="16"
>
<path
d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2z"
strokeWidth="0"
></path>
</svg>
</button>
</div>
);
}

View file

@ -0,0 +1,20 @@
import AddedConvo from './AddedConvo';
import type { TConversation } from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
export default function TextareaHeader({
addedConvo,
setAddedConvo,
}: {
addedConvo: TConversation | null;
setAddedConvo: SetterOrUpdater<TConversation | null>;
}) {
if (!addedConvo) {
return null;
}
return (
<div className="divide-token-border-light m-1.5 flex flex-col divide-y overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-primary-contrast">
<AddedConvo addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
</div>
);
}

View file

@ -50,12 +50,12 @@ const MenuItem: FC<MenuItemProps> = ({
}
const {
template,
shouldSwitch,
isNewModular,
newEndpointType,
isCurrentModular,
isExistingConversation,
newEndpointType,
template,
} = getConvoSwitchLogic({
newEndpoint,
modularChat,
@ -63,7 +63,8 @@ const MenuItem: FC<MenuItemProps> = ({
endpointsConfig,
});
if (isExistingConversation && isCurrentModular && isNewModular && shouldSwitch) {
const isModular = isCurrentModular && isNewModular && shouldSwitch;
if (isExistingConversation && isModular) {
template.endpointType = newEndpointType;
const currentConvo = getDefaultConversation({
@ -73,10 +74,18 @@ const MenuItem: FC<MenuItemProps> = ({
});
/* We don't reset the latest message, only when changing settings mid-converstion */
newConversation({ template: currentConvo, preset: currentConvo, keepLatestMessage: true });
newConversation({
template: currentConvo,
preset: currentConvo,
keepLatestMessage: true,
keepAddedConvos: true,
});
return;
}
newConversation({ template: { ...(template as Partial<TConversation>) } });
newConversation({
template: { ...(template as Partial<TConversation>) },
keepAddedConvos: isModular,
});
};
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');

View file

@ -29,12 +29,12 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs: TModelSpec[
}
const {
template,
shouldSwitch,
isNewModular,
newEndpointType,
isCurrentModular,
isExistingConversation,
newEndpointType,
template,
} = getConvoSwitchLogic({
newEndpoint,
modularChat,
@ -42,7 +42,8 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs: TModelSpec[
endpointsConfig,
});
if (isExistingConversation && isCurrentModular && isNewModular && shouldSwitch) {
const isModular = isCurrentModular && isNewModular && shouldSwitch;
if (isExistingConversation && isModular) {
template.endpointType = newEndpointType as EModelEndpoint | undefined;
const currentConvo = getDefaultConversation({
@ -52,11 +53,20 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs: TModelSpec[
});
/* We don't reset the latest message, only when changing settings mid-converstion */
newConversation({ template: currentConvo, preset, keepLatestMessage: true });
newConversation({
template: currentConvo,
preset,
keepLatestMessage: true,
keepAddedConvos: true,
});
return;
}
newConversation({ template: { ...(template as Partial<TConversation>) }, preset });
newConversation({
template: { ...(template as Partial<TConversation>) },
preset,
keepAddedConvos: isModular,
});
};
const selected = useMemo(() => {

View file

@ -1,12 +1,14 @@
import { useRecoilState } from 'recoil';
import TextareaAutosize from 'react-textarea-autosize';
import { EModelEndpoint } from 'librechat-data-provider';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useUpdateMessageMutation } from 'librechat-data-provider/react-query';
import type { TEditProps } from '~/common';
import { useChatContext, useAddedChatContext } from '~/Providers';
import { cn, removeFocusRings } from '~/utils';
import { useChatContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import Container from './Container';
import store from '~/store';
const EditMessage = ({
text,
@ -17,7 +19,11 @@ const EditMessage = ({
siblingIdx,
setSiblingIdx,
}: TEditProps) => {
const { addedIndex } = useAddedChatContext();
const { getMessages, setMessages, conversation } = useChatContext();
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
store.latestMessageFamily(addedIndex),
);
const [editedText, setEditedText] = useState<string>(text ?? '');
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
@ -85,17 +91,28 @@ const EditMessage = ({
text: editedText,
messageId,
});
setMessages(
messages.map((msg) =>
msg.messageId === messageId
? {
...msg,
text: editedText,
isEdited: true,
}
: msg,
),
);
if (message.messageId === latestMultiMessage?.messageId) {
setLatestMultiMessage({ ...latestMultiMessage, text: editedText });
}
const isInMessages = messages?.some((message) => message?.messageId === messageId);
if (!isInMessages) {
message.text = editedText;
} else {
setMessages(
messages.map((msg) =>
msg.messageId === messageId
? {
...msg,
text: editedText,
isEdited: true,
}
: msg,
),
);
}
enterEdit(true);
};

View file

@ -1,5 +1,6 @@
import { useRecoilValue } from 'recoil';
import { useAuthContext, useMessageHelpers, useLocalize } from '~/hooks';
import React, { useCallback, useMemo } from 'react';
import { useMessageProcess, useMessageActions } from '~/hooks';
import type { TMessage } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import Icon from '~/components/Chat/Messages/MessageIcon';
import { Plugin } from '~/components/Messages/Content';
@ -10,120 +11,213 @@ import MultiMessage from './MultiMessage';
import HoverButtons from './HoverButtons';
import SubRow from './SubRow';
import { cn } from '~/utils';
import store from '~/store';
export default function Message(props: TMessageProps) {
const UsernameDisplay = useRecoilValue<boolean>(store.UsernameDisplay);
const { user } = useAuthContext();
const localize = useLocalize();
const {
ask,
edit,
index,
isLast,
assistant,
enterEdit,
handleScroll,
conversation,
isSubmitting,
latestMessage,
handleContinue,
copyToClipboard,
regenerateMessage,
} = useMessageHelpers(props);
const { message, siblingIdx, siblingCount, setSiblingIdx, currentEditId, setCurrentEditId } =
props;
if (!message) {
return null;
}
const { text, children, messageId = null, isCreatedByUser, error, unfinished } = message ?? {};
let messageLabel = '';
if (isCreatedByUser) {
messageLabel = UsernameDisplay ? user?.name || user?.username : localize('com_user_message');
} else if (assistant) {
messageLabel = assistant.name ?? 'Assistant';
} else {
messageLabel = message.sender;
}
return (
<>
const MessageContainer = React.memo(
({ handleScroll, children }: { handleScroll: () => void; children: React.ReactNode }) => {
return (
<div
className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent"
onWheel={handleScroll}
onTouchMove={handleScroll}
>
<div className="m-auto justify-center p-4 py-2 text-base md:gap-6 ">
<div className="final-completion group mx-auto flex flex-1 gap-3 text-base md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
<div className="relative flex flex-shrink-0 flex-col items-end">
<div>
<div className="pt-0.5">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<Icon message={message} conversation={conversation} assistant={assistant} />
</div>
</div>
{children}
</div>
);
},
);
const PlaceholderRow = React.memo(({ isLast, isCard }: { isLast: boolean; isCard?: boolean }) => {
if (!isLast && !isCard) {
return null;
}
return <div className="mt-1 h-[27px] bg-transparent" />;
});
type MessageRenderProps = {
message?: TMessage;
isCard?: boolean;
isMultiMessage?: boolean;
isSubmittingFamily?: boolean;
} & Pick<
TMessageProps,
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
>;
const MessageRender = React.memo(
({
isCard,
siblingIdx,
siblingCount,
message: msg,
setSiblingIdx,
currentEditId,
isMultiMessage,
setCurrentEditId,
isSubmittingFamily,
}: MessageRenderProps) => {
const {
ask,
edit,
index,
assistant,
enterEdit,
conversation,
messageLabel,
isSubmitting,
latestMessage,
handleContinue,
copyToClipboard,
setLatestMessage,
regenerateMessage,
} = useMessageActions({
message: msg,
currentEditId,
isMultiMessage,
setCurrentEditId,
});
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
const { isCreatedByUser, error, unfinished } = msg ?? {};
const isLast = useMemo(
() => !msg?.children?.length && (msg?.depth === latestMessage?.depth || msg?.depth === -1),
[msg?.children, msg?.depth, latestMessage?.depth],
);
if (!msg) {
return null;
}
const isLatest = isCard && !isSubmittingFamily && msg.messageId === latestMessage?.messageId;
const clickHandler =
isLast && isCard && !isSubmittingFamily && msg.messageId !== latestMessage?.messageId
? () => setLatestMessage(msg)
: undefined;
return (
<div
className={cn(
'final-completion group mx-auto flex flex-1 gap-3 text-base',
isCard
? 'relative w-full gap-1 rounded-lg border border-border-medium bg-surface-primary-alt p-2 md:w-1/2 md:gap-3 md:p-4'
: '',
isLatest ? 'bg-surface-secondary' : '',
isLast && !isSubmittingFamily && isCard
? 'cursor-pointer transition-colors duration-300'
: '',
)}
onClick={clickHandler}
>
{isLatest && (
<div className="absolute right-0 top-0 m-2 h-3 w-3 rounded-full bg-text-primary"></div>
)}
<div className="relative flex flex-shrink-0 flex-col items-end">
<div>
<div className="pt-0.5">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<Icon message={msg} conversation={conversation} assistant={assistant} />
</div>
</div>
<div
className={cn('relative flex w-11/12 flex-col', isCreatedByUser ? '' : 'agent-turn')}
>
<div className="select-none font-semibold">{messageLabel}</div>
<div className="flex-col gap-1 md:gap-3">
<div className="flex max-w-full flex-grow flex-col gap-0">
{/* Legacy Plugins */}
{message?.plugin && <Plugin plugin={message?.plugin} />}
<MessageContent
ask={ask}
edit={edit}
isLast={isLast}
text={text ?? ''}
message={message}
enterEdit={enterEdit}
error={!!error}
isSubmitting={isSubmitting}
unfinished={unfinished ?? false}
isCreatedByUser={isCreatedByUser ?? true}
siblingIdx={siblingIdx ?? 0}
setSiblingIdx={
setSiblingIdx ??
(() => {
return;
})
}
/>
</div>
</div>
{isLast && isSubmitting ? null : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
index={index}
isEditing={edit}
message={message}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={() => regenerateMessage()}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
isLast={isLast}
/>
</SubRow>
)}
</div>
</div>
</div>
<div
className={cn('relative flex w-11/12 flex-col', msg?.isCreatedByUser ? '' : 'agent-turn')}
>
<div className="select-none font-semibold">{messageLabel}</div>
<div className="flex-col gap-1 md:gap-3">
<div className="flex max-w-full flex-grow flex-col gap-0">
{msg?.plugin && <Plugin plugin={msg?.plugin} />}
<MessageContent
ask={ask}
edit={edit}
isLast={isLast}
text={msg.text ?? ''}
message={msg}
enterEdit={enterEdit}
error={!!error}
isSubmitting={isSubmitting}
unfinished={unfinished ?? false}
isCreatedByUser={isCreatedByUser ?? true}
siblingIdx={siblingIdx ?? 0}
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
/>
</div>
</div>
{!msg?.children?.length && (isSubmittingFamily || isSubmitting) ? (
<PlaceholderRow isLast={isLast} isCard={isCard} />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
index={index}
isEditing={edit}
message={msg}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={handleRegenerateMessage}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
isLast={isLast}
/>
</SubRow>
)}
</div>
</div>
);
},
);
export default function Message(props: TMessageProps) {
const {
showSibling,
conversation,
handleScroll,
siblingMessage,
latestMultiMessage,
isSubmittingFamily,
} = useMessageProcess({ message: props.message });
const { message, currentEditId, setCurrentEditId } = props;
if (!message) {
return null;
}
const { children, messageId = null } = message ?? {};
return (
<>
<MessageContainer handleScroll={handleScroll}>
{showSibling ? (
<div className="m-auto my-2 flex justify-center p-4 py-2 text-base md:gap-6">
<div className="flex w-full flex-row flex-wrap justify-between gap-1 md:max-w-5xl md:flex-nowrap md:gap-2 lg:max-w-5xl xl:max-w-6xl">
<MessageRender
{...props}
message={message}
isSubmittingFamily={isSubmittingFamily}
isCard
/>
<MessageRender
{...props}
isMultiMessage
isCard
message={siblingMessage ?? latestMultiMessage ?? undefined}
isSubmittingFamily={isSubmittingFamily}
/>
</div>
</div>
) : (
<div className="m-auto justify-center p-4 py-2 text-base md:gap-6 ">
<div className="final-completion group mx-auto flex flex-1 gap-3 text-base md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
<MessageRender {...props} />
</div>
</div>
)}
</MessageContainer>
<MultiMessage
key={messageId}
messageId={messageId}

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, memo } from 'react';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { TMessage, TPreset, Assistant } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
@ -6,7 +6,7 @@ import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { getEndpointField, getIconEndpoint } from '~/utils';
import Icon from '~/components/Endpoints/Icon';
export default function MessageIcon(
function MessageIcon(
props: Pick<TMessageProps, 'message' | 'conversation'> & {
assistant?: false | Assistant;
},
@ -56,3 +56,5 @@ export default function MessageIcon(
/>
);
}
export default memo(MessageIcon);

View file

@ -83,7 +83,9 @@ export default function Message(props: TMessageProps) {
/>
</div>
</div>
{isLast && isSubmitting ? null : (
{isLast && isSubmitting ? (
<div className="mt-1 h-[27px] bg-transparent" />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}

View file

@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import { useEffect, useCallback } from 'react';
import type { TMessageProps } from '~/common';
// eslint-disable-next-line import/no-cycle
import Message from './Message';
@ -16,9 +16,12 @@ export default function MultiMessage({
}: TMessageProps) {
const [siblingIdx, setSiblingIdx] = useRecoilState(store.messagesSiblingIdxFamily(messageId));
const setSiblingIdxRev = (value: number) => {
setSiblingIdx((messagesTree?.length ?? 0) - value - 1);
};
const setSiblingIdxRev = useCallback(
(value: number) => {
setSiblingIdx((messagesTree?.length ?? 0) - value - 1);
},
[messagesTree?.length, setSiblingIdx],
);
useEffect(() => {
// reset siblingIdx when the tree changes, mostly when a new message is submitting.

View file

@ -1,10 +1,11 @@
import { UserIcon } from '~/components/svg';
import { memo } from 'react';
import type { IconProps } from '~/common';
import MessageEndpointIcon from './MessageEndpointIcon';
import { useAuthContext } from '~/hooks/AuthContext';
import useAvatar from '~/hooks/Messages/useAvatar';
import useLocalize from '~/hooks/useLocalize';
import { IconProps } from '~/common';
import { UserIcon } from '~/components/svg';
import { cn } from '~/utils';
import MessageEndpointIcon from './MessageEndpointIcon';
const Icon: React.FC<IconProps> = (props) => {
const { user } = useAuthContext();
@ -46,4 +47,4 @@ const Icon: React.FC<IconProps> = (props) => {
return <MessageEndpointIcon {...props} />;
};
export default Icon;
export default memo(Icon);