🌿 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,264 @@
import { v4 } from 'uuid';
import { useQueryClient } from '@tanstack/react-query';
import {
Constants,
QueryKeys,
ContentTypes,
parseCompactConvo,
isAssistantsEndpoint,
} from 'librechat-data-provider';
import { useSetRecoilState, useResetRecoilState } from 'recoil';
import type {
TMessage,
TSubmission,
TConversation,
TEndpointOption,
TEndpointsConfig,
} from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import type { TAskFunction, ExtendedFile } from '~/common';
import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete';
import useGetSender from '~/hooks/Conversations/useGetSender';
import { getEndpointField, logger } from '~/utils';
import useUserKey from '~/hooks/Input/useUserKey';
import store from '~/store';
export default function useChatFunctions({
index = 0,
files,
setFiles,
getMessages,
setMessages,
isSubmitting,
conversation,
latestMessage,
setSubmission,
setLatestMessage,
}: {
index?: number;
isSubmitting: boolean;
paramId?: string | undefined;
conversation: TConversation | null;
latestMessage: TMessage | null;
getMessages: () => TMessage[] | undefined;
setMessages: (messages: TMessage[]) => void;
files?: Map<string, ExtendedFile>;
setFiles?: SetterOrUpdater<Map<string, ExtendedFile>>;
setSubmission: SetterOrUpdater<TSubmission | null>;
setLatestMessage?: SetterOrUpdater<TMessage | null>;
}) {
const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1));
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
const setFilesToDelete = useSetFilesToDelete();
const getSender = useGetSender();
const queryClient = useQueryClient();
const { getExpiry } = useUserKey(conversation?.endpoint ?? '');
const ask: TAskFunction = (
{
text,
overrideConvoId,
overrideUserMessageId,
parentMessageId = null,
conversationId = null,
messageId = null,
},
{
editedText = null,
editedMessageId = null,
resubmitFiles = false,
isRegenerate = false,
isContinued = false,
isEdited = false,
overrideMessages,
} = {},
) => {
setShowStopButton(false);
resetLatestMultiMessage();
if (!!isSubmitting || text === '') {
return;
}
const endpoint = conversation?.endpoint;
if (endpoint === null) {
console.error('No endpoint available');
return;
}
conversationId = conversationId ?? conversation?.conversationId ?? null;
if (conversationId == 'search') {
console.error('cannot send any message under search view!');
return;
}
if (isContinued && !latestMessage) {
console.error('cannot continue AI message without latestMessage!');
return;
}
const isEditOrContinue = isEdited || isContinued;
let currentMessages: TMessage[] | null = overrideMessages ?? getMessages() ?? [];
// construct the query message
// this is not a real messageId, it is used as placeholder before real messageId returned
text = text.trim();
const intermediateId = overrideUserMessageId ?? v4();
parentMessageId = parentMessageId || latestMessage?.messageId || Constants.NO_PARENT;
if (conversationId == 'new') {
parentMessageId = Constants.NO_PARENT;
currentMessages = [];
conversationId = null;
}
const parentMessage = currentMessages?.find(
(msg) => msg.messageId === latestMessage?.parentMessageId,
);
let thread_id = parentMessage?.thread_id ?? latestMessage?.thread_id;
if (!thread_id) {
thread_id = currentMessages.find((message) => message.thread_id)?.thread_id;
}
const endpointsConfig = queryClient.getQueryData<TEndpointsConfig>([QueryKeys.endpoints]);
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
// set the endpoint option
const convo = parseCompactConvo({
endpoint,
endpointType,
conversation: conversation ?? {},
});
const { modelDisplayLabel } = endpointsConfig?.[endpoint ?? ''] ?? {};
const endpointOption = {
...convo,
endpoint,
thread_id,
endpointType,
overrideConvoId,
key: getExpiry(),
modelDisplayLabel,
overrideUserMessageId,
} as TEndpointOption;
const responseSender = getSender({ model: conversation?.model, ...endpointOption });
const currentMsg: TMessage = {
text,
sender: 'User',
isCreatedByUser: true,
parentMessageId,
conversationId,
messageId: isContinued && messageId ? messageId : intermediateId,
thread_id,
error: false,
};
const reuseFiles = (isRegenerate || resubmitFiles) && parentMessage?.files;
if (setFiles && reuseFiles && parentMessage.files?.length) {
currentMsg.files = parentMessage.files;
setFiles(new Map());
setFilesToDelete({});
} else if (setFiles && files && files.size > 0) {
currentMsg.files = Array.from(files.values()).map((file) => ({
file_id: file.file_id,
filepath: file.filepath,
type: file.type || '', // Ensure type is not undefined
height: file.height,
width: file.width,
}));
setFiles(new Map());
setFilesToDelete({});
}
// construct the placeholder response message
const generation = editedText ?? latestMessage?.text ?? '';
const responseText = isEditOrContinue ? generation : '';
const responseMessageId = editedMessageId ?? latestMessage?.messageId ?? null;
const initialResponse: TMessage = {
sender: responseSender,
text: responseText,
endpoint: endpoint ?? '',
parentMessageId: isRegenerate ? messageId : intermediateId,
messageId: responseMessageId ?? `${isRegenerate ? messageId : intermediateId}_`,
thread_id,
conversationId,
unfinished: false,
isCreatedByUser: false,
isEdited: isEditOrContinue,
iconURL: convo.iconURL,
error: false,
};
if (isAssistantsEndpoint(endpoint)) {
initialResponse.model = conversation?.assistant_id ?? '';
initialResponse.text = '';
initialResponse.content = [
{
type: ContentTypes.TEXT,
[ContentTypes.TEXT]: {
value: responseText,
},
},
];
} else {
setShowStopButton(true);
}
if (isContinued) {
currentMessages = currentMessages.filter((msg) => msg.messageId !== responseMessageId);
}
const submission: TSubmission = {
conversation: {
...conversation,
conversationId,
},
endpointOption,
userMessage: {
...currentMsg,
generation,
responseMessageId,
overrideParentMessageId: isRegenerate ? messageId : null,
},
messages: currentMessages,
isEdited: isEditOrContinue,
isContinued,
isRegenerate,
initialResponse,
};
if (isRegenerate) {
setMessages([...submission.messages, initialResponse]);
} else {
setMessages([...submission.messages, currentMsg, initialResponse]);
}
if (index === 0 && setLatestMessage) {
setLatestMessage(initialResponse);
}
setSubmission(submission);
logger.log('Submission:');
logger.dir(submission, { depth: null });
};
const regenerate = ({ parentMessageId }) => {
const messages = getMessages();
const parentMessage = messages?.find((element) => element.messageId == parentMessageId);
if (parentMessage && parentMessage.isCreatedByUser) {
ask({ ...parentMessage }, { isRegenerate: true });
} else {
console.error(
'Failed to regenerate the message: parentMessage not found or not created by user.',
);
}
};
return {
ask,
regenerate,
};
}