Add file support to agent chat (#13187)

https://github.com/user-attachments/assets/911d5d8d-cc2e-4c18-9f93-2663d84ff9ef

---------

Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: MD Readul Islam <99027968+readul-islam@users.noreply.github.com>
Co-authored-by: readul-islam <developer.readul@gamil.com>
Co-authored-by: Thomas des Francs <tdesfrancs@gmail.com>
Co-authored-by: Guillim <guillim@users.noreply.github.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Abdul Rahman
2025-07-15 12:27:10 +05:30
committed by GitHub
parent bba1b296c1
commit 72fd3b07e7
48 changed files with 1387 additions and 136 deletions

View File

@ -4,11 +4,11 @@ import { isNonEmptyString } from '@sniptt/guards';
import { ChangeEvent, useRef } from 'react';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { AttachmentIcon } from '../../files/components/AttachmentIcon';
import { AttachmentType } from '../../files/types/Attachment';
import { getFileType } from '../../files/utils/getFileType';
import { FileIcon } from '@/file/components/FileIcon';
import { isDefined } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
import { AttachmentType } from '../../files/types/Attachment';
import { getFileType } from '../../files/utils/getFileType';
const StyledFileInput = styled.input`
display: none;
@ -89,9 +89,7 @@ export const FileBlock = createReactBlockSpec(
if (isNonEmptyString(block.props.url)) {
return (
<StyledFileLine>
<AttachmentIcon
attachmentType={block.props.fileType as AttachmentType}
></AttachmentIcon>
<FileIcon fileType={block.props.fileType as AttachmentType} />
<StyledLink href={block.props.url} target="__blank">
{block.props.name}
</StyledLink>

View File

@ -1,6 +1,5 @@
import { ActivityRow } from '@/activities/components/ActivityRow';
import { AttachmentDropdown } from '@/activities/files/components/AttachmentDropdown';
import { AttachmentIcon } from '@/activities/files/components/AttachmentIcon';
import { Attachment } from '@/activities/files/types/Attachment';
import { downloadFile } from '@/activities/files/utils/downloadFile';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
@ -17,6 +16,7 @@ import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { PREVIEWABLE_EXTENSIONS } from '@/activities/files/const/previewable-extensions.const';
import { FileIcon } from '@/file/components/FileIcon';
import { IconCalendar, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { isNavigationModifierPressed } from 'twenty-ui/utilities';
import { formatToHumanReadableDate } from '~/utils/date-utils';
@ -162,7 +162,7 @@ export const AttachmentRow = ({
>
<ActivityRow disabled>
<StyledLeftContent>
<AttachmentIcon attachmentType={attachment.type} />
<FileIcon fileType={attachment.type} />
{isEditing ? (
<TextInput
instanceId={`attachment-${attachment.id}-name`}

View File

@ -0,0 +1,3 @@
import { REACT_APP_SERVER_BASE_URL } from '~/config';
export const REST_API_BASE_URL = `${REACT_APP_SERVER_BASE_URL}/rest`;

View File

@ -21,6 +21,7 @@ import { CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
import { AuthTokenPair } from '~/generated/graphql';
import { logDebug } from '~/utils/logDebug';
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
import { i18n } from '@lingui/core';
import {
DefinitionNode,
@ -30,7 +31,6 @@ import {
} from 'graphql';
import isEmpty from 'lodash.isempty';
import { getGenericOperationName, isDefined } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { cookieStorage } from '~/utils/cookie-storage';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { ApolloManager } from '../types/apolloManager.interface';
@ -51,8 +51,6 @@ export interface Options<TCacheShape> extends ApolloClientOptions<TCacheShape> {
isDebugMode?: boolean;
}
const REST_API_BASE_URL = `${REACT_APP_SERVER_BASE_URL}/rest`;
export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
private client: ApolloClient<TCacheShape>;
private currentWorkspaceMember: CurrentWorkspaceMember | null = null;
@ -76,7 +74,7 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
this.currentWorkspace = currentWorkspace;
const buildApolloLink = (): ApolloLink => {
const httpLink = createUploadLink({
const uploadLink = createUploadLink({
uri,
});
@ -252,7 +250,7 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
retryLink,
streamingRestLink,
restLink,
httpLink,
uploadLink,
].filter(isDefined),
);
};

View File

@ -1,7 +1,7 @@
import { AttachmentType } from '@/activities/files/types/Attachment';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { AttachmentType } from '@/activities/files/types/Attachment';
import {
IconComponent,
IconFile,
@ -21,9 +21,8 @@ const StyledIconContainer = styled.div<{ background: string }>`
color: ${({ theme }) => theme.grayScale.gray0};
display: flex;
flex-shrink: 0;
height: 20px;
justify-content: center;
width: 20px;
padding: ${({ theme }) => theme.spacing(1.25)};
`;
const IconMapping: { [key in AttachmentType]: IconComponent } = {
@ -37,11 +36,7 @@ const IconMapping: { [key in AttachmentType]: IconComponent } = {
Other: IconFile,
};
export const AttachmentIcon = ({
attachmentType,
}: {
attachmentType: AttachmentType;
}) => {
export const FileIcon = ({ fileType }: { fileType: AttachmentType }) => {
const theme = useTheme();
const IconColors: { [key in AttachmentType]: string } = {
@ -55,10 +50,10 @@ export const AttachmentIcon = ({
Other: theme.color.gray,
};
const Icon = IconMapping[attachmentType];
const Icon = IconMapping[fileType];
return (
<StyledIconContainer background={IconColors[attachmentType]}>
<StyledIconContainer background={IconColors[fileType]}>
{Icon && <Icon size={theme.icon.size.sm} />}
</StyledIconContainer>
);

View File

@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const CREATE_FILE = gql`
mutation CreateFile($file: Upload!) {
createFile(file: $file) {
id
name
fullPath
size
type
createdAt
}
}
`;

View File

@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const DELETE_FILE = gql`
mutation DeleteFile($fileId: String!) {
deleteFile(fileId: $fileId) {
id
name
fullPath
size
type
createdAt
}
}
`;

View File

@ -0,0 +1,23 @@
import { formatFileSize } from '../formatFileSize';
describe('formatFileSize', () => {
it('should format bytes correctly', () => {
expect(formatFileSize(0)).toBe('0 B');
expect(formatFileSize(512)).toBe('512 B');
expect(formatFileSize(1024)).toBe('1024 B');
});
it('should format kilobytes correctly', () => {
expect(formatFileSize(1025)).toBe('1.0 KB');
expect(formatFileSize(2048)).toBe('2.0 KB');
expect(formatFileSize(1536)).toBe('1.5 KB');
expect(formatFileSize(1024 * 1024)).toBe('1024.0 KB');
});
it('should format megabytes correctly', () => {
expect(formatFileSize(1024 * 1024 + 1)).toBe('1.0 MB');
expect(formatFileSize(2 * 1024 * 1024)).toBe('2.0 MB');
expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB');
expect(formatFileSize(10 * 1024 * 1024)).toBe('10.0 MB');
});
});

View File

@ -0,0 +1,9 @@
export const formatFileSize = (size: number) => {
if (size > 1024 * 1024) {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
if (size > 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${size} B`;
};

View File

@ -27,6 +27,7 @@ export const GET_AGENT_CHAT_MESSAGES = gql`
role
content
createdAt
files
}
}
`;

View File

@ -1,11 +1,12 @@
import { TextArea } from '@/ui/input/components/TextArea';
import { keyframes, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import React from 'react';
import { Avatar, IconDotsVertical, IconSparkles } from 'twenty-ui/display';
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { AgentChatFilePreview } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFilePreview';
import { AgentChatFileUpload } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFileUpload';
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { t } from '@lingui/core/macro';
import { Button } from 'twenty-ui/input';
@ -13,6 +14,7 @@ import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { useAgentChat } from '../hooks/useAgentChat';
import { AgentChatMessage } from '../hooks/useAgentChatMessages';
import { AIChatSkeletonLoader } from './AIChatSkeletonLoader';
import { AgentChatSelectedFilesPreview } from './AgentChatSelectedFilesPreview';
const StyledContainer = styled.div`
background: ${({ theme }) => theme.background.primary};
@ -172,11 +174,21 @@ const StyledToolCallContainer = styled.div`
}
`;
type AIChatTabProps = {
agentId: string;
};
const StyledButtonsContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
`;
export const AIChatTab: React.FC<AIChatTabProps> = ({ agentId }) => {
const StyledFilesContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
flex-wrap: wrap;
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const AIChatTab = ({ agentId }: { agentId: string }) => {
const theme = useTheme();
const {
@ -186,6 +198,7 @@ export const AIChatTab: React.FC<AIChatTabProps> = ({ agentId }) => {
input,
handleInputChange,
agentStreamingMessage,
scrollWrapperId,
} = useAgentChat(agentId);
const getAssistantMessageContent = (message: AgentChatMessage) => {
@ -215,9 +228,7 @@ export const AIChatTab: React.FC<AIChatTabProps> = ({ agentId }) => {
return (
<StyledContainer>
{messages.length !== 0 && (
<StyledScrollWrapper
componentInstanceId={`scroll-wrapper-ai-chat-${agentId}`}
>
<StyledScrollWrapper componentInstanceId={scrollWrapperId}>
{messages.map((msg) => (
<StyledMessageBubble
key={msg.id}
@ -254,6 +265,13 @@ export const AIChatTab: React.FC<AIChatTabProps> = ({ agentId }) => {
? getAssistantMessageContent(msg)
: msg.content}
</StyledMessageText>
{msg.files.length > 0 && (
<StyledFilesContainer>
{msg.files.map((file) => (
<AgentChatFilePreview key={file.id} file={file} />
))}
</StyledFilesContainer>
)}
{msg.content && (
<StyledMessageFooter className="message-footer">
<span>
@ -282,21 +300,25 @@ export const AIChatTab: React.FC<AIChatTabProps> = ({ agentId }) => {
{isLoading && messages.length === 0 && <AIChatSkeletonLoader />}
<StyledInputArea>
<AgentChatSelectedFilesPreview agentId={agentId} />
<TextArea
textAreaId={`${agentId}-chat-input`}
placeholder={t`Enter a question...`}
value={input}
onChange={handleInputChange}
/>
<Button
variant="primary"
accent="blue"
size="small"
hotkeys={input && !isLoading ? ['⏎'] : undefined}
disabled={!input || isLoading}
title={t`Send`}
onClick={handleSendMessage}
/>
<StyledButtonsContainer>
<AgentChatFileUpload agentId={agentId} />
<Button
variant="primary"
accent="blue"
size="small"
hotkeys={input && !isLoading ? ['⏎'] : undefined}
disabled={!input || isLoading}
title={t`Send`}
onClick={handleSendMessage}
/>
</StyledButtonsContainer>
</StyledInputArea>
</StyledContainer>
);

View File

@ -0,0 +1,103 @@
import { getFileType } from '@/activities/files/utils/getFileType';
import { FileIcon } from '@/file/components/FileIcon';
import { formatFileSize } from '@/file/utils/formatFileSize';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconX } from 'twenty-ui/display';
import { Loader } from 'twenty-ui/feedback';
import { File as FileDocument } from '~/generated-metadata/graphql';
const StyledFileChip = styled.div`
display: flex;
align-items: center;
background: ${({ theme }) => theme.background.transparent.lighter};
border-radius: ${({ theme }) => theme.border.radius.md};
height: 40px;
border: 1px solid ${({ theme }) => theme.border.color.light};
`;
const StyledFileIconContainer = styled.div`
align-items: center;
display: flex;
height: 100%;
justify-content: center;
padding-inline: ${({ theme }) => theme.spacing(2)};
overflow: hidden;
`;
const StyledFileInfo = styled.div`
height: 100%;
display: flex;
justify-content: center;
flex: 1;
min-width: 0;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
border-left: 1px solid ${({ theme }) => theme.border.color.light};
padding-inline: ${({ theme }) => theme.spacing(2)};
`;
const StyledFileName = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 125px;
`;
const StyledFileSize = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledRemoveIconContainer = styled.div`
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding-inline: ${({ theme }) => theme.spacing(3)};
border-left: 1px solid ${({ theme }) => theme.border.color.light};
svg {
cursor: pointer;
}
`;
export const AgentChatFilePreview = ({
file,
onRemove,
isUploading,
}: {
file: File | FileDocument;
onRemove?: () => void;
isUploading?: boolean;
}) => {
const theme = useTheme();
return (
<StyledFileChip key={file.name}>
<StyledFileIconContainer>
{isUploading ? (
<Loader color="yellow" />
) : (
<FileIcon fileType={getFileType(file.name)} />
)}
</StyledFileIconContainer>
<StyledFileInfo>
<StyledFileName title={file.name}>{file.name}</StyledFileName>
<StyledFileSize>{formatFileSize(file.size)}</StyledFileSize>
</StyledFileInfo>
{onRemove && (
<StyledRemoveIconContainer>
<IconX
size={theme.icon.size.md}
color={theme.font.color.secondary}
onClick={onRemove}
/>
</StyledRemoveIconContainer>
)}
</StyledFileChip>
);
};

View File

@ -0,0 +1,127 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { agentChatSelectedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatSelectedFilesComponentState';
import { agentChatUploadedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatUploadedFilesComponentState';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import React, { useRef } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconPaperclip } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import {
File as FileDocument,
useCreateFileMutation,
} from '~/generated-metadata/graphql';
const StyledFileUploadContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledFileInput = styled.input`
display: none;
`;
export const AgentChatFileUpload = ({ agentId }: { agentId: string }) => {
const coreClient = useApolloCoreClient();
const [createFile] = useCreateFileMutation({ client: coreClient });
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const [agentChatSelectedFiles, setAgentChatSelectedFiles] =
useRecoilComponentStateV2(agentChatSelectedFilesComponentState, agentId);
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
useRecoilComponentStateV2(agentChatUploadedFilesComponentState, agentId);
const fileInputRef = useRef<HTMLInputElement>(null);
const sendFile = async (file: File) => {
try {
const result = await createFile({
variables: {
file,
},
});
const uploadedFile = result?.data?.createFile;
if (!isDefined(uploadedFile)) {
throw new Error("Couldn't upload the file.");
}
setAgentChatSelectedFiles(
agentChatSelectedFiles.filter((f) => f.name !== file.name),
);
return uploadedFile;
} catch (error) {
const fileName = file.name;
enqueueErrorSnackBar({
message: t`Failed to upload file: ${fileName}`,
});
return null;
}
};
const uploadFiles = async (files: File[]) => {
const uploadResults = await Promise.allSettled(
files.map((file) => sendFile(file)),
);
const successfulUploads = uploadResults.reduce<FileDocument[]>(
(acc, result) => {
if (result.status === 'fulfilled' && isDefined(result.value)) {
acc.push(result.value);
}
return acc;
},
[],
);
if (successfulUploads.length > 0) {
setAgentChatUploadedFiles([
...agentChatUploadedFiles,
...successfulUploads,
]);
}
const failedCount = uploadResults.filter(
(result) => result.status === 'rejected',
).length;
if (failedCount > 0) {
enqueueErrorSnackBar({
message: t`${failedCount} file(s) failed to upload`,
});
}
};
const handleFileInputChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
if (!event.target.files) {
return;
}
uploadFiles(Array.from(event.target.files));
setAgentChatSelectedFiles(Array.from(event.target.files));
};
return (
<StyledFileUploadContainer>
<StyledFileInput
ref={fileInputRef}
type="file"
multiple
accept="*/*"
onChange={handleFileInputChange}
/>
<Button
variant="secondary"
size="small"
onClick={() => {
fileInputRef.current?.click();
}}
Icon={IconPaperclip}
/>
</StyledFileUploadContainer>
);
};

View File

@ -0,0 +1,75 @@
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { AgentChatFilePreview } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFilePreview';
import { agentChatSelectedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatSelectedFilesComponentState';
import { agentChatUploadedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatUploadedFilesComponentState';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useDeleteFileMutation } from '~/generated-metadata/graphql';
const StyledPreviewContainer = styled.div`
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export const AgentChatSelectedFilesPreview = ({
agentId,
}: {
agentId: string;
}) => {
const { t } = useLingui();
const [agentChatSelectedFiles, setAgentChatSelectedFiles] =
useRecoilComponentStateV2(agentChatSelectedFilesComponentState, agentId);
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
useRecoilComponentStateV2(agentChatUploadedFilesComponentState, agentId);
const { enqueueErrorSnackBar } = useSnackBar();
const [deleteFile] = useDeleteFileMutation();
const handleRemoveUploadedFile = async (fileId: string) => {
const originalFiles = agentChatUploadedFiles;
setAgentChatUploadedFiles(
agentChatUploadedFiles.filter((f) => f.id !== fileId),
);
try {
await deleteFile({ variables: { fileId } });
} catch (error) {
setAgentChatUploadedFiles(originalFiles);
enqueueErrorSnackBar({
message: t`Failed to remove file`,
});
}
};
return [...agentChatSelectedFiles, ...agentChatUploadedFiles].length > 0 ? (
<StyledPreviewContainer>
{agentChatSelectedFiles.map((file) => (
<AgentChatFilePreview
file={file}
key={file.name}
onRemove={() => {
setAgentChatSelectedFiles(
agentChatSelectedFiles.filter((f) => f.name !== file.name),
);
}}
isUploading
/>
))}
{agentChatUploadedFiles.map((file) => (
<AgentChatFilePreview
file={file}
key={file.id}
onRemove={() => handleRemoveUploadedFile(file.id)}
isUploading={false}
/>
))}
</StyledPreviewContainer>
) : null;
};

View File

@ -136,7 +136,7 @@ export const WorkflowEditActionAiAgent = ({
onChange={(value) => handleFieldChange('modelId', value)}
disabled={actionOptions.readonly || noModelsAvailable}
emptyOption={{
label: t`No AI models available`,
label: t`Auto`,
value: '',
}}
/>

View File

@ -6,8 +6,11 @@ import { Key } from 'ts-key-enum';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperElement';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { STREAM_CHAT_QUERY } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/api/agent-chat-apollo.api';
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { agentChatSelectedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatSelectedFilesComponentState';
import { agentChatUploadedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatUploadedFilesComponentState';
import { useApolloClient } from '@apollo/client';
import { v4 } from 'uuid';
import { agentChatInputState } from '../states/agentChatInputState';
@ -25,6 +28,14 @@ export const useAgentChat = (agentId: string) => {
const apolloClient = useApolloClient();
const { enqueueErrorSnackBar } = useSnackBar();
const agentChatSelectedFiles = useRecoilComponentValueV2(
agentChatSelectedFilesComponentState,
agentId,
);
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
useRecoilComponentStateV2(agentChatUploadedFilesComponentState, agentId);
const [agentChatMessages, setAgentChatMessages] = useRecoilComponentStateV2(
agentChatMessagesComponentState,
agentId,
@ -39,7 +50,9 @@ export const useAgentChat = (agentId: string) => {
const [isStreaming, setIsStreaming] = useState(false);
const { scrollWrapperHTMLElement } = useScrollWrapperElement(agentId);
const scrollWrapperId = `scroll-wrapper-ai-chat-${agentId}`;
const { scrollWrapperHTMLElement } = useScrollWrapperElement(scrollWrapperId);
const scrollToBottom = () => {
scrollWrapperHTMLElement?.scroll({
@ -58,7 +71,11 @@ export const useAgentChat = (agentId: string) => {
scrollToBottom();
});
const isLoading = messagesLoading || threadsLoading || isStreaming;
const isLoading =
messagesLoading ||
threadsLoading ||
isStreaming ||
agentChatSelectedFiles.length > 0;
const createOptimisticMessages = (content: string): AgentChatMessage[] => {
const optimisticUserMessage: OptimisticMessage = {
@ -68,6 +85,7 @@ export const useAgentChat = (agentId: string) => {
content,
createdAt: new Date().toISOString(),
isPending: true,
files: agentChatUploadedFiles,
};
const optimisticAiMessage: OptimisticMessage = {
@ -77,6 +95,7 @@ export const useAgentChat = (agentId: string) => {
content: '',
createdAt: new Date().toISOString(),
isPending: true,
files: [],
};
return [optimisticUserMessage, optimisticAiMessage];
@ -95,6 +114,7 @@ export const useAgentChat = (agentId: string) => {
requestBody: {
threadId: currentThreadId,
userMessage: content,
fileIds: agentChatUploadedFiles.map((file) => file.id),
},
},
context: {
@ -135,6 +155,8 @@ export const useAgentChat = (agentId: string) => {
...optimisticMessages,
]);
setAgentChatUploadedFiles([]);
setTimeout(scrollToBottom, 100);
await streamAgentResponse(content);
@ -180,5 +202,6 @@ export const useAgentChat = (agentId: string) => {
handleSendMessage,
isLoading,
agentStreamingMessage,
scrollWrapperId,
};
};

View File

@ -1,6 +1,7 @@
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { useQuery } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import { File } from '~/generated-metadata/graphql';
import { GET_AGENT_CHAT_MESSAGES } from '../api/agent-chat-apollo.api';
export type AgentChatMessage = {
@ -9,6 +10,7 @@ export type AgentChatMessage = {
role: AgentChatMessageRole;
content: string;
createdAt: string;
files: File[];
};
export const useAgentChatMessages = (

View File

@ -0,0 +1,10 @@
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { AgentChatMessagesComponentInstanceContext } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatMessagesComponentState';
export const agentChatSelectedFilesComponentState = createComponentStateV2<
File[]
>({
key: 'agentChatSelectedFilesComponentState',
defaultValue: [],
componentInstanceContext: AgentChatMessagesComponentInstanceContext,
});

View File

@ -0,0 +1,11 @@
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { AgentChatMessagesComponentInstanceContext } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatMessagesComponentState';
import { File } from '~/generated-metadata/graphql';
export const agentChatUploadedFilesComponentState = createComponentStateV2<
File[]
>({
key: 'agentChatUploadedFilesComponentState',
defaultValue: [],
componentInstanceContext: AgentChatMessagesComponentInstanceContext,
});

View File

@ -0,0 +1,211 @@
import { parseAgentStreamingChunk } from '../parseAgentStreamingChunk';
describe('parseAgentStreamingChunk', () => {
let mockCallbacks: {
onTextDelta: jest.Mock;
onToolCall: jest.Mock;
onError: jest.Mock;
onParseError: jest.Mock;
};
beforeEach(() => {
mockCallbacks = {
onTextDelta: jest.fn(),
onToolCall: jest.fn(),
onError: jest.fn(),
onParseError: jest.fn(),
};
});
afterEach(() => {
jest.clearAllMocks();
});
describe('valid event types', () => {
it('should call onTextDelta for text-delta events', () => {
const chunk = JSON.stringify({
type: 'text-delta',
message: 'Hello world',
});
parseAgentStreamingChunk(chunk, mockCallbacks);
expect(mockCallbacks.onTextDelta).toHaveBeenCalledWith('Hello world');
expect(mockCallbacks.onToolCall).not.toHaveBeenCalled();
expect(mockCallbacks.onError).not.toHaveBeenCalled();
expect(mockCallbacks.onParseError).not.toHaveBeenCalled();
});
it('should call onToolCall for tool-call events', () => {
const chunk = JSON.stringify({
type: 'tool-call',
message: 'Tool execution result',
});
parseAgentStreamingChunk(chunk, mockCallbacks);
expect(mockCallbacks.onToolCall).toHaveBeenCalledWith(
'Tool execution result',
);
expect(mockCallbacks.onTextDelta).not.toHaveBeenCalled();
expect(mockCallbacks.onError).not.toHaveBeenCalled();
expect(mockCallbacks.onParseError).not.toHaveBeenCalled();
});
it('should call onError for error events', () => {
const chunk = JSON.stringify({
type: 'error',
message: 'Something went wrong',
});
parseAgentStreamingChunk(chunk, mockCallbacks);
expect(mockCallbacks.onError).toHaveBeenCalledWith(
'Something went wrong',
);
expect(mockCallbacks.onTextDelta).not.toHaveBeenCalled();
expect(mockCallbacks.onToolCall).not.toHaveBeenCalled();
expect(mockCallbacks.onParseError).not.toHaveBeenCalled();
});
});
describe('multiple events in chunk', () => {
it('should process multiple events separated by newlines', () => {
const chunk = [
JSON.stringify({ type: 'text-delta', message: 'First message' }),
JSON.stringify({ type: 'tool-call', message: 'Tool result' }),
JSON.stringify({ type: 'error', message: 'Error occurred' }),
].join('\n');
parseAgentStreamingChunk(chunk, mockCallbacks);
expect(mockCallbacks.onTextDelta).toHaveBeenCalledWith('First message');
expect(mockCallbacks.onToolCall).toHaveBeenCalledWith('Tool result');
expect(mockCallbacks.onError).toHaveBeenCalledWith('Error occurred');
expect(mockCallbacks.onParseError).not.toHaveBeenCalled();
});
it('should skip empty lines', () => {
const chunk = [
JSON.stringify({ type: 'text-delta', message: 'First message' }),
'',
' ',
JSON.stringify({ type: 'tool-call', message: 'Tool result' }),
].join('\n');
parseAgentStreamingChunk(chunk, mockCallbacks);
expect(mockCallbacks.onTextDelta).toHaveBeenCalledWith('First message');
expect(mockCallbacks.onToolCall).toHaveBeenCalledWith('Tool result');
expect(mockCallbacks.onParseError).not.toHaveBeenCalled();
});
});
describe('JSON parsing errors', () => {
it('should call onParseError for invalid JSON', () => {
const chunk = 'invalid json content';
parseAgentStreamingChunk(chunk, mockCallbacks);
expect(mockCallbacks.onParseError).toHaveBeenCalledWith(
expect.any(Error),
'invalid json content',
);
expect(mockCallbacks.onTextDelta).not.toHaveBeenCalled();
expect(mockCallbacks.onToolCall).not.toHaveBeenCalled();
expect(mockCallbacks.onError).not.toHaveBeenCalled();
});
it('should call onParseError for malformed JSON', () => {
const chunk = '{"type": "text-delta", "message": "unclosed quote}';
parseAgentStreamingChunk(chunk, mockCallbacks);
expect(mockCallbacks.onParseError).toHaveBeenCalledWith(
expect.any(Error),
'{"type": "text-delta", "message": "unclosed quote}',
);
});
it('should handle mixed valid and invalid JSON in same chunk', () => {
const chunk = [
JSON.stringify({ type: 'text-delta', message: 'Valid message' }),
'invalid json',
JSON.stringify({ type: 'tool-call', message: 'Another valid message' }),
].join('\n');
parseAgentStreamingChunk(chunk, mockCallbacks);
expect(mockCallbacks.onTextDelta).toHaveBeenCalledWith('Valid message');
expect(mockCallbacks.onToolCall).toHaveBeenCalledWith(
'Another valid message',
);
expect(mockCallbacks.onParseError).toHaveBeenCalledWith(
expect.any(Error),
'invalid json',
);
});
});
describe('optional callbacks', () => {
it('should not throw when callbacks are undefined', () => {
const chunk = JSON.stringify({
type: 'text-delta',
message: 'Test message',
});
expect(() => {
parseAgentStreamingChunk(chunk, {});
}).not.toThrow();
});
it('should handle partial callback definitions', () => {
const chunk = JSON.stringify({
type: 'text-delta',
message: 'Test message',
});
const partialCallbacks = {
onTextDelta: jest.fn(),
};
parseAgentStreamingChunk(chunk, partialCallbacks);
expect(partialCallbacks.onTextDelta).toHaveBeenCalledWith('Test message');
});
});
describe('edge cases', () => {
it('should handle empty chunk', () => {
parseAgentStreamingChunk('', mockCallbacks);
expect(mockCallbacks.onTextDelta).not.toHaveBeenCalled();
expect(mockCallbacks.onToolCall).not.toHaveBeenCalled();
expect(mockCallbacks.onError).not.toHaveBeenCalled();
expect(mockCallbacks.onParseError).not.toHaveBeenCalled();
});
it('should handle chunk with only whitespace', () => {
parseAgentStreamingChunk(' \n\t\n ', mockCallbacks);
expect(mockCallbacks.onTextDelta).not.toHaveBeenCalled();
expect(mockCallbacks.onToolCall).not.toHaveBeenCalled();
expect(mockCallbacks.onError).not.toHaveBeenCalled();
expect(mockCallbacks.onParseError).not.toHaveBeenCalled();
});
it('should handle unknown event types gracefully', () => {
const chunk = JSON.stringify({
type: 'unknown-type',
message: 'Unknown event',
});
parseAgentStreamingChunk(chunk, mockCallbacks);
expect(mockCallbacks.onTextDelta).not.toHaveBeenCalled();
expect(mockCallbacks.onToolCall).not.toHaveBeenCalled();
expect(mockCallbacks.onError).not.toHaveBeenCalled();
expect(mockCallbacks.onParseError).not.toHaveBeenCalled();
});
});
});