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:
@ -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>
|
||||
|
||||
@ -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`}
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
|
||||
export const REST_API_BASE_URL = `${REACT_APP_SERVER_BASE_URL}/rest`;
|
||||
@ -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),
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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`;
|
||||
};
|
||||
@ -27,6 +27,7 @@ export const GET_AGENT_CHAT_MESSAGES = gql`
|
||||
role
|
||||
content
|
||||
createdAt
|
||||
files
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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: '',
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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,
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user