🚀 feat: Assistants Streaming (#2159)

* chore: bump openai to 4.29.0 and npm audit fix

* chore: remove unnecessary stream field from ContentData

* feat: new enum and types for AssistantStreamEvent

* refactor(AssistantService): remove stream field and add conversationId to text ContentData
> - return `finalMessage` and `text` on run completion
> - move `processMessages` to services/Threads to avoid circular dependencies with new stream handling
> - refactor(processMessages/retrieveAndProcessFile): add new `client` field to differentiate new RunClient type

* WIP: new assistants stream handling

* chore: stores messages to StreamRunManager

* chore: add additional typedefs

* fix: pass req and openai to StreamRunManager

* fix(AssistantService): pass openai as client to `retrieveAndProcessFile`

* WIP: streaming tool i/o, handle in_progress and completed run steps

* feat(assistants): process required actions with streaming enabled

* chore: condense early return check for useSSE useEffect

* chore: remove unnecessary comments and only handle completed tool calls when not function

* feat: add TTL for assistants run abort cacheKey

* feat: abort stream runs

* fix(assistants): render streaming cursor

* fix(assistants): hide edit icon as functionality is not supported

* fix(textArea): handle pasting edge cases; first, when onChange events wouldn't fire; second, when textarea wouldn't resize

* chore: memoize Conversations

* chore(useTextarea): reverse args order

* fix: load default capabilities when an azure is configured to support assistants, but `assistants` endpoint is not configured

* fix(AssistantSelect): update form assistant model on assistant form select

* fix(actions): handle azure strict validation for function names to fix crud for actions

* chore: remove content data debug log as it fires in rapid succession

* feat: improve UX for assistant errors mid-request

* feat: add tool call localizations and replace any domain separators from azure action names

* refactor(chat): error out tool calls without outputs during handleError

* fix(ToolService): handle domain separators allowing Azure use of actions

* refactor(StreamRunManager): types and throw Error if tool submission fails
This commit is contained in:
Danny Avila 2024-03-21 22:42:25 -04:00 committed by GitHub
parent ed64c76053
commit f427ad792a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1503 additions and 330 deletions

View file

@ -2,6 +2,7 @@ import debounce from 'lodash/debounce';
import React, { useEffect, useRef, useCallback } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import type { TEndpointOption } from 'librechat-data-provider';
import type { UseFormSetValue } from 'react-hook-form';
import type { KeyboardEvent } from 'react';
import { useAssistantsMapContext } from '~/Providers/AssistantsMapContext';
import useGetSender from '~/hooks/Conversations/useGetSender';
@ -12,7 +13,6 @@ import useLocalize from '~/hooks/useLocalize';
type KeyEvent = KeyboardEvent<HTMLTextAreaElement>;
function insertTextAtCursor(element: HTMLTextAreaElement, textToInsert: string) {
// Focus the element to ensure the insertion point is updated
element.focus();
// Use the browser's built-in undoable actions if possible
@ -31,6 +31,25 @@ function insertTextAtCursor(element: HTMLTextAreaElement, textToInsert: string)
}
}
/**
* Necessary resize helper for edge cases where paste doesn't update the container height.
*
1) Resetting the height to 'auto' forces the component to recalculate height based on its current content
2) Forcing a reflow. Accessing offsetHeight will cause a reflow of the page,
ensuring that the reset height takes effect before resetting back to the scrollHeight.
This step is necessary because changes to the DOM do not instantly cause reflows.
3) Reseting back to scrollHeight reads and applies the ideal height for the current content dynamically
*/
const forceResize = (textAreaRef: React.RefObject<HTMLTextAreaElement>) => {
if (textAreaRef.current) {
textAreaRef.current.style.height = 'auto';
textAreaRef.current.offsetHeight;
textAreaRef.current.style.height = `${textAreaRef.current.scrollHeight}px`;
}
};
const getAssistantName = ({
name,
localize,
@ -48,10 +67,14 @@ const getAssistantName = ({
export default function useTextarea({
textAreaRef,
submitButtonRef,
setValue,
getValues,
disabled = false,
}: {
textAreaRef: React.RefObject<HTMLTextAreaElement>;
submitButtonRef: React.RefObject<HTMLButtonElement>;
setValue: UseFormSetValue<{ text: string }>;
getValues: (field: string) => string;
disabled?: boolean;
}) {
const assistantMap = useAssistantsMapContext();
@ -205,6 +228,21 @@ export default function useTextarea({
isComposing.current = false;
};
/** Necessary handler to update form state when paste doesn't fire textArea input event */
const setPastedValue = useCallback(
(textArea: HTMLTextAreaElement, pastedData: string) => {
const currentTextValue = getValues('text') || '';
const { selectionStart, selectionEnd } = textArea;
const newValue =
currentTextValue.substring(0, selectionStart) +
pastedData +
currentTextValue.substring(selectionEnd);
setValue('text', newValue, { shouldValidate: true });
},
[getValues, setValue],
);
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
e.preventDefault();
@ -214,7 +252,9 @@ export default function useTextarea({
}
const pastedData = e.clipboardData.getData('text/plain');
setPastedValue(textArea, pastedData);
insertTextAtCursor(textArea, pastedData);
forceResize(textAreaRef);
if (e.clipboardData && e.clipboardData.files.length > 0) {
e.preventDefault();
@ -229,7 +269,7 @@ export default function useTextarea({
handleFiles(timestampedFiles);
}
},
[handleFiles, setFilesLoading, textAreaRef],
[handleFiles, setFilesLoading, setPastedValue, textAreaRef],
);
return {

View file

@ -22,7 +22,7 @@ export default function useContentHandler({ setMessages, getMessages }: TUseCont
const messageMap = useMemo(() => new Map<string, TMessage>(), []);
return useCallback(
({ data, submission }: TContentHandler) => {
const { type, messageId, thread_id, conversationId, index, stream } = data;
const { type, messageId, thread_id, conversationId, index } = data;
const _messages = getMessages();
const messages =
@ -46,8 +46,9 @@ export default function useContentHandler({ setMessages, getMessages }: TUseCont
}
// TODO: handle streaming for non-text
const part: ContentPart =
stream && data[ContentTypes.TEXT] ? { value: data[ContentTypes.TEXT] } : data[type];
const part: ContentPart = data[ContentTypes.TEXT]
? { value: data[ContentTypes.TEXT] }
: data[type];
/* spreading the content array to avoid mutation */
response.content = [...(response.content ?? [])];

View file

@ -502,10 +502,7 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
);
useEffect(() => {
if (submission === null) {
return;
}
if (Object.keys(submission).length === 0) {
if (submission === null || Object.keys(submission).length === 0) {
return;
}