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:
@ -20,6 +20,7 @@ module.exports = {
|
||||
'./src/modules/analytics/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/object-metadata/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/attachments/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/file/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/onboarding/graphql/**/*.{ts,tsx}',
|
||||
|
||||
'!./src/**/*.test.{ts,tsx}',
|
||||
|
||||
@ -812,8 +812,20 @@ export type FieldPermissionInput = {
|
||||
objectMetadataId: Scalars['String'];
|
||||
};
|
||||
|
||||
export type File = {
|
||||
__typename?: 'File';
|
||||
createdAt: Scalars['DateTime'];
|
||||
fullPath: Scalars['String'];
|
||||
id: Scalars['ID'];
|
||||
messageId?: Maybe<Scalars['ID']>;
|
||||
name: Scalars['String'];
|
||||
size: Scalars['Float'];
|
||||
type: Scalars['String'];
|
||||
};
|
||||
|
||||
export enum FileFolder {
|
||||
Attachment = 'Attachment',
|
||||
File = 'File',
|
||||
PersonPicture = 'PersonPicture',
|
||||
ProfilePicture = 'ProfilePicture',
|
||||
ServerlessFunction = 'ServerlessFunction',
|
||||
@ -1058,6 +1070,7 @@ export type Mutation = {
|
||||
createApprovedAccessDomain: ApprovedAccessDomain;
|
||||
createDatabaseConfigVariable: Scalars['Boolean'];
|
||||
createDraftFromWorkflowVersion: WorkflowVersion;
|
||||
createFile: File;
|
||||
createOIDCIdentityProvider: SetupSsoOutput;
|
||||
createObjectEvent: Analytics;
|
||||
createOneAppToken: AppToken;
|
||||
@ -1073,6 +1086,7 @@ export type Mutation = {
|
||||
deleteApprovedAccessDomain: Scalars['Boolean'];
|
||||
deleteCurrentWorkspace: Workspace;
|
||||
deleteDatabaseConfigVariable: Scalars['Boolean'];
|
||||
deleteFile: File;
|
||||
deleteOneField: Field;
|
||||
deleteOneObject: Object;
|
||||
deleteOneRemoteServer: RemoteServer;
|
||||
@ -1203,6 +1217,11 @@ export type MutationCreateDraftFromWorkflowVersionArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateFileArgs = {
|
||||
file: Scalars['Upload'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateOidcIdentityProviderArgs = {
|
||||
input: SetupOidcSsoInput;
|
||||
};
|
||||
@ -1276,6 +1295,11 @@ export type MutationDeleteDatabaseConfigVariableArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteFileArgs = {
|
||||
fileId: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteOneFieldArgs = {
|
||||
input: DeleteOneFieldInput;
|
||||
};
|
||||
@ -3194,6 +3218,20 @@ export type GetOneDatabaseConnectionQueryVariables = Exact<{
|
||||
|
||||
export type GetOneDatabaseConnectionQuery = { __typename?: 'Query', findOneRemoteServerById: { __typename?: 'RemoteServer', id: string, createdAt: string, foreignDataWrapperId: string, foreignDataWrapperOptions?: any | null, foreignDataWrapperType: string, updatedAt: string, schema?: string | null, label: string, userMappingOptions?: { __typename?: 'UserMappingOptionsUser', user?: string | null } | null } };
|
||||
|
||||
export type CreateFileMutationVariables = Exact<{
|
||||
file: Scalars['Upload'];
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateFileMutation = { __typename?: 'Mutation', createFile: { __typename?: 'File', id: string, name: string, fullPath: string, size: number, type: string, createdAt: string } };
|
||||
|
||||
export type DeleteFileMutationVariables = Exact<{
|
||||
fileId: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type DeleteFileMutation = { __typename?: 'Mutation', deleteFile: { __typename?: 'File', id: string, name: string, fullPath: string, size: number, type: string, createdAt: string } };
|
||||
|
||||
export type CreateOneObjectMetadataItemMutationVariables = Exact<{
|
||||
input: CreateOneObjectInput;
|
||||
}>;
|
||||
@ -5482,6 +5520,82 @@ export function useGetOneDatabaseConnectionLazyQuery(baseOptions?: Apollo.LazyQu
|
||||
export type GetOneDatabaseConnectionQueryHookResult = ReturnType<typeof useGetOneDatabaseConnectionQuery>;
|
||||
export type GetOneDatabaseConnectionLazyQueryHookResult = ReturnType<typeof useGetOneDatabaseConnectionLazyQuery>;
|
||||
export type GetOneDatabaseConnectionQueryResult = Apollo.QueryResult<GetOneDatabaseConnectionQuery, GetOneDatabaseConnectionQueryVariables>;
|
||||
export const CreateFileDocument = gql`
|
||||
mutation CreateFile($file: Upload!) {
|
||||
createFile(file: $file) {
|
||||
id
|
||||
name
|
||||
fullPath
|
||||
size
|
||||
type
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type CreateFileMutationFn = Apollo.MutationFunction<CreateFileMutation, CreateFileMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useCreateFileMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useCreateFileMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useCreateFileMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [createFileMutation, { data, loading, error }] = useCreateFileMutation({
|
||||
* variables: {
|
||||
* file: // value for 'file'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useCreateFileMutation(baseOptions?: Apollo.MutationHookOptions<CreateFileMutation, CreateFileMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<CreateFileMutation, CreateFileMutationVariables>(CreateFileDocument, options);
|
||||
}
|
||||
export type CreateFileMutationHookResult = ReturnType<typeof useCreateFileMutation>;
|
||||
export type CreateFileMutationResult = Apollo.MutationResult<CreateFileMutation>;
|
||||
export type CreateFileMutationOptions = Apollo.BaseMutationOptions<CreateFileMutation, CreateFileMutationVariables>;
|
||||
export const DeleteFileDocument = gql`
|
||||
mutation DeleteFile($fileId: String!) {
|
||||
deleteFile(fileId: $fileId) {
|
||||
id
|
||||
name
|
||||
fullPath
|
||||
size
|
||||
type
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type DeleteFileMutationFn = Apollo.MutationFunction<DeleteFileMutation, DeleteFileMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useDeleteFileMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useDeleteFileMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useDeleteFileMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [deleteFileMutation, { data, loading, error }] = useDeleteFileMutation({
|
||||
* variables: {
|
||||
* fileId: // value for 'fileId'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useDeleteFileMutation(baseOptions?: Apollo.MutationHookOptions<DeleteFileMutation, DeleteFileMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<DeleteFileMutation, DeleteFileMutationVariables>(DeleteFileDocument, options);
|
||||
}
|
||||
export type DeleteFileMutationHookResult = ReturnType<typeof useDeleteFileMutation>;
|
||||
export type DeleteFileMutationResult = Apollo.MutationResult<DeleteFileMutation>;
|
||||
export type DeleteFileMutationOptions = Apollo.BaseMutationOptions<DeleteFileMutation, DeleteFileMutationVariables>;
|
||||
export const CreateOneObjectMetadataItemDocument = gql`
|
||||
mutation CreateOneObjectMetadataItem($input: CreateOneObjectInput!) {
|
||||
createOneObject(input: $input) {
|
||||
|
||||
@ -776,8 +776,20 @@ export type FieldPermissionInput = {
|
||||
objectMetadataId: Scalars['String'];
|
||||
};
|
||||
|
||||
export type File = {
|
||||
__typename?: 'File';
|
||||
createdAt: Scalars['DateTime'];
|
||||
fullPath: Scalars['String'];
|
||||
id: Scalars['ID'];
|
||||
messageId?: Maybe<Scalars['ID']>;
|
||||
name: Scalars['String'];
|
||||
size: Scalars['Float'];
|
||||
type: Scalars['String'];
|
||||
};
|
||||
|
||||
export enum FileFolder {
|
||||
Attachment = 'Attachment',
|
||||
File = 'File',
|
||||
PersonPicture = 'PersonPicture',
|
||||
ProfilePicture = 'ProfilePicture',
|
||||
ServerlessFunction = 'ServerlessFunction',
|
||||
@ -1015,6 +1027,7 @@ export type Mutation = {
|
||||
createApprovedAccessDomain: ApprovedAccessDomain;
|
||||
createDatabaseConfigVariable: Scalars['Boolean'];
|
||||
createDraftFromWorkflowVersion: WorkflowVersion;
|
||||
createFile: File;
|
||||
createOIDCIdentityProvider: SetupSsoOutput;
|
||||
createObjectEvent: Analytics;
|
||||
createOneAppToken: AppToken;
|
||||
@ -1029,6 +1042,7 @@ export type Mutation = {
|
||||
deleteApprovedAccessDomain: Scalars['Boolean'];
|
||||
deleteCurrentWorkspace: Workspace;
|
||||
deleteDatabaseConfigVariable: Scalars['Boolean'];
|
||||
deleteFile: File;
|
||||
deleteOneField: Field;
|
||||
deleteOneObject: Object;
|
||||
deleteOneRole: Scalars['String'];
|
||||
@ -1154,6 +1168,11 @@ export type MutationCreateDraftFromWorkflowVersionArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateFileArgs = {
|
||||
file: Scalars['Upload'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateOidcIdentityProviderArgs = {
|
||||
input: SetupOidcSsoInput;
|
||||
};
|
||||
@ -1212,6 +1231,11 @@ export type MutationDeleteDatabaseConfigVariableArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteFileArgs = {
|
||||
fileId: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteOneFieldArgs = {
|
||||
input: DeleteOneFieldInput;
|
||||
};
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -2,6 +2,7 @@ import { Logger } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { CleanupOrphanedFilesCronCommand } from 'src/engine/core-modules/file/crons/commands/cleanup-orphaned-files.cron.command';
|
||||
import { CalendarEventListFetchCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-event-list-fetch.cron.command';
|
||||
import { CalendarEventsImportCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-import.cron.command';
|
||||
import { CalendarOngoingStaleCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-ongoing-stale.cron.command';
|
||||
@ -25,6 +26,7 @@ export class CronRegisterAllCommand extends CommandRunner {
|
||||
private readonly calendarEventsImportCronCommand: CalendarEventsImportCronCommand,
|
||||
private readonly calendarOngoingStaleCronCommand: CalendarOngoingStaleCronCommand,
|
||||
private readonly cronTriggerCronCommand: CronTriggerCronCommand,
|
||||
private readonly cleanupOrphanedFilesCronCommand: CleanupOrphanedFilesCronCommand,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@ -58,6 +60,10 @@ export class CronRegisterAllCommand extends CommandRunner {
|
||||
command: this.calendarOngoingStaleCronCommand,
|
||||
},
|
||||
{ name: 'CronTrigger', command: this.cronTriggerCronCommand },
|
||||
{
|
||||
name: 'CleanupOrphanedFiles',
|
||||
command: this.cleanupOrphanedFilesCronCommand,
|
||||
},
|
||||
];
|
||||
|
||||
let successCount = 0;
|
||||
|
||||
@ -5,6 +5,7 @@ import { ConfirmationQuestion } from 'src/database/commands/questions/confirmati
|
||||
import { UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/upgrade-version-command.module';
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
@ -25,6 +26,7 @@ import { DataSeedWorkspaceCommand } from './data-seed-dev-workspace.command';
|
||||
MessagingImportManagerModule,
|
||||
CalendarEventImportManagerModule,
|
||||
AutomatedTriggerModule,
|
||||
FileModule,
|
||||
|
||||
// Data seeding dependencies
|
||||
TypeORMModule,
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateFileTable1752207396042 implements MigrationInterface {
|
||||
name = 'CreateFileTable1752207396042';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "core"."file" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "fullPath" character varying NOT NULL, "size" bigint NOT NULL, "type" character varying NOT NULL, "workspaceId" uuid NOT NULL, "messageId" uuid, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_36b46d232307066b3a2c9ea3a1d" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_FILE_WORKSPACE_ID" ON "core"."file" ("workspaceId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."file" ADD CONSTRAINT "FK_de468b3d8dcf7e94f7074220929" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."file" ADD CONSTRAINT "FK_a78a68c3f577a485dd4c741909f" FOREIGN KEY ("messageId") REFERENCES "core"."agentChatMessage"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."file" DROP CONSTRAINT "FK_a78a68c3f577a485dd4c741909f"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."file" DROP CONSTRAINT "FK_de468b3d8dcf7e94f7074220929"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "core"."IDX_FILE_WORKSPACE_ID"`);
|
||||
await queryRunner.query(`DROP TABLE "core"."file"`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import {
|
||||
CLEANUP_ORPHANED_FILES_CRON_PATTERN,
|
||||
CleanupOrphanedFilesCronJob,
|
||||
} from 'src/engine/core-modules/file/crons/jobs/cleanup-orphaned-files.cron.job';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
|
||||
@Command({
|
||||
name: 'cron:file:cleanup-orphaned-files',
|
||||
description: 'Starts a cron job to clean up orphaned files (no messageId)',
|
||||
})
|
||||
export class CleanupOrphanedFilesCronCommand extends CommandRunner {
|
||||
constructor(
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.messageQueueService.addCron<undefined>({
|
||||
jobName: CleanupOrphanedFilesCronJob.name,
|
||||
data: undefined,
|
||||
options: {
|
||||
repeat: { pattern: CLEANUP_ORPHANED_FILES_CRON_PATTERN },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { IsNull, LessThan, Repository } from 'typeorm';
|
||||
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileMetadataService } from 'src/engine/core-modules/file/services/file-metadata.service';
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
export const CLEANUP_ORPHANED_FILES_CRON_PATTERN = '0 2 * * *';
|
||||
|
||||
@Processor(MessageQueue.cronQueue)
|
||||
export class CleanupOrphanedFilesCronJob {
|
||||
constructor(
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@InjectRepository(FileEntity, 'core')
|
||||
private readonly fileRepository: Repository<FileEntity>,
|
||||
private readonly fileMetadataService: FileMetadataService,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
) {}
|
||||
|
||||
@Process(CleanupOrphanedFilesCronJob.name)
|
||||
@SentryCronMonitor(
|
||||
CleanupOrphanedFilesCronJob.name,
|
||||
CLEANUP_ORPHANED_FILES_CRON_PATTERN,
|
||||
)
|
||||
async handle(): Promise<void> {
|
||||
const activeWorkspaces = await this.workspaceRepository.find({
|
||||
where: {
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
},
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
if (activeWorkspaces.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
||||
|
||||
const orphanedFiles = await this.fileRepository.find({
|
||||
select: ['id', 'workspaceId', 'fullPath'],
|
||||
where: {
|
||||
messageId: IsNull(),
|
||||
createdAt: LessThan(oneHourAgo),
|
||||
},
|
||||
});
|
||||
|
||||
if (orphanedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of orphanedFiles) {
|
||||
await this.fileMetadataService
|
||||
.deleteFileById(file.id, file.workspaceId)
|
||||
.catch((error) => {
|
||||
throw new Error(
|
||||
`[${CleanupOrphanedFilesCronJob.name}] Cannot delete orphaned file - ${file.fullPath}: ${error.message}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('File')
|
||||
export class FileDTO {
|
||||
@Field(() => ID)
|
||||
id: string;
|
||||
|
||||
@Field()
|
||||
name: string;
|
||||
|
||||
@Field()
|
||||
fullPath: string;
|
||||
|
||||
@Field()
|
||||
size: number;
|
||||
|
||||
@Field()
|
||||
type: string;
|
||||
|
||||
@Field(() => ID, { nullable: true })
|
||||
messageId?: string;
|
||||
|
||||
@Field()
|
||||
createdAt: Date;
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Relation,
|
||||
} from 'typeorm';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentChatMessageEntity } from 'src/engine/metadata-modules/agent/agent-chat-message.entity';
|
||||
|
||||
@Entity('file')
|
||||
@Index('IDX_FILE_WORKSPACE_ID', ['workspaceId'])
|
||||
export class FileEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ nullable: false })
|
||||
name: string;
|
||||
|
||||
@Column({ nullable: false })
|
||||
fullPath: string;
|
||||
|
||||
@Column({ nullable: false, type: 'bigint' })
|
||||
size: number;
|
||||
|
||||
@Column({ nullable: false })
|
||||
type: string;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
workspaceId: string;
|
||||
|
||||
@ManyToOne(() => Workspace, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<Workspace>;
|
||||
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
messageId: string;
|
||||
|
||||
@ManyToOne(() => AgentChatMessageEntity, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'messageId' })
|
||||
message: Relation<AgentChatMessageEntity>;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard';
|
||||
import { FileDeletionJob } from 'src/engine/core-modules/file/jobs/file-deletion.job';
|
||||
@ -6,21 +8,37 @@ import { FileWorkspaceFolderDeletionJob } from 'src/engine/core-modules/file/job
|
||||
import { FileAttachmentListener } from 'src/engine/core-modules/file/listeners/file-attachment.listener';
|
||||
import { FileWorkspaceMemberListener } from 'src/engine/core-modules/file/listeners/file-workspace-member.listener';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { FileController } from './controllers/file.controller';
|
||||
import { CleanupOrphanedFilesCronCommand } from './crons/commands/cleanup-orphaned-files.cron.command';
|
||||
import { CleanupOrphanedFilesCronJob } from './crons/jobs/cleanup-orphaned-files.cron.job';
|
||||
import { FileEntity } from './entities/file.entity';
|
||||
import { FileUploadService } from './file-upload/services/file-upload.service';
|
||||
import { FileResolver } from './resolvers/file.resolver';
|
||||
import { FileMetadataService } from './services/file-metadata.service';
|
||||
import { FileService } from './services/file.service';
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule],
|
||||
imports: [
|
||||
JwtModule,
|
||||
TypeOrmModule.forFeature([FileEntity, Workspace], 'core'),
|
||||
HttpModule,
|
||||
],
|
||||
providers: [
|
||||
FileService,
|
||||
FileMetadataService,
|
||||
FileResolver,
|
||||
FilePathGuard,
|
||||
FileAttachmentListener,
|
||||
FileWorkspaceMemberListener,
|
||||
FileWorkspaceFolderDeletionJob,
|
||||
FileDeletionJob,
|
||||
CleanupOrphanedFilesCronJob,
|
||||
CleanupOrphanedFilesCronCommand,
|
||||
FileUploadService,
|
||||
],
|
||||
exports: [FileService],
|
||||
exports: [FileService, FileMetadataService, CleanupOrphanedFilesCronCommand],
|
||||
controllers: [FileController],
|
||||
})
|
||||
export class FileModule {}
|
||||
|
||||
@ -8,6 +8,7 @@ export enum FileFolder {
|
||||
Attachment = 'attachment',
|
||||
PersonPicture = 'person-picture',
|
||||
ServerlessFunction = 'serverless-function',
|
||||
File = 'file',
|
||||
}
|
||||
|
||||
registerEnumType(FileFolder, {
|
||||
@ -34,6 +35,9 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
|
||||
[FileFolder.ServerlessFunction]: {
|
||||
ignoreExpirationToken: false,
|
||||
},
|
||||
[FileFolder.File]: {
|
||||
ignoreExpirationToken: false,
|
||||
},
|
||||
};
|
||||
|
||||
export type AllowedFolders = KebabCase<keyof typeof FileFolder>;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { extractFolderPathAndFilename } from 'src/engine/core-modules/file/utils/extract-folderpath-and-filename.utils';
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
@ -18,8 +19,7 @@ export class FileDeletionJob {
|
||||
async handle(data: FileDeletionJobData): Promise<void> {
|
||||
const { workspaceId, fullPath } = data;
|
||||
|
||||
const folderPath = fullPath.split('/').slice(0, -1).join('/');
|
||||
const filename = fullPath.split('/').pop();
|
||||
const { folderPath, filename } = extractFolderPathAndFilename(fullPath);
|
||||
|
||||
if (!filename) {
|
||||
throw new UnrecoverableError(
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { FileUpload, GraphQLUpload } from 'graphql-upload';
|
||||
|
||||
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
import { FileMetadataService } from 'src/engine/core-modules/file/services/file-metadata.service';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
|
||||
@Resolver()
|
||||
export class FileResolver {
|
||||
constructor(private readonly fileMetadataService: FileMetadataService) {}
|
||||
|
||||
@Mutation(() => FileDTO)
|
||||
async createFile(
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream, filename, mimetype }: FileUpload,
|
||||
): Promise<FileDTO> {
|
||||
const stream = createReadStream();
|
||||
const buffer = await streamToBuffer(stream);
|
||||
|
||||
return this.fileMetadataService.createFile({
|
||||
file: buffer,
|
||||
filename,
|
||||
mimeType: mimetype,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => FileDTO)
|
||||
async deleteFile(
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
@Args('fileId') fileId: string,
|
||||
): Promise<FileDTO> {
|
||||
const deletedFile = await this.fileMetadataService.deleteFileById(
|
||||
fileId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!deletedFile) {
|
||||
throw new Error(`File with id ${fileId} not found`);
|
||||
}
|
||||
|
||||
return deletedFile;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
|
||||
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
|
||||
import { extractFolderPathAndFilename } from 'src/engine/core-modules/file/utils/extract-folderpath-and-filename.utils';
|
||||
|
||||
import { FileService } from './file.service';
|
||||
|
||||
@Injectable()
|
||||
export class FileMetadataService {
|
||||
constructor(
|
||||
@InjectRepository(FileEntity, 'core')
|
||||
private readonly fileRepository: Repository<FileEntity>,
|
||||
private readonly fileService: FileService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly fileUploadService: FileUploadService,
|
||||
) {}
|
||||
|
||||
async createFile({
|
||||
file,
|
||||
filename,
|
||||
mimeType,
|
||||
workspaceId,
|
||||
}: {
|
||||
file: Buffer;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
workspaceId: string;
|
||||
}): Promise<FileDTO> {
|
||||
const { files } = await this.fileUploadService.uploadFile({
|
||||
file,
|
||||
filename,
|
||||
mimeType,
|
||||
fileFolder: FileFolder.File,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!files.length) {
|
||||
throw new Error('Failed to upload file');
|
||||
}
|
||||
|
||||
const createdFile = this.fileRepository.create({
|
||||
name: filename,
|
||||
fullPath: files[0].path,
|
||||
size: file.length,
|
||||
type: mimeType,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const savedFile = await this.fileRepository.save(createdFile);
|
||||
|
||||
return savedFile;
|
||||
}
|
||||
|
||||
async deleteFileById(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
): Promise<FileDTO | null> {
|
||||
const file = await this.fileRepository.findOne({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { folderPath, filename } = extractFolderPathAndFilename(
|
||||
file.fullPath,
|
||||
);
|
||||
|
||||
try {
|
||||
if (file.fullPath) {
|
||||
await this.fileService.deleteFile({
|
||||
folderPath,
|
||||
filename,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
await this.fileRepository.delete(file.id);
|
||||
|
||||
return file;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to delete file ${id}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,18 +3,18 @@ import { Injectable } from '@nestjs/common';
|
||||
import { basename, dirname, extname } from 'path';
|
||||
import { Stream } from 'stream';
|
||||
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
import { buildSignedPath } from 'twenty-shared/utils';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { buildSignedPath } from 'twenty-shared/utils';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { extractFilenameFromPath } from 'src/engine/core-modules/file/utils/extract-file-id-from-path.utils';
|
||||
import {
|
||||
FileTokenJwtPayload,
|
||||
JwtTokenTypeEnum,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { extractFolderPathAndFilename } from 'src/engine/core-modules/file/utils/extract-folderpath-and-filename.utils';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class FileService {
|
||||
@ -45,7 +45,7 @@ export class FileService {
|
||||
return buildSignedPath({
|
||||
path: url,
|
||||
token: this.encodeFileToken({
|
||||
filename: extractFilenameFromPath(url),
|
||||
filename: extractFolderPathAndFilename(url).filename,
|
||||
workspaceId,
|
||||
}),
|
||||
});
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
import { extractFilenameFromPath } from 'src/engine/core-modules/file/utils/extract-file-id-from-path.utils';
|
||||
|
||||
describe('extractFileIdFromPath', () => {
|
||||
it('should return the last segment of a normal path', () => {
|
||||
const result = extractFilenameFromPath('uploads/files/1234.txt');
|
||||
|
||||
expect(result).toBe('1234.txt');
|
||||
});
|
||||
|
||||
it('should return the last segment when there is no slash', () => {
|
||||
const result = extractFilenameFromPath('file.txt');
|
||||
|
||||
expect(result).toBe('file.txt');
|
||||
});
|
||||
|
||||
it('should throw when empty path', () => {
|
||||
expect(() => extractFilenameFromPath('')).toThrow(
|
||||
new Error('Cannot extract id from empty path'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when empty filename', () => {
|
||||
const folderPath = 'uploads/files/';
|
||||
|
||||
expect(() => extractFilenameFromPath(folderPath)).toThrow(
|
||||
new Error(`Cannot extract id from folder path '${folderPath}'`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when empty filename absolute path', () => {
|
||||
const folderPath = '/a/b/c/';
|
||||
|
||||
expect(() => extractFilenameFromPath(folderPath)).toThrow(
|
||||
new Error(`Cannot extract id from folder path '${folderPath}'`),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -1,17 +0,0 @@
|
||||
import { basename } from 'path';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
export const extractFilenameFromPath = (path: string) => {
|
||||
if (path.endsWith('/')) {
|
||||
throw new Error(`Cannot extract id from folder path '${path}'`);
|
||||
}
|
||||
|
||||
const fileId = basename(path);
|
||||
|
||||
if (!isNonEmptyString(fileId)) {
|
||||
throw new Error(`Cannot extract id from empty path`);
|
||||
}
|
||||
|
||||
return fileId;
|
||||
};
|
||||
@ -0,0 +1,13 @@
|
||||
export function extractFolderPathAndFilename(fullPath: string): {
|
||||
folderPath: string;
|
||||
filename: string;
|
||||
} {
|
||||
if (!fullPath || typeof fullPath !== 'string') {
|
||||
throw new Error('Invalid fullPath provided');
|
||||
}
|
||||
const parts = fullPath.split('/');
|
||||
const filename = parts.pop() || '';
|
||||
const folderPath = parts.join('/');
|
||||
|
||||
return { folderPath, filename };
|
||||
}
|
||||
@ -5,10 +5,12 @@ import {
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
Relation,
|
||||
} from 'typeorm';
|
||||
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/agent/agent-chat-thread.entity';
|
||||
|
||||
export enum AgentChatMessageRole {
|
||||
@ -37,6 +39,9 @@ export class AgentChatMessageEntity {
|
||||
@Column('text')
|
||||
content: string;
|
||||
|
||||
@OneToMany(() => FileEntity, (file) => file.message)
|
||||
files: Relation<FileEntity[]>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@ -58,7 +58,7 @@ export class AgentChatController {
|
||||
@Post('stream')
|
||||
async streamAgentChat(
|
||||
@Body()
|
||||
body: { threadId: string; userMessage: string },
|
||||
body: { threadId: string; userMessage: string; fileIds?: string[] },
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
@ -67,6 +67,7 @@ export class AgentChatController {
|
||||
threadId: body.threadId,
|
||||
userMessage: body.userMessage,
|
||||
userWorkspaceId,
|
||||
fileIds: body.fileIds || [],
|
||||
res,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import {
|
||||
AgentChatMessageEntity,
|
||||
AgentChatMessageRole,
|
||||
@ -20,6 +21,8 @@ export class AgentChatService {
|
||||
private readonly threadRepository: Repository<AgentChatThreadEntity>,
|
||||
@InjectRepository(AgentChatMessageEntity, 'core')
|
||||
private readonly messageRepository: Repository<AgentChatMessageEntity>,
|
||||
@InjectRepository(FileEntity, 'core')
|
||||
private readonly fileRepository: Repository<FileEntity>,
|
||||
) {}
|
||||
|
||||
async createThread(agentId: string, userWorkspaceId: string) {
|
||||
@ -45,10 +48,12 @@ export class AgentChatService {
|
||||
threadId,
|
||||
role,
|
||||
content,
|
||||
fileIds,
|
||||
}: {
|
||||
threadId: string;
|
||||
role: AgentChatMessageRole;
|
||||
content: string;
|
||||
fileIds?: string[];
|
||||
}) {
|
||||
const message = this.messageRepository.create({
|
||||
threadId,
|
||||
@ -56,7 +61,17 @@ export class AgentChatService {
|
||||
content,
|
||||
});
|
||||
|
||||
return this.messageRepository.save(message);
|
||||
const savedMessage = await this.messageRepository.save(message);
|
||||
|
||||
if (fileIds && fileIds.length > 0) {
|
||||
for (const fileId of fileIds) {
|
||||
await this.fileRepository.update(fileId, {
|
||||
messageId: savedMessage.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return savedMessage;
|
||||
}
|
||||
|
||||
async getMessagesForThread(threadId: string, userWorkspaceId: string) {
|
||||
@ -77,6 +92,7 @@ export class AgentChatService {
|
||||
return this.messageRepository.find({
|
||||
where: { threadId },
|
||||
order: { createdAt: 'ASC' },
|
||||
relations: ['files'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,16 +1,30 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { CoreMessage, generateObject, generateText, streamText } from 'ai';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
CoreMessage,
|
||||
CoreUserMessage,
|
||||
FilePart,
|
||||
generateObject,
|
||||
generateText,
|
||||
ImagePart,
|
||||
streamText,
|
||||
TextPart,
|
||||
} from 'ai';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
ModelId,
|
||||
ModelProvider,
|
||||
} from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { extractFolderPathAndFilename } from 'src/engine/core-modules/file/utils/extract-folderpath-and-filename.utils';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import {
|
||||
AgentChatMessageEntity,
|
||||
@ -22,6 +36,7 @@ import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constant
|
||||
import { convertOutputSchemaToZod } from 'src/engine/metadata-modules/agent/utils/convert-output-schema-to-zod';
|
||||
import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
|
||||
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
import { AgentEntity } from './agent.entity';
|
||||
import { AgentException, AgentExceptionCode } from './agent.exception';
|
||||
@ -45,9 +60,12 @@ export class AgentExecutionService {
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly agentToolService: AgentToolService,
|
||||
private readonly fileService: FileService,
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
@InjectRepository(AgentEntity, 'core')
|
||||
private readonly agentRepository: Repository<AgentEntity>,
|
||||
@InjectRepository(FileEntity, 'core')
|
||||
private readonly fileRepository: Repository<FileEntity>,
|
||||
) {}
|
||||
|
||||
getModel = (modelId: ModelId, provider: ModelProvider) => {
|
||||
@ -58,9 +76,7 @@ export class AgentExecutionService {
|
||||
apiKey: this.twentyConfigService.get('OPENAI_COMPATIBLE_API_KEY'),
|
||||
});
|
||||
|
||||
return OpenAIProvider(
|
||||
this.aiModelRegistryService.getEffectiveModelConfig(modelId).modelId,
|
||||
);
|
||||
return OpenAIProvider(modelId);
|
||||
}
|
||||
case ModelProvider.OPENAI: {
|
||||
const OpenAIProvider = createOpenAI({
|
||||
@ -167,14 +183,73 @@ export class AgentExecutionService {
|
||||
}
|
||||
}
|
||||
|
||||
private async buildUserMessageWithFiles(
|
||||
userMessage: string,
|
||||
fileIds?: string[],
|
||||
): Promise<CoreUserMessage> {
|
||||
if (!fileIds || fileIds.length === 0) {
|
||||
return { role: AgentChatMessageRole.USER, content: userMessage };
|
||||
}
|
||||
|
||||
const files = await this.fileRepository.find({
|
||||
where: {
|
||||
id: In(fileIds),
|
||||
},
|
||||
});
|
||||
|
||||
const textPart: TextPart = {
|
||||
type: 'text',
|
||||
text: userMessage,
|
||||
};
|
||||
|
||||
const fileParts = await Promise.all(
|
||||
files.map((file) => this.createFilePart(file)),
|
||||
);
|
||||
|
||||
return {
|
||||
role: AgentChatMessageRole.USER,
|
||||
content: [textPart, ...fileParts],
|
||||
};
|
||||
}
|
||||
|
||||
private async createFilePart(
|
||||
file: FileEntity,
|
||||
): Promise<ImagePart | FilePart> {
|
||||
const { folderPath, filename } = extractFolderPathAndFilename(
|
||||
file.fullPath,
|
||||
);
|
||||
const fileStream = await this.fileService.getFileStream(
|
||||
folderPath,
|
||||
filename,
|
||||
file.workspaceId,
|
||||
);
|
||||
const fileBuffer = await streamToBuffer(fileStream as Readable);
|
||||
|
||||
if (file.type.startsWith('image')) {
|
||||
return {
|
||||
type: 'image',
|
||||
image: fileBuffer,
|
||||
mimeType: file.type,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: 'file',
|
||||
data: fileBuffer,
|
||||
mimeType: file.type,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async streamChatResponse({
|
||||
agentId,
|
||||
userMessage,
|
||||
messages,
|
||||
fileIds,
|
||||
}: {
|
||||
agentId: string;
|
||||
userMessage: string;
|
||||
messages: AgentChatMessageEntity[];
|
||||
fileIds?: string[];
|
||||
}) {
|
||||
const agent = await this.agentRepository.findOneOrFail({
|
||||
where: { id: agentId },
|
||||
@ -185,10 +260,12 @@ export class AgentExecutionService {
|
||||
content,
|
||||
}));
|
||||
|
||||
llmMessages.push({
|
||||
role: AgentChatMessageRole.USER,
|
||||
content: userMessage,
|
||||
});
|
||||
const userMessageWithFiles = await this.buildUserMessageWithFiles(
|
||||
userMessage,
|
||||
fileIds,
|
||||
);
|
||||
|
||||
llmMessages.push(userMessageWithFiles);
|
||||
|
||||
const aiRequestConfig = await this.prepareAIRequestConfig({
|
||||
system: `${AGENT_SYSTEM_PROMPTS.AGENT_CHAT}\n\n${agent.prompt}`,
|
||||
|
||||
@ -17,6 +17,7 @@ export type StreamAgentChatOptions = {
|
||||
threadId: string;
|
||||
userMessage: string;
|
||||
userWorkspaceId: string;
|
||||
fileIds?: string[];
|
||||
res: Response;
|
||||
};
|
||||
|
||||
@ -41,6 +42,7 @@ export class AgentStreamingService {
|
||||
threadId,
|
||||
userMessage,
|
||||
userWorkspaceId,
|
||||
fileIds = [],
|
||||
res,
|
||||
}: StreamAgentChatOptions) {
|
||||
try {
|
||||
@ -59,12 +61,6 @@ export class AgentStreamingService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.agentChatService.addMessage({
|
||||
threadId,
|
||||
role: AgentChatMessageRole.USER,
|
||||
content: userMessage,
|
||||
});
|
||||
|
||||
this.setupStreamingHeaders(res);
|
||||
|
||||
const { fullStream } =
|
||||
@ -72,6 +68,7 @@ export class AgentStreamingService {
|
||||
agentId: thread.agent.id,
|
||||
userMessage,
|
||||
messages: thread.messages,
|
||||
fileIds,
|
||||
});
|
||||
|
||||
let aiResponse = '';
|
||||
@ -92,6 +89,20 @@ export class AgentStreamingService {
|
||||
});
|
||||
break;
|
||||
case 'error':
|
||||
{
|
||||
const errorMessage =
|
||||
chunk.error &&
|
||||
typeof chunk.error === 'object' &&
|
||||
'message' in chunk.error
|
||||
? chunk.error.message
|
||||
: 'Something went wrong. Please try again.';
|
||||
|
||||
this.sendStreamEvent(res, {
|
||||
type: 'error',
|
||||
message: errorMessage as string,
|
||||
});
|
||||
res.end();
|
||||
}
|
||||
this.logger.error(`Stream error: ${JSON.stringify(chunk)}`);
|
||||
break;
|
||||
default:
|
||||
@ -100,6 +111,19 @@ export class AgentStreamingService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!aiResponse) {
|
||||
res.end();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.agentChatService.addMessage({
|
||||
threadId,
|
||||
role: AgentChatMessageRole.USER,
|
||||
content: userMessage,
|
||||
fileIds,
|
||||
});
|
||||
|
||||
await this.agentChatService.addMessage({
|
||||
threadId,
|
||||
role: AgentChatMessageRole.ASSISTANT,
|
||||
|
||||
@ -5,6 +5,9 @@ import { AiModule } from 'src/engine/core-modules/ai/ai.module';
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { AgentChatController } from 'src/engine/metadata-modules/agent/agent-chat.controller';
|
||||
@ -33,6 +36,7 @@ import { AgentService } from './agent.service';
|
||||
RoleTargetsEntity,
|
||||
AgentChatMessageEntity,
|
||||
AgentChatThreadEntity,
|
||||
FileEntity,
|
||||
UserWorkspace,
|
||||
],
|
||||
'core',
|
||||
@ -41,6 +45,8 @@ import { AgentService } from './agent.service';
|
||||
ThrottlerModule,
|
||||
AuditModule,
|
||||
FeatureFlagModule,
|
||||
FileUploadModule,
|
||||
FileModule,
|
||||
ObjectMetadataModule,
|
||||
WorkspacePermissionsCacheModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
|
||||
@ObjectType('AgentChatMessage')
|
||||
export class AgentChatMessageDTO {
|
||||
@Field(() => ID)
|
||||
@ -14,6 +16,9 @@ export class AgentChatMessageDTO {
|
||||
@Field()
|
||||
content: string;
|
||||
|
||||
@Field(() => [FileDTO], { nullable: true })
|
||||
files?: FileDTO[];
|
||||
|
||||
@Field()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@ -57,6 +57,7 @@ const bootstrap = async () => {
|
||||
|
||||
// Graphql file upload
|
||||
app.use(
|
||||
'/graphql',
|
||||
graphqlUploadExpress({
|
||||
maxFieldSize: bytes(settings.storage.maxFileSize),
|
||||
maxFiles: 10,
|
||||
|
||||
@ -619,7 +619,7 @@ export class WorkflowVersionStepWorkspaceService {
|
||||
const newAgent = await this.agentService.createOneAgentAndFirstThread(
|
||||
{
|
||||
label: 'AI Agent Workflow Step',
|
||||
name: 'ai-agent-workflow',
|
||||
name: `ai-agent-workflow-${newStepId}`,
|
||||
description: 'Created automatically for workflow step',
|
||||
prompt: '',
|
||||
modelId: 'auto',
|
||||
@ -635,16 +635,6 @@ export class WorkflowVersionStepWorkspaceService {
|
||||
);
|
||||
}
|
||||
|
||||
const userWorkspaceId =
|
||||
this.scopedWorkspaceContextFactory.create().userWorkspaceId;
|
||||
|
||||
if (userWorkspaceId) {
|
||||
await this.agentChatService.createThread(
|
||||
newAgent.id,
|
||||
userWorkspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: newStepId,
|
||||
name: 'AI Agent',
|
||||
|
||||
Reference in New Issue
Block a user