🚀 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

@ -210,7 +210,7 @@ export type TAdditionalProps = {
export type TMessageContentProps = TInitialProps & TAdditionalProps;
export type TText = Pick<TInitialProps, 'text'>;
export type TText = Pick<TInitialProps, 'text'> & { className?: string };
export type TEditProps = Pick<TInitialProps, 'text' | 'isSubmitting'> &
Omit<TAdditionalProps, 'isCreatedByUser'>;
export type TDisplayProps = TText &

View file

@ -24,8 +24,18 @@ const ChatForm = ({ index = 0 }) => {
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
const { requiresKey } = useRequiresKey();
const methods = useForm<{ text: string }>({
defaultValues: { text: '' },
});
const { handlePaste, handleKeyUp, handleKeyDown, handleCompositionStart, handleCompositionEnd } =
useTextarea({ textAreaRef, submitButtonRef, disabled: !!requiresKey });
useTextarea({
textAreaRef,
submitButtonRef,
disabled: !!requiresKey,
setValue: methods.setValue,
getValues: methods.getValues,
});
const {
ask,
@ -39,9 +49,6 @@ const ChatForm = ({ index = 0 }) => {
} = useChatContext();
const assistantMap = useAssistantsMapContext();
const methods = useForm<{ text: string }>({
defaultValues: { text: '' },
});
const submitMessage = useCallback(
(data?: { text: string }) => {

View file

@ -21,20 +21,20 @@ any) => {
return (
<>
{content.map((part: TMessageContentParts | undefined, idx: number) => {
if (!part) {
return null;
}
return (
<Part
key={`display-${messageId}-${idx}`}
showCursor={idx === content.length - 1 && isLast}
isSubmitting={isSubmitting}
part={part}
{...props}
/>
);
})}
{content
.filter((part: TMessageContentParts | undefined) => part)
.map((part: TMessageContentParts | undefined, idx: number) => {
const showCursor = idx === content.length - 1 && isLast;
return (
<Part
key={`display-${messageId}-${idx}`}
showCursor={showCursor && isSubmitting}
isSubmitting={isSubmitting}
part={part}
{...props}
/>
);
})}
{!isSubmitting && unfinished && (
<Suspense>
<DelayedRender delay={250}>

View file

@ -5,23 +5,21 @@ import FileContainer from '~/components/Chat/Input/Files/FileContainer';
import Plugin from '~/components/Messages/Content/Plugin';
import Error from '~/components/Messages/Content/Error';
import { DelayedRender } from '~/components/ui';
import { useAuthContext } from '~/hooks';
import EditMessage from './EditMessage';
import Container from './Container';
import Markdown from './Markdown';
import { cn } from '~/utils';
import Image from './Image';
export const ErrorMessage = ({ text }: TText) => {
const { logout } = useAuthContext();
if (text.includes('ban')) {
logout();
return null;
}
export const ErrorMessage = ({ text, className = '' }: TText) => {
return (
<Container>
<div className="rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200">
<div
className={cn(
'rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
className,
)}
>
<Error text={text} />
</div>
</Container>

View file

@ -1,6 +1,7 @@
import { ToolCallTypes, ContentTypes, imageGenTools } from 'librechat-data-provider';
import type { TMessageContentParts, TMessage } from 'librechat-data-provider';
import type { TDisplayProps } from '~/common';
import { ErrorMessage } from './MessageContent';
import RetrievalCall from './RetrievalCall';
import CodeAnalyze from './CodeAnalyze';
import Container from './Container';
@ -17,6 +18,7 @@ const DisplayMessage = ({ text, isCreatedByUser = false, message, showCursor }:
return (
<div
className={cn(
showCursor && !!text?.length ? 'result-streaming' : '',
'markdown prose dark:prose-invert light w-full break-words',
isCreatedByUser ? 'whitespace-pre-wrap dark:text-gray-20' : 'dark:text-gray-70',
)}
@ -44,7 +46,10 @@ export default function Part({
if (!part) {
return null;
}
if (part.type === ContentTypes.TEXT) {
if (part.type === ContentTypes.ERROR) {
return <ErrorMessage text={part[ContentTypes.TEXT].value} className="my-2" />;
} else if (part.type === ContentTypes.TEXT) {
// Access the value property
return (
<Container>

View file

@ -1,6 +1,7 @@
// import { useState, useEffect } from 'react';
import { actionDelimiter } from 'librechat-data-provider';
import { actionDelimiter, actionDomainSeparator } from 'librechat-data-provider';
import * as Popover from '@radix-ui/react-popover';
import useLocalize from '~/hooks/useLocalize';
import ProgressCircle from './ProgressCircle';
import InProgressCall from './InProgressCall';
import CancelledIcon from './CancelledIcon';
@ -24,12 +25,14 @@ export default function ToolCall({
args: string;
output?: string | null;
}) {
const localize = useLocalize();
const progress = useProgress(initialProgress);
const radius = 56.08695652173913;
const circumference = 2 * Math.PI * radius;
const offset = circumference - progress * circumference;
const [function_name, domain] = name.split(actionDelimiter);
const [function_name, _domain] = name.split(actionDelimiter);
const domain = _domain?.replaceAll(actionDomainSeparator, '.') ?? null;
const error = output?.toLowerCase()?.includes('error processing tool');
return (
@ -58,8 +61,12 @@ export default function ToolCall({
<ProgressText
progress={progress}
onClick={() => ({})}
inProgressText={'Running action'}
finishedText={domain ? `Talked to ${domain}` : `Ran ${function_name}`}
inProgressText={localize('com_assistants_running_action')}
finishedText={
domain
? localize('com_assistants_completed_action', domain)
: localize('com_assistants_completed_function', function_name)
}
hasInput={!!args?.length}
popover={true}
/>

View file

@ -1,4 +1,5 @@
import * as Popover from '@radix-ui/react-popover';
import useLocalize from '~/hooks/useLocalize';
export default function ToolPopover({
input,
@ -11,6 +12,7 @@ export default function ToolPopover({
output?: string | null;
domain?: string;
}) {
const localize = useLocalize();
const formatText = (text: string) => {
try {
return JSON.stringify(JSON.parse(text), null, 2);
@ -31,7 +33,9 @@ export default function ToolPopover({
<div tabIndex={-1}>
<div className="bg-token-surface-primary max-w-sm rounded-md p-2 shadow-[0_0_24px_0_rgba(0,0,0,0.05),inset_0_0.5px_0_0_rgba(0,0,0,0.05),0_2px_8px_0_rgba(0,0,0,0.05)]">
<div className="mb-2 text-sm font-medium dark:text-gray-100">
{domain ? 'Assistant sent this info to ' + domain : `Assistant used ${function_name}`}
{domain
? localize('com_assistants_domain_info', domain)
: localize('com_assistants_function_use', function_name)}
</div>
<div className="bg-token-surface-secondary text-token-text-primary dark rounded-md text-xs">
<div className="max-h-32 overflow-y-auto rounded-md p-2 dark:bg-gray-700">
@ -40,7 +44,9 @@ export default function ToolPopover({
</div>
{output && (
<>
<div className="mb-2 mt-2 text-sm font-medium dark:text-gray-100">Result</div>
<div className="mb-2 mt-2 text-sm font-medium dark:text-gray-100">
{localize('com_ui_result')}
</div>
<div className="bg-token-surface-secondary text-token-text-primary dark rounded-md text-xs">
<div className="max-h-32 overflow-y-auto rounded-md p-2 dark:bg-gray-700">
<code className="!whitespace-pre-wrap ">{formatText(output)}</code>

View file

@ -1,4 +1,5 @@
import { useState } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import type { TConversation, TMessage } from 'librechat-data-provider';
import { Clipboard, CheckMark, EditIcon, RegenerateIcon, ContinueIcon } from '~/components/svg';
import { useGenerationsByLatest, useLocalize } from '~/hooks';
@ -55,21 +56,23 @@ export default function HoverButtons({
return (
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
<button
className={cn(
'hover-button rounded-md p-1 text-gray-400 hover:text-gray-900 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
isCreatedByUser ? '' : 'active',
hideEditButton ? 'opacity-0' : '',
isEditing ? 'active bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200' : '',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={onEdit}
type="button"
title={localize('com_ui_edit')}
disabled={hideEditButton}
>
<EditIcon />
</button>
{endpoint !== EModelEndpoint.assistants && (
<button
className={cn(
'hover-button rounded-md p-1 text-gray-400 hover:text-gray-900 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
isCreatedByUser ? '' : 'active',
hideEditButton ? 'opacity-0' : '',
isEditing ? 'active bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200' : '',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={onEdit}
type="button"
title={localize('com_ui_edit')}
disabled={hideEditButton}
>
<EditIcon />
</button>
)}
<button
className={cn(
'ml-0 flex items-center gap-1.5 rounded-md p-1 text-xs hover:text-gray-900 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
@ -86,8 +89,10 @@ export default function HoverButtons({
</button>
{regenerateEnabled ? (
<button
className={cn("hover-button active rounded-md p-1 text-gray-400 hover:text-gray-900 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible",
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',)}
className={cn(
'hover-button active rounded-md p-1 text-gray-400 hover:text-gray-900 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={regenerate}
type="button"
title={localize('com_ui_regenerate')}
@ -97,8 +102,10 @@ export default function HoverButtons({
) : null}
{continueSupported ? (
<button
className={cn("hover-button active rounded-md p-1 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible ",
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',)}
className={cn(
'hover-button active rounded-md p-1 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible ',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={handleContinue}
type="button"
title={localize('com_ui_continue')}

View file

@ -103,6 +103,7 @@ export default function Message(props: TMessageProps) {
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
isLast={isLast}
/>
</SubRow>
)}

View file

@ -1,9 +1,7 @@
import { useMemo, memo } from 'react';
import { parseISO, isToday } from 'date-fns';
import { useLocation } from 'react-router-dom';
import { TConversation } from 'librechat-data-provider';
import { groupConversationsByDate } from '~/utils';
import Conversation from './Conversation';
import Convo from './Convo';
const Conversations = ({
@ -15,16 +13,14 @@ const Conversations = ({
moveToTop: () => void;
toggleNav: () => void;
}) => {
const location = useLocation();
const { pathname } = location;
const ConvoItem = pathname.includes('chat') ? Conversation : Convo;
const groupedConversations = useMemo(
() => groupConversationsByDate(conversations),
[conversations],
);
const firstTodayConvoId = conversations.find((convo) =>
isToday(parseISO(convo.updatedAt)),
)?.conversationId;
const firstTodayConvoId = useMemo(
() => conversations.find((convo) => isToday(parseISO(convo.updatedAt)))?.conversationId,
[conversations],
);
return (
<div className="text-token-text-primary flex flex-col gap-2 pb-2 text-sm">
@ -44,7 +40,7 @@ const Conversations = ({
{groupName}
</div>
{convos.map((convo, i) => (
<ConvoItem
<Convo
key={`${groupName}-${convo.conversationId}-${i}`}
isLatestConvo={convo.conversationId === firstTodayConvoId}
conversation={convo}

View file

@ -1,6 +1,6 @@
import { useRecoilValue } from 'recoil';
import { useState, useRef, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useState, useRef, useMemo } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
@ -17,7 +17,8 @@ import store from '~/store';
type KeyEvent = KeyboardEvent<HTMLInputElement>;
export default function Conversation({ conversation, retainView, toggleNav, isLatestConvo }) {
const { conversationId: currentConvoId } = useParams();
const params = useParams();
const currentConvoId = useMemo(() => params.conversationId, [params.conversationId]);
const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? '');
const activeConvos = useRecoilValue(store.allConversationsSelector);
const { data: endpointsConfig } = useGetEndpointsQuery();

View file

@ -14,9 +14,11 @@ import type {
} from 'librechat-data-provider';
import type { ActionAuthForm } from '~/common';
import type { Spec } from './ActionsTable';
import { useAssistantsMapContext, useToastContext } from '~/Providers';
import { ActionsTable, columns } from './ActionsTable';
import { useUpdateAction } from '~/data-provider';
import { cn, removeFocusOutlines } from '~/utils';
import useLocalize from '~/hooks/useLocalize';
import { Spinner } from '~/components/svg';
const debouncedValidation = debounce(
@ -44,6 +46,9 @@ export default function ActionsInput({
setValidationResult(result);
};
const localize = useLocalize();
const { showToast } = useToastContext();
const assistantMap = useAssistantsMapContext();
const { handleSubmit, reset } = useFormContext<ActionAuthForm>();
const [validationResult, setValidationResult] = useState<null | ValidationResult>(null);
const [inputValue, setInputValue] = useState('');
@ -81,9 +86,19 @@ export default function ActionsInput({
const updateAction = useUpdateAction({
onSuccess(data) {
showToast({
message: localize('com_assistants_update_actions_success'),
status: 'success',
});
reset();
setAction(data[2]);
},
onError(error) {
showToast({
message: (error as Error)?.message ?? localize('com_assistants_update_actions_error'),
status: 'error',
});
},
});
const saveAction = handleSubmit((authFormData) => {
@ -158,6 +173,7 @@ export default function ActionsInput({
metadata,
functions,
assistant_id,
model: assistantMap[assistant_id].model,
});
});
@ -185,7 +201,8 @@ export default function ActionsInput({
onChange={(e) => console.log(e.target.value)}
className="border-token-border-medium h-8 min-w-[100px] rounded-lg border bg-transparent px-2 py-0 text-sm"
>
<option value="label">Examples</option>
<option value="label">{localize('com_ui_examples')}</option>
{/* TODO: make these appear and function correctly */}
<option value="0">Weather (JSON)</option>
<option value="1">Pet Store (YAML)</option>
<option value="2">Blank Template</option>
@ -218,7 +235,9 @@ export default function ActionsInput({
{!!data && (
<div>
<div className="mb-1.5 flex items-center">
<label className="text-token-text-primary block font-medium">Available actions</label>
<label className="text-token-text-primary block font-medium">
{localize('com_assistants_available_actions')}
</label>
</div>
<ActionsTable columns={columns} data={data} />
</div>
@ -226,7 +245,9 @@ export default function ActionsInput({
<div className="mt-4">
<div className="mb-1.5 flex items-center">
<span className="" data-state="closed">
<label className="text-token-text-primary block font-medium">Privacy policy</label>
<label className="text-token-text-primary block font-medium">
{localize('com_ui_privacy_policy')}
</label>
</span>
</div>
<div className="rounded-md border border-gray-300 px-3 py-2 shadow-none focus-within:border-gray-800 focus-within:ring-1 focus-within:ring-gray-800 dark:border-gray-700 dark:bg-gray-700 dark:focus-within:border-gray-500 dark:focus-within:ring-gray-500">
@ -252,13 +273,12 @@ export default function ActionsInput({
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0 disabled:bg-green-400"
type="button"
>
{/* TODO: Add localization */}
{updateAction.isLoading ? (
<Spinner className="icon-md" />
) : action?.action_id ? (
'Update'
localize('com_ui_update')
) : (
'Create'
localize('com_ui_create')
)}
</button>
</div>

View file

@ -6,9 +6,11 @@ import {
TokenExchangeMethodEnum,
} from 'librechat-data-provider';
import type { AssistantPanelProps, ActionAuthForm } from '~/common';
import { useAssistantsMapContext, useToastContext } from '~/Providers';
import { Dialog, DialogTrigger } from '~/components/ui';
import { useDeleteAction } from '~/data-provider';
import { NewTrashIcon } from '~/components/svg';
import useLocalize from '~/hooks/useLocalize';
import ActionsInput from './ActionsInput';
import ActionsAuth from './ActionsAuth';
import { Panel } from '~/common';
@ -20,12 +22,25 @@ export default function ActionsPanel({
setActivePanel,
assistant_id,
}: AssistantPanelProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const assistantMap = useAssistantsMapContext();
const [openAuthDialog, setOpenAuthDialog] = useState(false);
const deleteAction = useDeleteAction({
onSuccess: () => {
showToast({
message: localize('com_assistants_delete_actions_success'),
status: 'success',
});
setActivePanel(Panel.builder);
setAction(undefined);
},
onError(error) {
showToast({
message: (error as Error)?.message ?? localize('com_assistants_delete_actions_error'),
status: 'error',
});
},
});
const methods = useForm<ActionAuthForm>({
@ -115,6 +130,7 @@ export default function ActionsPanel({
const confirmed = confirm('Are you sure you want to delete this action?');
if (confirmed) {
deleteAction.mutate({
model: assistantMap[assistant_id].model,
action_id: action.action_id,
assistant_id,
});
@ -129,8 +145,7 @@ export default function ActionsPanel({
)}
<div className="text-xl font-medium">{(action ? 'Edit' : 'Add') + ' ' + 'actions'}</div>
<div className="text-token-text-tertiary text-sm">
{/* TODO: use App title */}
Let your Assistant retrieve information or take actions outside of LibreChat.
{localize('com_assistants_actions_info')}
</div>
{/* <div className="text-sm text-token-text-tertiary">
<a href="https://help.openai.com/en/articles/8554397-creating-a-gpt" target="_blank" rel="noreferrer" className="font-medium">Learn more.</a>
@ -141,7 +156,7 @@ export default function ActionsPanel({
<div className="relative mb-6">
<div className="mb-1.5 flex items-center">
<label className="text-token-text-primary block font-medium">
Authentication
{localize('com_ui_authentication')}
</label>
</div>
<div className="border-token-border-medium flex rounded-lg border text-sm hover:cursor-pointer">

View file

@ -107,6 +107,7 @@ export default function AssistantSelect({
functions,
...actions,
assistant: update,
model: update.model,
};
Object.entries(assistant).forEach(([name, value]) => {

View file

@ -489,7 +489,7 @@ export const useDeleteAction = (
const queryClient = useQueryClient();
return useMutation([MutationKeys.deleteAction], {
mutationFn: (variables: DeleteActionVariables) =>
dataService.deleteAction(variables.assistant_id, variables.action_id),
dataService.deleteAction(variables.assistant_id, variables.action_id, variables.model),
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context),

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;
}

View file

@ -24,6 +24,17 @@ export default {
com_assistants_actions: 'Actions',
com_assistants_add_tools: 'Add Tools',
com_assistants_add_actions: 'Add Actions',
com_assistants_available_actions: 'Available Actions',
com_assistants_running_action: 'Running action',
com_assistants_completed_action: 'Talked to {0}',
com_assistants_completed_function: 'Ran {0}',
com_assistants_function_use: 'Assistant used {0}',
com_assistants_domain_info: 'Assistant sent this info to {0}',
com_assistants_delete_actions_success: 'Successfully deleted Action from Assistant',
com_assistants_update_actions_success: 'Successfully created or updated Action',
com_assistants_update_actions_error: 'There was an error creating or updating the action.',
com_assistants_delete_actions_error: 'There was an error deleting the action.',
com_assistants_actions_info: 'Let your Assistant retrieve information or take actions via API\'s',
com_assistants_name_placeholder: 'Optional: The name of the assistant',
com_assistants_instructions_placeholder: 'The system instructions that the assistant uses',
com_assistants_description_placeholder: 'Optional: Describe your Assistant here',
@ -61,6 +72,8 @@ export default {
com_ui_context: 'Context',
com_ui_size: 'Size',
com_ui_host: 'Host',
com_ui_update: 'Update',
com_ui_authentication: 'Authentication',
com_ui_instructions: 'Instructions',
com_ui_description: 'Description',
com_ui_error: 'Error',
@ -106,6 +119,7 @@ export default {
com_ui_chats: 'chats',
com_ui_avatar: 'Avatar',
com_ui_unknown: 'Unknown',
com_ui_result: 'Result',
com_ui_image_gen: 'Image Gen',
com_ui_assistant: 'Assistant',
com_ui_assistants: 'Assistants',