From 65df511179f012db4a4ce0ed478a2de636549fab Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Mon, 23 Jun 2025 01:12:04 +0530 Subject: [PATCH] feat: Add AI Agent workflow action node (#12650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/user-attachments/assets/8593e488-cb00-4fd2-b903-5ba5766e0254 --------- Co-authored-by: Antoine Moreaux Co-authored-by: martmull Co-authored-by: Félix Malfait Co-authored-by: Baptiste Devessier Co-authored-by: Joseph Chiang Co-authored-by: Claude Co-authored-by: Guillim Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com> Co-authored-by: Lucas Bordeau Co-authored-by: Marie <51697796+ijreilly@users.noreply.github.com> Co-authored-by: Naifer <161821705+omarNaifer12@users.noreply.github.com> Co-authored-by: prastoin Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions Co-authored-by: Thomas Trompette Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> Co-authored-by: Ajay A Adsule <103304466+AjayAdsule@users.noreply.github.com> Co-authored-by: bosiraphael Co-authored-by: Charles Bochet Co-authored-by: Marty <91310557+real-marty@users.noreply.github.com> Co-authored-by: Félix Malfait Co-authored-by: Charles Bochet Co-authored-by: Cursor Agent Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Co-authored-by: Weiko Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: nitin <142569587+ehconitin@users.noreply.github.com> --- .../src/generated-metadata/graphql.ts | 74 ++++++ .../twenty-front/src/generated/graphql.tsx | 175 +++++++++++++- .../components/ClientConfigProviderEffect.tsx | 4 + .../graphql/queries/getClientConfig.ts | 7 + .../client-config/states/aiModelsState.ts | 7 + ...CommandMenuWorkflowSelectActionContent.tsx | 7 +- .../src/modules/workflow/types/Workflow.ts | 21 +- .../validation-schemas/workflowSchema.ts | 13 ++ .../WorkflowDiagramStepNodeIcon.tsx | 7 + .../components/WorkflowRunStepNodeDetail.tsx | 12 + .../components/WorkflowStepDetail.tsx | 31 ++- .../components/WorkflowEditActionAiAgent.tsx | 118 ++++++++++ .../WorkflowOutputFieldTypeSelector.tsx | 32 +++ .../WorkflowOutputSchemaBuilder.tsx | 221 ++++++++++++++++++ .../constants/output-field-type-options.ts | 39 ++++ .../graphql/mutations/updateOneAgent.ts | 14 ++ .../graphql/queries/findOneAgent.ts | 14 ++ .../hooks/useAgentUpdateFormState.ts | 82 +++++++ .../hooks/useAiAgentOutputSchema.ts | 70 ++++++ .../hooks/useAiModelOptions.ts | 14 ++ .../ai-agent-action/utils/getFieldIcon.ts | 17 ++ .../constants/OtherActions.ts | 5 + .../hooks/useFilteredOtherActions.ts | 11 + .../utils/getActionHeaderTypeOrThrow.ts | 3 + .../utils/getActionIconColorOrThrow.ts | 2 + .../types/StepOutputSchema.ts | 2 + .../src/testing/mock-data/config.ts | 1 + packages/twenty-server/package.json | 1 + .../common/1747401483136-createAgentTable.ts | 37 +++ .../src/database/typeorm/typeorm.service.ts | 3 + .../src/engine/core-modules/ai/ai.module.ts | 5 +- .../ai/constants/ai-models.const.ts | 65 ++++++ .../constants/dollar-to-credit-multiplier.ts | 2 + .../ai/services/ai-billing.service.spec.ts | 90 +++++++ .../ai/services/ai-billing.service.ts | 72 ++++++ .../core-modules/ai/utils/ai-cost.utils.ts | 7 + .../ai/utils/get-ai-model-by-id.ts | 9 + .../core-modules/billing/billing.module.ts | 2 + .../client-config.controller.spec.ts | 15 +- .../client-config/client-config.entity.ts | 29 +++ .../services/client-config.service.spec.ts | 8 + .../services/client-config.service.ts | 37 ++- .../twenty-config/config-variables.ts | 8 + .../resolvers/workflow-step.resolver.ts | 17 ++ .../workflow/workflow-api.module.ts | 2 + .../workspace/workspace.entity.ts | 6 + .../src/engine/guards/feature-flag.guard.ts | 65 ++++++ .../agent/agent-execution.service.ts | 131 +++++++++++ .../metadata-modules/agent/agent.entity.ts | 56 +++++ .../metadata-modules/agent/agent.exception.ts | 20 ++ .../metadata-modules/agent/agent.module.ts | 30 +++ .../metadata-modules/agent/agent.resolver.ts | 66 ++++++ .../metadata-modules/agent/agent.service.ts | 88 +++++++ .../agent/dtos/agent-id.input.ts | 9 + .../metadata-modules/agent/dtos/agent.dto.ts | 45 ++++ .../agent/dtos/create-agent.input.ts | 34 +++ .../agent/dtos/update-agent.input.ts | 46 ++++ .../utils/convert-output-schema-to-zod.ts | 42 ++++ .../metadata-engine.module.ts | 3 + .../serverless-function.module.ts | 2 + .../serverless-function.resolver.ts | 6 +- .../types/output-schema.type.ts | 2 + .../workflow-version-step.module.ts | 2 + ...workflow-version-step.workspace-service.ts | 54 ++++- .../factories/workflow-executor.factory.ts | 4 + .../ai-agent/ai-agent-action.module.ts | 24 ++ .../ai-agent/ai-agent.workflow-action.ts | 98 ++++++++ .../is-workflow-ai-agent-action.guard.ts | 11 + .../workflow-ai-agent-action-input.type.ts | 3 + .../workflow-ai-agent-action-settings.type.ts | 6 + .../types/workflow-action-settings.type.ts | 4 +- .../types/workflow-action.type.ts | 10 +- .../workflow-executor.module.ts | 4 + .../twenty-server/src/utils/typed-reflect.ts | 2 + yarn.lock | 13 ++ 75 files changed, 2268 insertions(+), 30 deletions(-) create mode 100644 packages/twenty-front/src/modules/client-config/states/aiModelsState.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowOutputFieldTypeSelector.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowOutputSchemaBuilder.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/output-field-type-options.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/graphql/mutations/updateOneAgent.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/graphql/queries/findOneAgent.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentUpdateFormState.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAiAgentOutputSchema.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAiModelOptions.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/utils/getFieldIcon.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/useFilteredOtherActions.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1747401483136-createAgentTable.ts create mode 100644 packages/twenty-server/src/engine/core-modules/ai/constants/ai-models.const.ts create mode 100644 packages/twenty-server/src/engine/core-modules/ai/constants/dollar-to-credit-multiplier.ts create mode 100644 packages/twenty-server/src/engine/core-modules/ai/services/ai-billing.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/ai/services/ai-billing.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/ai/utils/ai-cost.utils.ts create mode 100644 packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-model-by-id.ts create mode 100644 packages/twenty-server/src/engine/guards/feature-flag.guard.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/agent-execution.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/agent.entity.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/agent.exception.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/agent.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/agent.resolver.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/agent.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/dtos/agent-id.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/dtos/agent.dto.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/dtos/create-agent.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/dtos/update-agent.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/utils/convert-output-schema-to-zod.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/guards/is-workflow-ai-agent-action.guard.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-input.type.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-settings.type.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 74cc90eb1ca..906b42c39e1 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -57,6 +57,23 @@ export type AdminPanelWorkerQueueHealth = { status: AdminPanelHealthServiceStatus; }; +export type Agent = { + __typename?: 'Agent'; + createdAt: Scalars['DateTime']['output']; + description?: Maybe; + id: Scalars['UUID']['output']; + modelId: Scalars['String']['output']; + name: Scalars['String']['output']; + prompt: Scalars['String']['output']; + responseFormat?: Maybe; + updatedAt: Scalars['DateTime']['output']; +}; + +export type AgentIdInput = { + /** The id of the agent. */ + id: Scalars['UUID']['input']; +}; + export type Analytics = { __typename?: 'Analytics'; /** Boolean that confirms query was dispatched */ @@ -320,8 +337,18 @@ export type CheckUserExistOutput = { isEmailVerified: Scalars['Boolean']['output']; }; +export type ClientAiModelConfig = { + __typename?: 'ClientAIModelConfig'; + inputCostPer1kTokensInCredits: Scalars['Float']['output']; + label: Scalars['String']['output']; + modelId: Scalars['String']['output']; + outputCostPer1kTokensInCredits: Scalars['Float']['output']; + provider: ModelProvider; +}; + export type ClientConfig = { __typename?: 'ClientConfig'; + aiModels: Array; analyticsEnabled: Scalars['Boolean']['output']; api: ApiConfig; authProviders: AuthProviders; @@ -413,6 +440,14 @@ export type ConfigVariablesOutput = { groups: Array; }; +export type CreateAgentInput = { + description?: InputMaybe; + modelId: Scalars['String']['input']; + name: Scalars['String']['input']; + prompt: Scalars['String']['input']; + responseFormat?: InputMaybe; +}; + export type CreateAppTokenInput = { expiresAt: Scalars['DateTime']['input']; }; @@ -944,6 +979,11 @@ export enum MessageChannelVisibility { SUBJECT = 'SUBJECT' } +export enum ModelProvider { + ANTHROPIC = 'ANTHROPIC', + OPENAI = 'OPENAI' +} + export type Mutation = { __typename?: 'Mutation'; activateWorkflowVersion: Scalars['Boolean']['output']; @@ -957,6 +997,7 @@ export type Mutation = { createDraftFromWorkflowVersion: WorkflowVersion; createOIDCIdentityProvider: SetupSsoOutput; createObjectEvent: Analytics; + createOneAgent: Agent; createOneAppToken: AppToken; createOneField: Field; createOneObject: Object; @@ -969,6 +1010,7 @@ export type Mutation = { deleteApprovedAccessDomain: Scalars['Boolean']['output']; deleteCurrentWorkspace: Workspace; deleteDatabaseConfigVariable: Scalars['Boolean']['output']; + deleteOneAgent: Agent; deleteOneField: Field; deleteOneObject: Object; deleteOneRemoteServer: RemoteServer; @@ -1012,6 +1054,7 @@ export type Mutation = { unsyncRemoteTable: RemoteTable; updateDatabaseConfigVariable: Scalars['Boolean']['output']; updateLabPublicFeatureFlag: FeatureFlagDto; + updateOneAgent: Agent; updateOneField: Field; updateOneObject: Object; updateOneRemoteServer: RemoteServer; @@ -1093,6 +1136,11 @@ export type MutationCreateObjectEventArgs = { }; +export type MutationCreateOneAgentArgs = { + input: CreateAgentInput; +}; + + export type MutationCreateOneAppTokenArgs = { input: CreateOneAppTokenInput; }; @@ -1148,6 +1196,11 @@ export type MutationDeleteDatabaseConfigVariableArgs = { }; +export type MutationDeleteOneAgentArgs = { + input: AgentIdInput; +}; + + export type MutationDeleteOneFieldArgs = { input: DeleteOneFieldInput; }; @@ -1339,6 +1392,11 @@ export type MutationUpdateLabPublicFeatureFlagArgs = { }; +export type MutationUpdateOneAgentArgs = { + input: UpdateAgentInput; +}; + + export type MutationUpdateOneFieldArgs = { input: UpdateOneFieldMetadataInput; }; @@ -1655,8 +1713,10 @@ export type Query = { field: Field; fields: FieldConnection; findDistantTablesWithStatus: Array; + findManyAgents: Array; findManyRemoteServersByType: Array; findManyServerlessFunctions: Array; + findOneAgent: Agent; findOneRemoteServerById: RemoteServer; findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; @@ -1726,6 +1786,11 @@ export type QueryFindManyRemoteServersByTypeArgs = { }; +export type QueryFindOneAgentArgs = { + input: AgentIdInput; +}; + + export type QueryFindOneRemoteServerByIdArgs = { input: RemoteServerIdInput; }; @@ -2280,6 +2345,15 @@ export type UuidFilterComparison = { notLike?: InputMaybe; }; +export type UpdateAgentInput = { + description?: InputMaybe; + id: Scalars['UUID']['input']; + modelId: Scalars['String']['input']; + name: Scalars['String']['input']; + prompt: Scalars['String']['input']; + responseFormat?: InputMaybe; +}; + export type UpdateFieldInput = { defaultValue?: InputMaybe; description?: InputMaybe; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index ad41cef42d1..01b468fb330 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -49,6 +49,23 @@ export type AdminPanelWorkerQueueHealth = { status: AdminPanelHealthServiceStatus; }; +export type Agent = { + __typename?: 'Agent'; + createdAt: Scalars['DateTime']; + description?: Maybe; + id: Scalars['UUID']; + modelId: Scalars['String']; + name: Scalars['String']; + prompt: Scalars['String']; + responseFormat?: Maybe; + updatedAt: Scalars['DateTime']; +}; + +export type AgentIdInput = { + /** The id of the agent. */ + id: Scalars['UUID']; +}; + export type Analytics = { __typename?: 'Analytics'; /** Boolean that confirms query was dispatched */ @@ -312,8 +329,18 @@ export type CheckUserExistOutput = { isEmailVerified: Scalars['Boolean']; }; +export type ClientAiModelConfig = { + __typename?: 'ClientAIModelConfig'; + inputCostPer1kTokensInCredits: Scalars['Float']; + label: Scalars['String']; + modelId: Scalars['String']; + outputCostPer1kTokensInCredits: Scalars['Float']; + provider: ModelProvider; +}; + export type ClientConfig = { __typename?: 'ClientConfig'; + aiModels: Array; analyticsEnabled: Scalars['Boolean']; api: ApiConfig; authProviders: AuthProviders; @@ -405,6 +432,14 @@ export type ConfigVariablesOutput = { groups: Array; }; +export type CreateAgentInput = { + description?: InputMaybe; + modelId: Scalars['String']; + name: Scalars['String']; + prompt: Scalars['String']; + responseFormat?: InputMaybe; +}; + export type CreateApprovedAccessDomainInput = { domain: Scalars['String']; email: Scalars['String']; @@ -893,6 +928,11 @@ export enum MessageChannelVisibility { SUBJECT = 'SUBJECT' } +export enum ModelProvider { + ANTHROPIC = 'ANTHROPIC', + OPENAI = 'OPENAI' +} + export type Mutation = { __typename?: 'Mutation'; activateWorkflowVersion: Scalars['Boolean']; @@ -906,6 +946,7 @@ export type Mutation = { createDraftFromWorkflowVersion: WorkflowVersion; createOIDCIdentityProvider: SetupSsoOutput; createObjectEvent: Analytics; + createOneAgent: Agent; createOneAppToken: AppToken; createOneField: Field; createOneObject: Object; @@ -917,6 +958,7 @@ export type Mutation = { deleteApprovedAccessDomain: Scalars['Boolean']; deleteCurrentWorkspace: Workspace; deleteDatabaseConfigVariable: Scalars['Boolean']; + deleteOneAgent: Agent; deleteOneField: Field; deleteOneObject: Object; deleteOneRole: Scalars['String']; @@ -956,6 +998,7 @@ export type Mutation = { trackAnalytics: Analytics; updateDatabaseConfigVariable: Scalars['Boolean']; updateLabPublicFeatureFlag: FeatureFlagDto; + updateOneAgent: Agent; updateOneField: Field; updateOneObject: Object; updateOneRole: Role; @@ -1036,6 +1079,11 @@ export type MutationCreateObjectEventArgs = { }; +export type MutationCreateOneAgentArgs = { + input: CreateAgentInput; +}; + + export type MutationCreateOneFieldArgs = { input: CreateOneFieldMetadataInput; }; @@ -1076,6 +1124,11 @@ export type MutationDeleteDatabaseConfigVariableArgs = { }; +export type MutationDeleteOneAgentArgs = { + input: AgentIdInput; +}; + + export type MutationDeleteOneFieldArgs = { input: DeleteOneFieldInput; }; @@ -1247,6 +1300,11 @@ export type MutationUpdateLabPublicFeatureFlagArgs = { }; +export type MutationUpdateOneAgentArgs = { + input: UpdateAgentInput; +}; + + export type MutationUpdateOneFieldArgs = { input: UpdateOneFieldMetadataInput; }; @@ -1557,7 +1615,9 @@ export type Query = { currentWorkspace: Workspace; field: Field; fields: FieldConnection; + findManyAgents: Array; findManyServerlessFunctions: Array; + findOneAgent: Agent; findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; @@ -1605,6 +1665,11 @@ export type QueryCheckWorkspaceInviteHashIsValidArgs = { }; +export type QueryFindOneAgentArgs = { + input: AgentIdInput; +}; + + export type QueryFindOneServerlessFunctionArgs = { input: ServerlessFunctionIdInput; }; @@ -2118,6 +2183,15 @@ export type UuidFilterComparison = { notLike?: InputMaybe; }; +export type UpdateAgentInput = { + description?: InputMaybe; + id: Scalars['UUID']; + modelId: Scalars['String']; + name: Scalars['String']; + prompt: Scalars['String']; + responseFormat?: InputMaybe; +}; + export type UpdateFieldInput = { defaultValue?: InputMaybe; description?: InputMaybe; @@ -2751,7 +2825,7 @@ export type GetMeteredProductsUsageQuery = { __typename?: 'Query', getMeteredPro export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; -export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isEmailVerificationRequired: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, isAttachmentPreviewEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, isMicrosoftMessagingEnabled: boolean, isMicrosoftCalendarEnabled: boolean, isGoogleMessagingEnabled: boolean, isGoogleCalendarEnabled: boolean, isConfigVariablesInDbEnabled: boolean, calendarBookingPageId?: string | null, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, trialPeriods: Array<{ __typename?: 'BillingTrialPeriodDTO', duration: number, isCreditCardRequired: boolean }> }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, support: { __typename?: 'Support', supportDriver: SupportDriver, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number }, publicFeatureFlags: Array<{ __typename?: 'PublicFeatureFlag', key: FeatureFlagKey, metadata: { __typename?: 'PublicFeatureFlagMetadata', label: string, description: string, imagePath: string } }> } }; +export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isEmailVerificationRequired: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, isAttachmentPreviewEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, isMicrosoftMessagingEnabled: boolean, isMicrosoftCalendarEnabled: boolean, isGoogleMessagingEnabled: boolean, isGoogleCalendarEnabled: boolean, isConfigVariablesInDbEnabled: boolean, calendarBookingPageId?: string | null, aiModels: Array<{ __typename?: 'ClientAIModelConfig', modelId: string, label: string, provider: ModelProvider, inputCostPer1kTokensInCredits: number, outputCostPer1kTokensInCredits: number }>, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, trialPeriods: Array<{ __typename?: 'BillingTrialPeriodDTO', duration: number, isCreditCardRequired: boolean }> }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, support: { __typename?: 'Support', supportDriver: SupportDriver, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number }, publicFeatureFlags: Array<{ __typename?: 'PublicFeatureFlag', key: FeatureFlagKey, metadata: { __typename?: 'PublicFeatureFlagMetadata', label: string, description: string, imagePath: string } }> } }; export type SearchQueryVariables = Exact<{ searchInput: Scalars['String']; @@ -3062,6 +3136,20 @@ export type UpdateWorkflowVersionStepMutationVariables = Exact<{ export type UpdateWorkflowVersionStepMutation = { __typename?: 'Mutation', updateWorkflowVersionStep: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array | null } }; +export type UpdateOneAgentMutationVariables = Exact<{ + input: UpdateAgentInput; +}>; + + +export type UpdateOneAgentMutation = { __typename?: 'Mutation', updateOneAgent: { __typename?: 'Agent', id: any, name: string, description?: string | null, prompt: string, modelId: string, responseFormat?: any | null } }; + +export type FindOneAgentQueryVariables = Exact<{ + id: Scalars['UUID']; +}>; + + +export type FindOneAgentQuery = { __typename?: 'Query', findOneAgent: { __typename?: 'Agent', id: any, name: string, description?: string | null, prompt: string, modelId: string, responseFormat?: any | null } }; + export type SubmitFormStepMutationVariables = Exact<{ input: SubmitFormStepInput; }>; @@ -4708,6 +4796,13 @@ export type GetMeteredProductsUsageQueryResult = Apollo.QueryResult; export type UpdateWorkflowVersionStepMutationResult = Apollo.MutationResult; export type UpdateWorkflowVersionStepMutationOptions = Apollo.BaseMutationOptions; +export const UpdateOneAgentDocument = gql` + mutation UpdateOneAgent($input: UpdateAgentInput!) { + updateOneAgent(input: $input) { + id + name + description + prompt + modelId + responseFormat + } +} + `; +export type UpdateOneAgentMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateOneAgentMutation__ + * + * To run a mutation, you first call `useUpdateOneAgentMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateOneAgentMutation` 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 [updateOneAgentMutation, { data, loading, error }] = useUpdateOneAgentMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useUpdateOneAgentMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateOneAgentDocument, options); + } +export type UpdateOneAgentMutationHookResult = ReturnType; +export type UpdateOneAgentMutationResult = Apollo.MutationResult; +export type UpdateOneAgentMutationOptions = Apollo.BaseMutationOptions; +export const FindOneAgentDocument = gql` + query FindOneAgent($id: UUID!) { + findOneAgent(input: {id: $id}) { + id + name + description + prompt + modelId + responseFormat + } +} + `; + +/** + * __useFindOneAgentQuery__ + * + * To run a query within a React component, call `useFindOneAgentQuery` and pass it any options that fit your needs. + * When your component renders, `useFindOneAgentQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useFindOneAgentQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useFindOneAgentQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(FindOneAgentDocument, options); + } +export function useFindOneAgentLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(FindOneAgentDocument, options); + } +export type FindOneAgentQueryHookResult = ReturnType; +export type FindOneAgentLazyQueryHookResult = ReturnType; +export type FindOneAgentQueryResult = Apollo.QueryResult; export const SubmitFormStepDocument = gql` mutation SubmitFormStep($input: SubmitFormStepInput!) { submitFormStep(input: $input) diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx index 3840df97433..a5de390c83e 100644 --- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx @@ -1,4 +1,5 @@ import { useClientConfig } from '@/client-config/hooks/useClientConfig'; +import { aiModelsState } from '@/client-config/states/aiModelsState'; import { apiConfigState } from '@/client-config/states/apiConfigState'; import { authProvidersState } from '@/client-config/states/authProvidersState'; import { billingState } from '@/client-config/states/billingState'; @@ -29,6 +30,7 @@ export const ClientConfigProviderEffect = () => { const setIsAnalyticsEnabled = useSetRecoilState(isAnalyticsEnabledState); const setDomainConfiguration = useSetRecoilState(domainConfigurationState); const setAuthProviders = useSetRecoilState(authProvidersState); + const setAiModels = useSetRecoilState(aiModelsState); const setIsDeveloperDefaultSignInPrefilled = useSetRecoilState( isDeveloperDefaultSignInPrefilledState, @@ -134,6 +136,7 @@ export const ClientConfigProviderEffect = () => { magicLink: false, sso: data?.clientConfig.authProviders.sso, }); + setAiModels(data?.clientConfig.aiModels || []); setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled); setIsDeveloperDefaultSignInPrefilled(data?.clientConfig.signInPrefilled); setIsMultiWorkspaceEnabled(data?.clientConfig.isMultiWorkspaceEnabled); @@ -197,6 +200,7 @@ export const ClientConfigProviderEffect = () => { setIsAnalyticsEnabled, setDomainConfiguration, setAuthProviders, + setAiModels, setCanManageFeatureFlags, setLabPublicFeatureFlags, setMicrosoftMessagingEnabled, diff --git a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts index bbdd889d6db..b57dd3822cf 100644 --- a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts @@ -3,6 +3,13 @@ import { gql } from '@apollo/client'; export const GET_CLIENT_CONFIG = gql` query GetClientConfig { clientConfig { + aiModels { + modelId + label + provider + inputCostPer1kTokensInCredits + outputCostPer1kTokensInCredits + } billing { isBillingEnabled billingUrl diff --git a/packages/twenty-front/src/modules/client-config/states/aiModelsState.ts b/packages/twenty-front/src/modules/client-config/states/aiModelsState.ts new file mode 100644 index 00000000000..65235a603c8 --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/states/aiModelsState.ts @@ -0,0 +1,7 @@ +import { createState } from 'twenty-ui/utilities'; +import { ClientAiModelConfig } from '~/generated-metadata/graphql'; + +export const aiModelsState = createState({ + key: 'aiModelsState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent.tsx b/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent.tsx index 8dd2abb8534..1aafec1f923 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent.tsx @@ -2,10 +2,10 @@ import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow'; import { RightDrawerStepListContainer } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepContainer'; import { RightDrawerWorkflowSelectStepTitle } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepTitle'; import { useCreateStep } from '@/workflow/workflow-steps/hooks/useCreateStep'; -import { OTHER_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/OtherActions'; import { RECORD_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/RecordActions'; -import { MenuItemCommand } from 'twenty-ui/navigation'; +import { useFilteredOtherActions } from '@/workflow/workflow-steps/workflow-actions/hooks/useFilteredOtherActions'; import { useIcons } from 'twenty-ui/display'; +import { MenuItemCommand } from 'twenty-ui/navigation'; export const CommandMenuWorkflowSelectActionContent = ({ workflow, @@ -16,6 +16,7 @@ export const CommandMenuWorkflowSelectActionContent = ({ const { createStep } = useCreateStep({ workflow, }); + const filteredOtherActions = useFilteredOtherActions(); return ( @@ -33,7 +34,7 @@ export const CommandMenuWorkflowSelectActionContent = ({ Other - {OTHER_ACTIONS.map((action) => ( + {filteredOtherActions.map((action) => ( ; -export type WorkflowAction = z.infer; +export type WorkflowAiAgentActionSettings = z.infer< + typeof workflowAiAgentActionSettingsSchema +>; + +export type WorkflowAiAgentAction = z.infer; + +export type WorkflowAction = + | WorkflowCodeAction + | WorkflowSendEmailAction + | WorkflowCreateRecordAction + | WorkflowUpdateRecordAction + | WorkflowDeleteRecordAction + | WorkflowFindRecordsAction + | WorkflowFormAction + | WorkflowHttpRequestAction + | WorkflowAiAgentAction; + export type WorkflowActionType = WorkflowAction['type']; export type WorkflowStep = WorkflowAction; export type WorkflowStepType = WorkflowStep['type']; diff --git a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts b/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts index 44793cb92ea..0b92edfe0ba 100644 --- a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts +++ b/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts @@ -130,6 +130,13 @@ export const workflowHttpRequestActionSettingsSchema = }), }); +export const workflowAiAgentActionSettingsSchema = + baseWorkflowActionSettingsSchema.extend({ + input: z.object({ + agentId: z.string(), + }), + }); + // Action schemas export const workflowCodeActionSchema = baseWorkflowActionSchema.extend({ type: z.literal('CODE'), @@ -177,6 +184,11 @@ export const workflowHttpRequestActionSchema = baseWorkflowActionSchema.extend({ settings: workflowHttpRequestActionSettingsSchema, }); +export const workflowAiAgentActionSchema = baseWorkflowActionSchema.extend({ + type: z.literal('AI_AGENT'), + settings: workflowAiAgentActionSettingsSchema, +}); + // Combined action schema export const workflowActionSchema = z.discriminatedUnion('type', [ workflowCodeActionSchema, @@ -187,6 +199,7 @@ export const workflowActionSchema = z.discriminatedUnion('type', [ workflowFindRecordsActionSchema, workflowFormActionSchema, workflowHttpRequestActionSchema, + workflowAiAgentActionSchema, ]); // Trigger schemas diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon.tsx index 782e1099b72..d7dd470c0c9 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon.tsx @@ -64,6 +64,13 @@ export const WorkflowDiagramStepNodeIcon = ({ ); } + case 'AI_AGENT': { + return ( + + + + ); + } default: { return ( diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx index 32fb5fb37de..9929c771127 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx @@ -2,6 +2,7 @@ import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow'; import { assertUnreachable } from '@/workflow/utils/assertUnreachable'; import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow'; import { WorkflowDiagramRunStatus } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { WorkflowEditActionAiAgent } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent'; import { WorkflowActionServerlessFunction } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowActionServerlessFunction'; import { WorkflowEditActionCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord'; import { WorkflowEditActionDeleteRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord'; @@ -188,6 +189,17 @@ export const WorkflowRunStepNodeDetail = ({ /> ); } + case 'AI_AGENT': { + return ( + + ); + } } } } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx index 8c6514dce66..e2e316a13e6 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx @@ -1,6 +1,7 @@ import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow'; import { assertUnreachable } from '@/workflow/utils/assertUnreachable'; import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow'; +import { WorkflowEditActionAiAgent } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent'; import { WorkflowActionServerlessFunction } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowActionServerlessFunction'; import { WorkflowEditActionCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord'; import { WorkflowEditActionDeleteRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord'; @@ -88,12 +89,12 @@ export const WorkflowStepDetail = ({ /> ); } + default: + return assertUnreachable( + stepDefinition.definition, + `Expected the step to have an handler; ${JSON.stringify(stepDefinition)}`, + ); } - - return assertUnreachable( - stepDefinition.definition, - `Expected the step to have an handler; ${JSON.stringify(stepDefinition)}`, - ); } case 'action': { switch (stepDefinition.definition.type) { @@ -174,12 +175,22 @@ export const WorkflowStepDetail = ({ /> ); } + case 'AI_AGENT': { + return ( + + ); + } + + default: + return assertUnreachable( + stepDefinition.definition, + `Expected the step to have an handler; ${JSON.stringify(stepDefinition)}`, + ); } } } - - return assertUnreachable( - stepDefinition, - `Expected the step to have an handler; ${JSON.stringify(stepDefinition)}`, - ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent.tsx new file mode 100644 index 00000000000..42b15756a82 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent.tsx @@ -0,0 +1,118 @@ +import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput'; +import { Select } from '@/ui/input/components/Select'; +import { WorkflowAiAgentAction } from '@/workflow/types/Workflow'; +import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; +import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader'; +import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader'; +import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; +import { BaseOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema'; +import styled from '@emotion/styled'; +import { t } from '@lingui/core/macro'; +import { useIcons } from 'twenty-ui/display'; +import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader'; +import { useAgentUpdateFormState } from '../hooks/useAgentUpdateFormState'; +import { useAiAgentOutputSchema } from '../hooks/useAiAgentOutputSchema'; +import { useAiModelOptions } from '../hooks/useAiModelOptions'; +import { WorkflowOutputSchemaBuilder } from './WorkflowOutputSchemaBuilder'; + +const StyledErrorMessage = styled.div` + color: ${({ theme }) => theme.font.color.danger}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.regular}; + margin-top: ${({ theme }) => theme.spacing(1)}; +`; + +type WorkflowEditActionAiAgentProps = { + action: WorkflowAiAgentAction; + actionOptions: + | { readonly: true } + | { + readonly?: false; + onActionUpdate: (action: WorkflowAiAgentAction) => void; + }; +}; + +export const WorkflowEditActionAiAgent = ({ + action, + actionOptions, +}: WorkflowEditActionAiAgentProps) => { + const { getIcon } = useIcons(); + const { headerTitle, headerIcon, headerIconColor, headerType } = + useWorkflowActionHeader({ + action, + defaultTitle: 'AI Agent', + }); + + const { formValues, handleFieldChange, loading } = useAgentUpdateFormState({ + agentId: action.settings.input.agentId, + readonly: actionOptions.readonly === true, + }); + + const { handleOutputSchemaChange, outputFields } = useAiAgentOutputSchema( + action.settings.outputSchema as BaseOutputSchema, + actionOptions.readonly === true ? undefined : actionOptions.onActionUpdate, + action, + actionOptions.readonly, + ); + + const modelOptions = useAiModelOptions(); + + const noModelsAvailable = modelOptions.length === 0; + + return loading ? ( + + ) : ( + <> + { + if (actionOptions.readonly === true) { + return; + } + actionOptions.onActionUpdate?.({ ...action, name: newName }); + }} + Icon={getIcon(headerIcon)} + iconColor={headerIconColor} + initialTitle={headerTitle} + headerType={headerType} + disabled={actionOptions.readonly} + /> + +
+ ({ + ...option, + label: t(option.label), + }))} + value={value} + onChange={onChange} + disabled={disabled} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowOutputSchemaBuilder.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowOutputSchemaBuilder.tsx new file mode 100644 index 00000000000..3973c8ce663 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowOutputSchemaBuilder.tsx @@ -0,0 +1,221 @@ +import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer'; +import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput'; + +import { InputLabel } from '@/ui/input/components/InputLabel'; +import { InputSchemaPropertyType } from '@/workflow/types/InputSchema'; +import { OutputSchemaField } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/output-field-type-options'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { t } from '@lingui/core/macro'; +import { IconPlus, IconTrash } from 'twenty-ui/display'; +import { LightIconButton } from 'twenty-ui/input'; +import { v4 } from 'uuid'; +import { WorkflowOutputFieldTypeSelector } from './WorkflowOutputFieldTypeSelector'; + +type WorkflowOutputSchemaBuilderProps = { + fields: OutputSchemaField[]; + onChange: (fields: OutputSchemaField[]) => void; + readonly?: boolean; +}; + +const StyledOutputSchemaContainer = styled.div` + display: flex; + flex-direction: column; +`; + +const StyledFieldsContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledOutputSchemaFieldContainer = styled.div` + background-color: ${({ theme }) => theme.background.transparent.lighter}; + border: 1px solid ${({ theme }) => theme.border.color.light}; + border-radius: ${({ theme }) => theme.border.radius.md}; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledSettingsContent = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(3)}; +`; + +const StyledSettingsHeader = styled.div` + border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; + display: grid; + gap: ${({ theme }) => theme.spacing(1)}; + padding-right: ${({ theme }) => theme.spacing(2)}; + padding-left: ${({ theme }) => theme.spacing(3)}; + grid-template-columns: 1fr 24px; + padding-bottom: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledTitleContainer = styled.div` + color: ${({ theme }) => theme.font.color.primary}; + display: flex; + flex-direction: row; + gap: ${({ theme }) => theme.spacing(1)}; + padding-top: ${({ theme }) => theme.spacing(3)}; +`; + +const StyledCloseButtonContainer = styled.div` + padding-top: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledAddFieldButton = styled.button` + align-items: center; + border: 1px solid ${({ theme }) => theme.border.color.light}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + color: ${({ theme }) => theme.font.color.secondary}; + cursor: pointer; + display: flex; + font-family: inherit; + font-weight: ${({ theme }) => theme.font.weight.medium}; + gap: ${({ theme }) => theme.spacing(1)}; + justify-content: center; + margin-top: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(2)}; + width: 100%; + background-color: ${({ theme }) => theme.background.transparent.lighter}; + + &:hover { + background-color: ${({ theme }) => theme.background.transparent.light}; + } +`; + +const StyledMessageContentContainer = styled.div` + flex-direction: column; + color: ${({ theme }) => theme.font.color.secondary}; + display: flex; + gap: ${({ theme }) => theme.spacing(4)}; + padding: ${({ theme }) => theme.spacing(4)}; + line-height: normal; +`; + +const StyledMessageDescription = styled.div` + color: ${({ theme }) => theme.font.color.secondary}; + font-weight: ${({ theme }) => theme.font.weight.regular}; +`; + +export const WorkflowOutputSchemaBuilder = ({ + fields, + onChange, + readonly, +}: WorkflowOutputSchemaBuilderProps) => { + const theme = useTheme(); + + const addField = () => { + const newField: OutputSchemaField = { + id: v4(), + name: '', + description: '', + type: 'TEXT' as InputSchemaPropertyType, + }; + onChange([...fields, newField]); + }; + + const removeField = (id: string) => { + onChange(fields.filter((field) => field.id !== id)); + }; + + const updateField = (id: string, updates: Partial) => { + onChange( + fields.map((field) => + field.id === id ? { ...field, ...updates } : field, + ), + ); + }; + + return ( + + {t`AI Response Schema`} + + {fields.length === 0 && ( + + + + {t`Click on "Add Output Field" below to define the structure of your AI agent's response. These fields will be used to format and validate the AI's output when the workflow is executed, and can be referenced by subsequent workflow steps.`} + + + + )} + + {fields.length > 0 && ( + + {fields.map((field, index) => { + const fieldNumber = index + 1; + + return ( + + + + {t`Output Field ${fieldNumber}`} + + + {!readonly && ( + removeField(field.id)} + /> + )} + + + + + + updateField(field.id, { name: value }) + } + readonly={readonly} + /> + + + + + updateField(field.id, { type: value }) + } + value={field.type} + disabled={readonly} + dropdownId={`output-field-type-selector-${field.id}`} + /> + + + + + updateField(field.id, { description: value }) + } + readonly={readonly} + /> + + + + ); + })} + + )} + + {!readonly && ( + + + {t`Add Output Field`} + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/output-field-type-options.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/output-field-type-options.ts new file mode 100644 index 00000000000..ab7d1a5e5d6 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/output-field-type-options.ts @@ -0,0 +1,39 @@ +import { InputSchemaPropertyType } from '@/workflow/types/InputSchema'; +import { msg } from '@lingui/core/macro'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { + IllustrationIconCalendarEvent, + IllustrationIconNumbers, + IllustrationIconText, + IllustrationIconToggle, +} from 'twenty-ui/display'; + +export interface OutputSchemaField { + id: string; + name: string; + description?: string; + type: InputSchemaPropertyType | undefined; +} + +export const OUTPUT_FIELD_TYPE_OPTIONS = [ + { + label: msg`Text`, + value: FieldMetadataType.TEXT, + Icon: IllustrationIconText, + }, + { + label: msg`Number`, + value: FieldMetadataType.NUMBER, + Icon: IllustrationIconNumbers, + }, + { + label: msg`Boolean`, + value: FieldMetadataType.BOOLEAN, + Icon: IllustrationIconToggle, + }, + { + label: msg`Date`, + value: FieldMetadataType.DATE, + Icon: IllustrationIconCalendarEvent, + }, +]; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/graphql/mutations/updateOneAgent.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/graphql/mutations/updateOneAgent.ts new file mode 100644 index 00000000000..57979260076 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/graphql/mutations/updateOneAgent.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_ONE_AGENT = gql` + mutation UpdateOneAgent($input: UpdateAgentInput!) { + updateOneAgent(input: $input) { + id + name + description + prompt + modelId + responseFormat + } + } +`; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/graphql/queries/findOneAgent.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/graphql/queries/findOneAgent.ts new file mode 100644 index 00000000000..b4bb01adb25 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/graphql/queries/findOneAgent.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +export const FIND_ONE_AGENT = gql` + query FindOneAgent($id: UUID!) { + findOneAgent(input: { id: $id }) { + id + name + description + prompt + modelId + responseFormat + } + } +`; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentUpdateFormState.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentUpdateFormState.ts new file mode 100644 index 00000000000..9c3607a90d1 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentUpdateFormState.ts @@ -0,0 +1,82 @@ +import { useMutation, useQuery } from '@apollo/client'; +import { useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { useDebouncedCallback } from 'use-debounce'; +import { UPDATE_ONE_AGENT } from '../graphql/mutations/updateOneAgent'; +import { FIND_ONE_AGENT } from '../graphql/queries/findOneAgent'; + +type AgentFormValues = { + name: string; + prompt: string; + modelId: string; +}; + +export const useAgentUpdateFormState = ({ + agentId, + readonly = false, +}: { + agentId: string; + readonly?: boolean; +}) => { + const [formValues, setFormValues] = useState({ + name: '', + prompt: '', + modelId: '', + }); + + const { loading } = useQuery(FIND_ONE_AGENT, { + variables: { id: agentId }, + skip: !agentId, + onCompleted: (data) => { + if (isDefined(data?.findOneAgent)) { + const agent = data.findOneAgent; + setFormValues({ + name: agent.name, + prompt: agent.prompt, + modelId: agent.modelId, + }); + } + }, + }); + + const [updateAgent] = useMutation(UPDATE_ONE_AGENT); + + const updateAgentMutation = async (updates: Partial) => { + if (!agentId) { + return; + } + + await updateAgent({ + variables: { + input: { + id: agentId, + ...updates, + }, + }, + }); + }; + + const handleSave = useDebouncedCallback(async (formData: AgentFormValues) => { + await updateAgentMutation({ + name: formData.name, + prompt: formData.prompt, + modelId: formData.modelId, + }); + }, 500); + + const handleFieldChange = async (field: string, value: string) => { + if (readonly) { + return; + } + + setFormValues((prev) => ({ ...prev, [field]: value })); + + await handleSave({ ...formValues, [field]: value }); + }; + + return { + formValues, + handleFieldChange, + loading, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAiAgentOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAiAgentOutputSchema.ts new file mode 100644 index 00000000000..76f40b09280 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAiAgentOutputSchema.ts @@ -0,0 +1,70 @@ +import { WorkflowAiAgentAction } from '@/workflow/types/Workflow'; +import { OutputSchemaField } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/output-field-type-options'; +import { BaseOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema'; +import { useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { useDebouncedCallback } from 'use-debounce'; +import { v4 } from 'uuid'; +import { getFieldIcon } from '../utils/getFieldIcon'; + +export const useAiAgentOutputSchema = ( + outputSchema?: BaseOutputSchema, + onActionUpdate?: (action: WorkflowAiAgentAction) => void, + action?: WorkflowAiAgentAction, + readonly?: boolean, +) => { + const [outputFields, setOutputFields] = useState( + Object.entries(outputSchema || {}).map(([name, field]) => ({ + id: v4(), + name, + type: field.type, + description: field.description, + })), + ); + + const debouncedSave = useDebouncedCallback( + async (fields: OutputSchemaField[]) => { + if (readonly === true) { + return; + } + + const newOutputSchema = fields.reduce( + (schema, field) => { + if (isDefined(field.name)) { + schema[field.name] = { + isLeaf: true, + type: field.type, + value: null, + icon: getFieldIcon(field.type), + label: field.name, + description: field.description, + }; + } + return schema; + }, + {}, + ); + + if (isDefined(onActionUpdate) && isDefined(action)) { + onActionUpdate({ + ...action, + settings: { + ...action.settings, + outputSchema: newOutputSchema, + }, + }); + } + }, + 500, + ); + + const handleOutputSchemaChange = async (fields: OutputSchemaField[]) => { + setOutputFields(fields); + await debouncedSave(fields); + }; + + return { + handleOutputSchemaChange, + outputFields, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAiModelOptions.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAiModelOptions.ts new file mode 100644 index 00000000000..04686b9ef1c --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAiModelOptions.ts @@ -0,0 +1,14 @@ +import { aiModelsState } from '@/client-config/states/aiModelsState'; +import { useRecoilValue } from 'recoil'; +import { SelectOption } from 'twenty-ui/input'; + +export const useAiModelOptions = (): SelectOption[] => { + const aiModels = useRecoilValue(aiModelsState); + + return aiModels + .map((model) => ({ + value: model.modelId, + label: `${model.label} (${model.provider})`, + })) + .sort((a, b) => a.label.localeCompare(b.label)); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/utils/getFieldIcon.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/utils/getFieldIcon.ts new file mode 100644 index 00000000000..211ac2f7a4a --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/utils/getFieldIcon.ts @@ -0,0 +1,17 @@ +import { InputSchemaPropertyType } from '@/workflow/types/InputSchema'; +import { FieldMetadataType } from 'twenty-shared/types'; + +export const getFieldIcon = (fieldType?: InputSchemaPropertyType): string => { + switch (fieldType) { + case FieldMetadataType.TEXT: + return 'IconAbc'; + case FieldMetadataType.NUMBER: + return 'IconText'; + case FieldMetadataType.BOOLEAN: + return 'IconCheckbox'; + case FieldMetadataType.DATE: + return 'IconCalendarEvent'; + default: + return 'IconQuestionMark'; + } +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/OtherActions.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/OtherActions.ts index b2c738ecf73..927ebba56a9 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/OtherActions.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/OtherActions.ts @@ -28,4 +28,9 @@ export const OTHER_ACTIONS: Array<{ type: 'HTTP_REQUEST', icon: 'IconWorld', }, + { + label: 'AI Agent', + type: 'AI_AGENT', + icon: 'IconBrain', + }, ]; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/useFilteredOtherActions.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/useFilteredOtherActions.ts new file mode 100644 index 00000000000..347507f1e63 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/useFilteredOtherActions.ts @@ -0,0 +1,11 @@ +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated/graphql'; +import { OTHER_ACTIONS } from '../constants/OtherActions'; + +export const useFilteredOtherActions = () => { + const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); + + return OTHER_ACTIONS.filter((action) => { + return action.type !== 'AI_AGENT' || isAiEnabled; + }); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow.ts index 9bf7078e074..f86b119e149 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow.ts @@ -15,6 +15,9 @@ export const getActionHeaderTypeOrThrow = (actionType: WorkflowActionType) => { return msg`Action`; case 'HTTP_REQUEST': return msg`HTTP Request`; + case 'AI_AGENT': + return msg`AI Agent`; + default: assertUnreachable(actionType, `Unsupported action type: ${actionType}`); } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts index 40e43b55bf0..7ae50765fc8 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts @@ -21,6 +21,8 @@ export const getActionIconColorOrThrow = ({ return theme.font.color.tertiary; case 'SEND_EMAIL': return theme.color.blue; + case 'AI_AGENT': + return theme.color.pink; default: assertUnreachable(actionType, `Unsupported action type: ${actionType}`); } diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/types/StepOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/types/StepOutputSchema.ts index fe3fee273c3..13652d1c1e8 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/types/StepOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/types/StepOutputSchema.ts @@ -5,6 +5,7 @@ type Leaf = { type?: InputSchemaPropertyType; icon?: string; label?: string; + description?: string; value: any; }; @@ -14,6 +15,7 @@ type Node = { icon?: string; label?: string; value: OutputSchema; + description?: string; }; type Link = { diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index 5ffccbd4161..459b281fb4e 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -5,6 +5,7 @@ import { } from '~/generated/graphql'; export const mockedClientConfig: ClientConfig = { + aiModels: [], signInPrefilled: true, isMultiWorkspaceEnabled: false, isEmailVerificationRequired: false, diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index 6ce009a9765..8ec5eb1876a 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -15,6 +15,7 @@ "typeorm": "../../node_modules/typeorm/.bin/typeorm" }, "dependencies": { + "@ai-sdk/anthropic": "^1.2.12", "@ai-sdk/openai": "^1.3.22", "@blocknote/server-util": "^0.31.1", "@clickhouse/client": "^1.11.0", diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1747401483136-createAgentTable.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1747401483136-createAgentTable.ts new file mode 100644 index 00000000000..dcf795b9f3b --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1747401483136-createAgentTable.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateAgentTable1747401483136 implements MigrationInterface { + name = 'CreateAgentTable1747401483136'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "core"."agent" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "name" character varying NOT NULL, + "description" character varying, + "prompt" text NOT NULL, + "modelId" character varying NOT NULL, + "responseFormat" jsonb, + "workspaceId" uuid NOT NULL, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "deletedAt" TIMESTAMP WITH TIME ZONE, + CONSTRAINT "PK_agent" PRIMARY KEY ("id") + )`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_AGENT_ID_DELETED_AT" ON "core"."agent" ("id", "deletedAt")`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agent" ADD CONSTRAINT "FK_c4cb56621768a4a325dd772bbe1" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."agent" DROP CONSTRAINT "FK_c4cb56621768a4a325dd772bbe1"`, + ); + await queryRunner.query(`DROP INDEX "core"."IDX_AGENT_ID_DELETED_AT"`); + await queryRunner.query(`DROP TABLE "core"."agent"`); + } +} diff --git a/packages/twenty-server/src/database/typeorm/typeorm.service.ts b/packages/twenty-server/src/database/typeorm/typeorm.service.ts index 99cb7b322d4..384dfd91be4 100644 --- a/packages/twenty-server/src/database/typeorm/typeorm.service.ts +++ b/packages/twenty-server/src/database/typeorm/typeorm.service.ts @@ -20,6 +20,8 @@ import { TwoFactorMethod } from 'src/engine/core-modules/two-factor-method/two-f import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; + @Injectable() export class TypeORMService implements OnModuleInit, OnModuleDestroy { private mainDataSource: DataSource; @@ -48,6 +50,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy { WorkspaceSSOIdentityProvider, ApprovedAccessDomain, TwoFactorMethod, + AgentEntity, ], metadataTableName: '_typeorm_generated_columns_and_materialized_views', ssl: twentyConfigService.get('PG_SSL_ALLOW_SELF_SIGNED') diff --git a/packages/twenty-server/src/engine/core-modules/ai/ai.module.ts b/packages/twenty-server/src/engine/core-modules/ai/ai.module.ts index b5816aba015..524b0603e63 100644 --- a/packages/twenty-server/src/engine/core-modules/ai/ai.module.ts +++ b/packages/twenty-server/src/engine/core-modules/ai/ai.module.ts @@ -9,6 +9,7 @@ import { AI_DRIVER } from 'src/engine/core-modules/ai/ai.constants'; import { AiService } from 'src/engine/core-modules/ai/ai.service'; import { AiController } from 'src/engine/core-modules/ai/controllers/ai.controller'; import { OpenAIDriver } from 'src/engine/core-modules/ai/drivers/openai.driver'; +import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; @Global() @@ -33,8 +34,8 @@ export class AiModule { module: AiModule, imports: [FeatureFlagModule], controllers: [AiController], - providers: [AiService, provider], - exports: [AiService], + providers: [AiService, AIBillingService, provider], + exports: [AiService, AIBillingService], }; } } diff --git a/packages/twenty-server/src/engine/core-modules/ai/constants/ai-models.const.ts b/packages/twenty-server/src/engine/core-modules/ai/constants/ai-models.const.ts new file mode 100644 index 00000000000..eb22188806f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/constants/ai-models.const.ts @@ -0,0 +1,65 @@ +export enum ModelProvider { + OPENAI = 'openai', + ANTHROPIC = 'anthropic', +} + +export type ModelId = + | 'gpt-4o' + | 'gpt-4o-mini' + | 'gpt-4-turbo' + | 'claude-opus-4-20250514' + | 'claude-sonnet-4-20250514' + | 'claude-3-5-haiku-20241022'; + +export interface AIModelConfig { + modelId: ModelId; + label: string; + provider: ModelProvider; + inputCostPer1kTokensInCents: number; + outputCostPer1kTokensInCents: number; +} + +export const AI_MODELS: AIModelConfig[] = [ + { + modelId: 'gpt-4o', + label: 'GPT-4o', + provider: ModelProvider.OPENAI, + inputCostPer1kTokensInCents: 0.25, + outputCostPer1kTokensInCents: 1.0, + }, + { + modelId: 'gpt-4o-mini', + label: 'GPT-4o Mini', + provider: ModelProvider.OPENAI, + inputCostPer1kTokensInCents: 0.015, + outputCostPer1kTokensInCents: 0.06, + }, + { + modelId: 'gpt-4-turbo', + label: 'GPT-4 Turbo', + provider: ModelProvider.OPENAI, + inputCostPer1kTokensInCents: 1.0, + outputCostPer1kTokensInCents: 3.0, + }, + { + modelId: 'claude-opus-4-20250514', + label: 'Claude Opus 4', + provider: ModelProvider.ANTHROPIC, + inputCostPer1kTokensInCents: 1.5, + outputCostPer1kTokensInCents: 7.5, + }, + { + modelId: 'claude-sonnet-4-20250514', + label: 'Claude Sonnet 4', + provider: ModelProvider.ANTHROPIC, + inputCostPer1kTokensInCents: 0.3, + outputCostPer1kTokensInCents: 1.5, + }, + { + modelId: 'claude-3-5-haiku-20241022', + label: 'Claude Haiku 3.5', + provider: ModelProvider.ANTHROPIC, + inputCostPer1kTokensInCents: 0.08, + outputCostPer1kTokensInCents: 0.4, + }, +]; diff --git a/packages/twenty-server/src/engine/core-modules/ai/constants/dollar-to-credit-multiplier.ts b/packages/twenty-server/src/engine/core-modules/ai/constants/dollar-to-credit-multiplier.ts new file mode 100644 index 00000000000..6ec65a06b3b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/constants/dollar-to-credit-multiplier.ts @@ -0,0 +1,2 @@ +// Configuration: $0.001 = 1 credit +export const DOLLAR_TO_CREDIT_MULTIPLIER = 1000; // 1 / 0.001 = 1000 credits per dollar diff --git a/packages/twenty-server/src/engine/core-modules/ai/services/ai-billing.service.spec.ts b/packages/twenty-server/src/engine/core-modules/ai/services/ai-billing.service.spec.ts new file mode 100644 index 00000000000..eeed3b4927c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/services/ai-billing.service.spec.ts @@ -0,0 +1,90 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant'; +import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names'; +import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; + +import { AIBillingService } from './ai-billing.service'; + +describe('AIBillingService', () => { + let service: AIBillingService; + let mockWorkspaceEventEmitter: jest.Mocked; + + const mockTokenUsage = { + promptTokens: 1000, + completionTokens: 500, + totalTokens: 1500, + }; + + beforeEach(async () => { + const mockEventEmitterMethods = { + emitCustomBatchEvent: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AIBillingService, + { + provide: WorkspaceEventEmitter, + useValue: mockEventEmitterMethods, + }, + ], + }).compile(); + + service = module.get(AIBillingService); + mockWorkspaceEventEmitter = module.get(WorkspaceEventEmitter); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('calculateCost', () => { + it('should calculate cost correctly for valid model and token usage', async () => { + const costInCents = await service.calculateCost('gpt-4o', mockTokenUsage); + + // Expected: (1000/1000 * 0.25) + (500/1000 * 1.0) = 0.25 + 0.5 = 0.75 cents + expect(costInCents).toBe(0.75); + }); + + it('should calculate cost correctly with different token usage', async () => { + const differentTokenUsage = { + promptTokens: 2000, + completionTokens: 1000, + totalTokens: 3000, + }; + + const costInCents = await service.calculateCost( + 'gpt-4o', + differentTokenUsage, + ); + + // Expected: (2000/1000 * 0.25) + (1000/1000 * 1.0) = 0.5 + 1.0 = 1.5 cents + expect(costInCents).toBe(1.5); + }); + }); + + describe('calculateAndBillUsage', () => { + it('should calculate cost and emit billing event when model exists', async () => { + await service.calculateAndBillUsage( + 'gpt-4o', + mockTokenUsage, + 'workspace-1', + ); + + // Expected credits: (0.75 cents / 100) * 1000 = 0.0075 * 1000 = 7.5 credits, rounded to 8 + expect( + mockWorkspaceEventEmitter.emitCustomBatchEvent, + ).toHaveBeenCalledWith( + BILLING_FEATURE_USED, + [ + { + eventName: BillingMeterEventName.WORKFLOW_NODE_RUN, + value: 8, + }, + ], + 'workspace-1', + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/ai/services/ai-billing.service.ts b/packages/twenty-server/src/engine/core-modules/ai/services/ai-billing.service.ts new file mode 100644 index 00000000000..3bf3b353005 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/services/ai-billing.service.ts @@ -0,0 +1,72 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const'; +import { DOLLAR_TO_CREDIT_MULTIPLIER } from 'src/engine/core-modules/ai/constants/dollar-to-credit-multiplier'; +import { getAIModelById } from 'src/engine/core-modules/ai/utils/get-ai-model-by-id'; +import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant'; +import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names'; +import { BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type'; +import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; + +export interface TokenUsage { + promptTokens: number; + completionTokens: number; + totalTokens: number; +} + +@Injectable() +export class AIBillingService { + private readonly logger = new Logger(AIBillingService.name); + + constructor(private readonly workspaceEventEmitter: WorkspaceEventEmitter) {} + + async calculateCost(modelId: ModelId, usage: TokenUsage): Promise { + const model = getAIModelById(modelId); + + if (!model) { + throw new Error(`AI model with id ${modelId} not found`); + } + + const inputCost = + (usage.promptTokens / 1000) * model.inputCostPer1kTokensInCents; + const outputCost = + (usage.completionTokens / 1000) * model.outputCostPer1kTokensInCents; + + const totalCost = inputCost + outputCost; + + this.logger.log( + `Calculated cost for model ${modelId}: ${totalCost} cents (input: ${inputCost}, output: ${outputCost})`, + ); + + return totalCost; + } + + async calculateAndBillUsage( + modelId: ModelId, + usage: TokenUsage, + workspaceId: string, + ): Promise { + const costInCents = await this.calculateCost(modelId, usage); + + const costInDollars = costInCents / 100; + const creditsUsed = Math.round(costInDollars * DOLLAR_TO_CREDIT_MULTIPLIER); + + this.sendAiTokenUsageEvent(workspaceId, creditsUsed); + } + + private sendAiTokenUsageEvent( + workspaceId: string, + creditsUsed: number, + ): void { + this.workspaceEventEmitter.emitCustomBatchEvent( + BILLING_FEATURE_USED, + [ + { + eventName: BillingMeterEventName.WORKFLOW_NODE_RUN, + value: creditsUsed, + }, + ], + workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/ai/utils/ai-cost.utils.ts b/packages/twenty-server/src/engine/core-modules/ai/utils/ai-cost.utils.ts new file mode 100644 index 00000000000..cff71218f78 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/utils/ai-cost.utils.ts @@ -0,0 +1,7 @@ +/** + * Converts cost in cents to cost in credits + * Formula: credits = cents / 100 * 1000 = cents * 10 + * @param cents - Cost in cents (real cost) + * @returns Cost in credits (end-user cost) + */ +export const convertCentsToCredits = (cents: number): number => cents * 10; diff --git a/packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-model-by-id.ts b/packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-model-by-id.ts new file mode 100644 index 00000000000..e90dd835b69 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-model-by-id.ts @@ -0,0 +1,9 @@ +import { + AI_MODELS, + AIModelConfig, + ModelId, +} from 'src/engine/core-modules/ai/constants/ai-models.const'; + +export const getAIModelById = (modelId: ModelId): AIModelConfig | undefined => { + return AI_MODELS.find((model) => model.modelId === modelId); +}; diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts index ebb23041072..df30fbf16d9 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AiModule } from 'src/engine/core-modules/ai/ai.module'; import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver'; import { BillingAddWorkflowSubscriptionItemCommand } from 'src/engine/core-modules/billing/commands/billing-add-workflow-subscription-item.command'; import { BillingSyncCustomerDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-customer-data.command'; @@ -41,6 +42,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi DomainManagerModule, MessageQueueModule, PermissionsModule, + AiModule, TypeOrmModule.forFeature( [ BillingSubscription, diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts index 612610fc659..fb9ee9e5ada 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts @@ -2,6 +2,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface'; +import { + ModelId, + ModelProvider, +} from 'src/engine/core-modules/ai/constants/ai-models.const'; import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service'; import { ClientConfigController } from './client-config.controller'; @@ -44,6 +48,15 @@ describe('ClientConfigController', () => { }, ], }, + aiModels: [ + { + modelId: 'gpt-4o' as ModelId, + label: 'GPT-4o', + provider: ModelProvider.OPENAI, + inputCostPer1kTokensInCredits: 2.5, + outputCostPer1kTokensInCredits: 10.0, + }, + ], authProviders: { google: true, magicLink: false, @@ -92,8 +105,8 @@ describe('ClientConfigController', () => { const result = await controller.getClientConfig(); - expect(clientConfigService.getClientConfig).toHaveBeenCalled(); expect(result).toEqual(mockClientConfig); + expect(clientConfigService.getClientConfig).toHaveBeenCalled(); }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index 6a179fdcab0..b7c8f35a52a 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -2,6 +2,10 @@ import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface'; +import { + ModelId, + ModelProvider, +} from 'src/engine/core-modules/ai/constants/ai-models.const'; import { BillingTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-trial-period.dto'; import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; @@ -11,6 +15,28 @@ registerEnumType(FeatureFlagKey, { name: 'FeatureFlagKey', }); +registerEnumType(ModelProvider, { + name: 'ModelProvider', +}); + +@ObjectType() +export class ClientAIModelConfig { + @Field(() => String) + modelId: ModelId; + + @Field(() => String) + label: string; + + @Field(() => ModelProvider) + provider: ModelProvider; + + @Field(() => Number) + inputCostPer1kTokensInCredits: number; + + @Field(() => Number) + outputCostPer1kTokensInCredits: number; +} + @ObjectType() class Billing { @Field(() => Boolean) @@ -88,6 +114,9 @@ export class ClientConfig { @Field(() => Billing, { nullable: false }) billing: Billing; + @Field(() => [ClientAIModelConfig]) + aiModels: ClientAIModelConfig[]; + @Field(() => Boolean) signInPrefilled: boolean; diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts index 9f3fa0b246b..b61c3ebc99d 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts @@ -9,6 +9,10 @@ import { DomainManagerService } from 'src/engine/core-modules/domain-manager/ser import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +jest.mock('src/engine/core-modules/ai/constants/ai-models.const', () => ({ + AI_MODELS: [], +})); + describe('ClientConfigService', () => { let service: ClientConfigService; let twentyConfigService: TwentyConfigService; @@ -107,6 +111,7 @@ describe('ClientConfigService', () => { }, ], }, + aiModels: [], authProviders: { google: true, magicLink: false, @@ -164,6 +169,7 @@ describe('ClientConfigService', () => { expect(result.debugMode).toBe(false); expect(result.canManageFeatureFlags).toBe(false); + expect(result.aiModels).toEqual([]); }); it('should handle missing captcha driver', async () => { @@ -180,6 +186,7 @@ describe('ClientConfigService', () => { expect(result.captcha.provider).toBeUndefined(); expect(result.captcha.siteKey).toBe('site-key'); + expect(result.aiModels).toEqual([]); }); it('should handle missing support driver', async () => { @@ -194,6 +201,7 @@ describe('ClientConfigService', () => { const result = await service.getClientConfig(); expect(result.support.supportDriver).toBe(SupportDriver.NONE); + expect(result.aiModels).toEqual([]); }); it('should handle billing enabled with feature flags', async () => { diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts index 88c883ebf09..35743be2580 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts @@ -3,7 +3,15 @@ import { Injectable } from '@nestjs/common'; import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface'; -import { ClientConfig } from 'src/engine/core-modules/client-config/client-config.entity'; +import { + AI_MODELS, + ModelProvider, +} from 'src/engine/core-modules/ai/constants/ai-models.const'; +import { convertCentsToCredits } from 'src/engine/core-modules/ai/utils/ai-cost.utils'; +import { + ClientAIModelConfig, + ClientConfig, +} from 'src/engine/core-modules/client-config/client-config.entity'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -18,6 +26,32 @@ export class ClientConfigService { async getClientConfig(): Promise { const captchaProvider = this.twentyConfigService.get('CAPTCHA_DRIVER'); const supportDriver = this.twentyConfigService.get('SUPPORT_DRIVER'); + const openaiApiKey = this.twentyConfigService.get('OPENAI_API_KEY'); + const anthropicApiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY'); + + const aiModels = AI_MODELS.reduce((acc, model) => { + const isAvailable = + (model.provider === ModelProvider.OPENAI && openaiApiKey) || + (model.provider === ModelProvider.ANTHROPIC && anthropicApiKey); + + if (!isAvailable) { + return acc; + } + + acc.push({ + modelId: model.modelId, + label: model.label, + provider: model.provider, + inputCostPer1kTokensInCredits: convertCentsToCredits( + model.inputCostPer1kTokensInCents, + ), + outputCostPer1kTokensInCredits: convertCentsToCredits( + model.outputCostPer1kTokensInCents, + ), + }); + + return acc; + }, []); const clientConfig: ClientConfig = { billing: { @@ -38,6 +72,7 @@ export class ClientConfigService { }, ], }, + aiModels, authProviders: { google: this.twentyConfigService.get('AUTH_GOOGLE_ENABLED'), magicLink: false, diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index ca292d9e1ba..3f815d30d1d 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -984,6 +984,14 @@ export class ConfigVariables { }) OPENAI_API_KEY: string; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.LLM, + isSensitive: true, + description: 'API key for Anthropic integration', + type: ConfigVariableType.STRING, + }) + ANTHROPIC_API_KEY: string; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.ServerConfig, description: 'Enable or disable multi-workspace support', diff --git a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-step.resolver.ts b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-step.resolver.ts index d6474704667..9b005cbf0e2 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-step.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-step.resolver.ts @@ -1,6 +1,8 @@ import { UseFilters, UseGuards } from '@nestjs/common'; import { Args, Mutation, Resolver } from '@nestjs/graphql'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto'; import { DeleteWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/delete-workflow-version-step-input.dto'; import { SubmitFormStepInput } from 'src/engine/core-modules/workflow/dtos/submit-form-step-input.dto'; @@ -15,6 +17,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service'; +import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service'; @Resolver() @@ -28,6 +31,7 @@ export class WorkflowStepResolver { constructor( private readonly workflowVersionStepWorkspaceService: WorkflowVersionStepWorkspaceService, private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService, + private readonly featureFlagService: FeatureFlagService, ) {} @Mutation(() => WorkflowActionDTO) @@ -36,6 +40,19 @@ export class WorkflowStepResolver { @Args('input') input: CreateWorkflowVersionStepInput, ): Promise { + if (input.stepType === WorkflowActionType.AI_AGENT) { + const isAiEnabled = await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_AI_ENABLED, + workspaceId, + ); + + if (!isAiEnabled) { + throw new Error( + 'AI features are not available in your current workspace. Please contact support to enable them.', + ); + } + } + return this.workflowVersionStepWorkspaceService.createWorkflowVersionStep({ workspaceId, input, diff --git a/packages/twenty-server/src/engine/core-modules/workflow/workflow-api.module.ts b/packages/twenty-server/src/engine/core-modules/workflow/workflow-api.module.ts index db43eeea575..aab34aaf898 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/workflow-api.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/workflow-api.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { WorkflowTriggerController } from 'src/engine/core-modules/workflow/controllers/workflow-trigger.controller'; import { WorkflowBuilderResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-builder.resolver'; import { WorkflowStepResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-step.resolver'; @@ -14,6 +15,7 @@ import { WorkflowTriggerModule } from 'src/modules/workflow/workflow-trigger/wor @Module({ imports: [ + FeatureFlagModule, WorkflowTriggerModule, WorkflowBuilderModule, WorkflowCommonModule, diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index 69d13f70dbd..1569c10e202 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -22,6 +22,7 @@ import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-p import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity'; import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; registerEnumType(WorkspaceActivationStatus, { @@ -121,6 +122,11 @@ export class Workspace { ) workspaceSSOIdentityProviders: Relation; + @OneToMany(() => AgentEntity, (agent) => agent.workspace, { + onDelete: 'CASCADE', + }) + agents: Relation; + @Field() @Column({ default: 1 }) metadataVersion: number; diff --git a/packages/twenty-server/src/engine/guards/feature-flag.guard.ts b/packages/twenty-server/src/engine/guards/feature-flag.guard.ts new file mode 100644 index 00000000000..84408eca551 --- /dev/null +++ b/packages/twenty-server/src/engine/guards/feature-flag.guard.ts @@ -0,0 +1,65 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { GqlExecutionContext } from '@nestjs/graphql'; + +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { TypedReflect } from 'src/utils/typed-reflect'; + +export const FEATURE_FLAG_KEY = 'feature-flag-metadata-args'; + +export function RequireFeatureFlag(featureFlag: FeatureFlagKey) { + return ( + target: object, + propertyKey?: string, + descriptor?: PropertyDescriptor, + ) => { + TypedReflect.defineMetadata( + FEATURE_FLAG_KEY, + featureFlag, + descriptor?.value || target, + ); + + return descriptor; + }; +} + +@Injectable() +export class FeatureFlagGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly featureFlagService: FeatureFlagService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const ctx = GqlExecutionContext.create(context); + const request = ctx.getContext().req; + const workspaceId = request.workspace?.id; + + if (!workspaceId) { + return false; + } + + const featureFlag = this.reflector.get( + FEATURE_FLAG_KEY, + context.getHandler(), + ); + + if (!featureFlag) { + return true; + } + + const isEnabled = await this.featureFlagService.isFeatureEnabled( + featureFlag, + workspaceId, + ); + + if (!isEnabled) { + throw new Error( + `Feature flag "${featureFlag}" is not enabled for this workspace`, + ); + } + + return true; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-execution.service.ts new file mode 100644 index 00000000000..c1eea2f4a82 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent-execution.service.ts @@ -0,0 +1,131 @@ +import { Injectable } from '@nestjs/common'; + +import { createAnthropic } from '@ai-sdk/anthropic'; +import { createOpenAI } from '@ai-sdk/openai'; +import { generateObject } from 'ai'; + +import { + ModelId, + ModelProvider, +} from 'src/engine/core-modules/ai/constants/ai-models.const'; +import { getAIModelById } from 'src/engine/core-modules/ai/utils/get-ai-model-by-id'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +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 { AgentEntity } from './agent.entity'; +import { AgentException, AgentExceptionCode } from './agent.exception'; + +import { convertOutputSchemaToZod } from './utils/convert-output-schema-to-zod'; + +export interface AgentExecutionResult { + object: object; + usage: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; +} + +@Injectable() +export class AgentExecutionService { + constructor(private readonly twentyConfigService: TwentyConfigService) {} + + private getModel = (modelId: ModelId, provider: ModelProvider) => { + switch (provider) { + case ModelProvider.OPENAI: { + const OpenAIProvider = createOpenAI({ + apiKey: this.twentyConfigService.get('OPENAI_API_KEY'), + }); + + return OpenAIProvider(modelId); + } + case ModelProvider.ANTHROPIC: { + const AnthropicProvider = createAnthropic({ + apiKey: this.twentyConfigService.get('ANTHROPIC_API_KEY'), + }); + + return AnthropicProvider(modelId); + } + default: + throw new AgentException( + `Unsupported provider: ${provider}`, + AgentExceptionCode.AGENT_EXECUTION_FAILED, + ); + } + }; + + private async validateApiKey(provider: ModelProvider): Promise { + let apiKey: string | undefined; + + switch (provider) { + case ModelProvider.OPENAI: + apiKey = this.twentyConfigService.get('OPENAI_API_KEY'); + break; + case ModelProvider.ANTHROPIC: + apiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY'); + break; + default: + throw new AgentException( + `Unsupported provider: ${provider}`, + AgentExceptionCode.AGENT_EXECUTION_FAILED, + ); + } + + if (!apiKey) { + throw new AgentException( + `${provider.toUpperCase()} API key not configured`, + AgentExceptionCode.API_KEY_NOT_CONFIGURED, + ); + } + } + + async executeAgent({ + agent, + context, + schema, + }: { + agent: AgentEntity; + context: Record; + schema: OutputSchema; + }): Promise { + try { + const aiModel = getAIModelById(agent.modelId); + + if (!aiModel) { + throw new AgentException( + `AI model with id ${agent.modelId} not found`, + AgentExceptionCode.AGENT_EXECUTION_FAILED, + ); + } + + const provider = aiModel.provider; + + await this.validateApiKey(provider); + + const output = await generateObject({ + model: this.getModel(agent.modelId, provider), + prompt: resolveInput(agent.prompt, context) as string, + schema: convertOutputSchemaToZod(schema), + }); + + return { + object: output.object, + usage: { + promptTokens: output.usage?.promptTokens ?? 0, + completionTokens: output.usage?.completionTokens ?? 0, + totalTokens: output.usage?.totalTokens, + }, + }; + } catch (error) { + if (error instanceof AgentException) { + throw error; + } + + throw new AgentException( + error instanceof Error ? error.message : 'Agent execution failed', + AgentExceptionCode.AGENT_EXECUTION_FAILED, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent.entity.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent.entity.ts new file mode 100644 index 00000000000..e3037ade0b9 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent.entity.ts @@ -0,0 +1,56 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; + +import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +@Entity('agent') +@Index('IDX_AGENT_ID_DELETED_AT', ['id', 'deletedAt']) +export class AgentEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ nullable: false }) + name: string; + + @Column({ nullable: true }) + description: string; + + @Column({ nullable: false, type: 'text' }) + prompt: string; + + @Column({ nullable: false, type: 'varchar' }) + modelId: ModelId; + + @Column({ nullable: true, type: 'jsonb' }) + responseFormat: object; + + @Column({ nullable: false, type: 'uuid' }) + workspaceId: string; + + @ManyToOne(() => Workspace, (workspace) => workspace.agents, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'workspaceId' }) + workspace: Relation; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ type: 'timestamptz' }) + deletedAt?: Date; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent.exception.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent.exception.ts new file mode 100644 index 00000000000..ccf6ed9cbc9 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent.exception.ts @@ -0,0 +1,20 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class AgentException extends CustomException { + declare code: AgentExceptionCode; + constructor(message: string, code: AgentExceptionCode) { + super(message, code); + } +} + +export enum AgentExceptionCode { + AGENT_NOT_FOUND = 'AGENT_NOT_FOUND', + FEATURE_FLAG_INVALID = 'FEATURE_FLAG_INVALID', + AGENT_ALREADY_EXISTS = 'AGENT_ALREADY_EXISTS', + AGENT_EXECUTION_FAILED = 'AGENT_EXECUTION_FAILED', + AGENT_EXECUTION_LIMIT_REACHED = 'AGENT_EXECUTION_LIMIT_REACHED', + AGENT_INVALID_PROMPT = 'AGENT_INVALID_PROMPT', + AGENT_INVALID_MODEL = 'AGENT_INVALID_MODEL', + UNSUPPORTED_MODEL = 'UNSUPPORTED_MODEL', + API_KEY_NOT_CONFIGURED = 'API_KEY_NOT_CONFIGURED', +} diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent.module.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent.module.ts new file mode 100644 index 00000000000..65d80e032e5 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { AiModule } from 'src/engine/core-modules/ai/ai.module'; +import { AuditModule } from 'src/engine/core-modules/audit/audit.module'; +import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module'; + +import { AgentExecutionService } from './agent-execution.service'; +import { AgentEntity } from './agent.entity'; +import { AgentResolver } from './agent.resolver'; +import { AgentService } from './agent.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([AgentEntity, FeatureFlag], 'core'), + AiModule, + ThrottlerModule, + AuditModule, + FeatureFlagModule, + ], + providers: [AgentResolver, AgentService, AgentExecutionService], + exports: [ + AgentService, + AgentExecutionService, + TypeOrmModule.forFeature([AgentEntity], 'core'), + ], +}) +export class AgentModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent.resolver.ts new file mode 100644 index 00000000000..f353c611534 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent.resolver.ts @@ -0,0 +1,66 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { + FeatureFlagGuard, + RequireFeatureFlag, +} from 'src/engine/guards/feature-flag.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +import { AgentService } from './agent.service'; + +import { AgentIdInput } from './dtos/agent-id.input'; +import { AgentDTO } from './dtos/agent.dto'; +import { CreateAgentInput } from './dtos/create-agent.input'; +import { UpdateAgentInput } from './dtos/update-agent.input'; + +@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard) +@Resolver() +export class AgentResolver { + constructor(private readonly agentService: AgentService) {} + + @Query(() => AgentDTO) + @RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED) + async findOneAgent( + @Args('input') { id }: AgentIdInput, + @AuthWorkspace() { id: workspaceId }: Workspace, + ) { + return this.agentService.findOneAgent(id, workspaceId); + } + + @Query(() => [AgentDTO]) + @RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED) + async findManyAgents(@AuthWorkspace() { id: workspaceId }: Workspace) { + return this.agentService.findManyAgents(workspaceId); + } + + @Mutation(() => AgentDTO) + @RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED) + async createOneAgent( + @Args('input') input: CreateAgentInput, + @AuthWorkspace() { id: workspaceId }: Workspace, + ) { + return this.agentService.createOneAgent(input, workspaceId); + } + + @Mutation(() => AgentDTO) + @RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED) + async updateOneAgent( + @Args('input') input: UpdateAgentInput, + @AuthWorkspace() { id: workspaceId }: Workspace, + ) { + return this.agentService.updateOneAgent(input, workspaceId); + } + + @Mutation(() => AgentDTO) + @RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED) + async deleteOneAgent( + @Args('input') { id }: AgentIdInput, + @AuthWorkspace() { id: workspaceId }: Workspace, + ) { + return this.agentService.deleteOneAgent(id, workspaceId); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent.service.ts new file mode 100644 index 00000000000..5cf8817526c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent.service.ts @@ -0,0 +1,88 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const'; + +import { AgentEntity } from './agent.entity'; +import { AgentException, AgentExceptionCode } from './agent.exception'; + +@Injectable() +export class AgentService { + constructor( + @InjectRepository(AgentEntity, 'core') + private readonly agentRepository: Repository, + ) {} + + async findManyAgents(workspaceId: string) { + return this.agentRepository.find({ + where: { workspaceId }, + order: { createdAt: 'DESC' }, + }); + } + + async findOneAgent(id: string, workspaceId: string) { + const agent = await this.agentRepository.findOne({ + where: { id, workspaceId }, + }); + + if (!agent) { + throw new AgentException( + `Agent with id ${id} not found`, + AgentExceptionCode.AGENT_NOT_FOUND, + ); + } + + return agent; + } + + async createOneAgent( + input: { + name: string; + description?: string; + prompt: string; + modelId: ModelId; + responseFormat?: object; + }, + workspaceId: string, + ) { + const agent = this.agentRepository.create({ + ...input, + workspaceId, + }); + + const createdAgent = await this.agentRepository.save(agent); + + return this.findOneAgent(createdAgent.id, workspaceId); + } + + async updateOneAgent( + input: { + id: string; + name?: string; + description?: string; + prompt?: string; + modelId?: ModelId; + responseFormat?: object; + }, + workspaceId: string, + ) { + const agent = await this.findOneAgent(input.id, workspaceId); + + const updatedAgent = await this.agentRepository.save({ + ...agent, + ...input, + }); + + return updatedAgent; + } + + async deleteOneAgent(id: string, workspaceId: string) { + const agent = await this.findOneAgent(id, workspaceId); + + await this.agentRepository.softDelete({ id: agent.id }); + + return agent; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/dtos/agent-id.input.ts b/packages/twenty-server/src/engine/metadata-modules/agent/dtos/agent-id.input.ts new file mode 100644 index 00000000000..3864ec41781 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/agent/dtos/agent-id.input.ts @@ -0,0 +1,9 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@InputType() +export class AgentIdInput { + @Field(() => UUIDScalarType, { description: 'The id of the agent.' }) + id!: string; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/dtos/agent.dto.ts b/packages/twenty-server/src/engine/metadata-modules/agent/dtos/agent.dto.ts new file mode 100644 index 00000000000..4c2cb755287 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/agent/dtos/agent.dto.ts @@ -0,0 +1,45 @@ +import { Field, HideField, ObjectType } from '@nestjs/graphql'; + +import { IsDateString, IsNotEmpty, IsString, IsUUID } from 'class-validator'; +import GraphQLJSON from 'graphql-type-json'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const'; + +@ObjectType('Agent') +export class AgentDTO { + @IsUUID() + @IsNotEmpty() + @Field(() => UUIDScalarType) + id: string; + + @IsString() + @Field() + name: string; + + @IsString() + @Field({ nullable: true }) + description: string; + + @IsString() + @IsNotEmpty() + @Field() + prompt: string; + + @Field(() => String) + modelId: ModelId; + + @Field(() => GraphQLJSON, { nullable: true }) + responseFormat: object; + + @HideField() + workspaceId: string; + + @IsDateString() + @Field() + createdAt: Date; + + @IsDateString() + @Field() + updatedAt: Date; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/dtos/create-agent.input.ts b/packages/twenty-server/src/engine/metadata-modules/agent/dtos/create-agent.input.ts new file mode 100644 index 00000000000..6c3b4ff21b4 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/agent/dtos/create-agent.input.ts @@ -0,0 +1,34 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator'; +import GraphQLJSON from 'graphql-type-json'; + +import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const'; + +@InputType() +export class CreateAgentInput { + @IsString() + @IsNotEmpty() + @Field() + name: string; + + @IsString() + @IsOptional() + @Field({ nullable: true }) + description?: string; + + @IsString() + @IsNotEmpty() + @Field() + prompt: string; + + @IsString() + @IsNotEmpty() + @Field(() => String) + modelId: ModelId; + + @IsObject() + @IsOptional() + @Field(() => GraphQLJSON, { nullable: true }) + responseFormat?: object; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/dtos/update-agent.input.ts b/packages/twenty-server/src/engine/metadata-modules/agent/dtos/update-agent.input.ts new file mode 100644 index 00000000000..5e4466fd1e9 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/agent/dtos/update-agent.input.ts @@ -0,0 +1,46 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { + IsNotEmpty, + IsObject, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; +import GraphQLJSON from 'graphql-type-json'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const'; + +@InputType() +export class UpdateAgentInput { + @IsUUID() + @IsNotEmpty() + @Field(() => UUIDScalarType) + id: string; + + @IsString() + @IsOptional() + @Field() + name?: string; + + @IsString() + @IsOptional() + @Field({ nullable: true }) + description?: string; + + @IsString() + @IsOptional() + @Field() + prompt?: string; + + @IsString() + @IsOptional() + @Field(() => String) + modelId?: ModelId; + + @IsObject() + @IsOptional() + @Field(() => GraphQLJSON, { nullable: true }) + responseFormat?: object; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/utils/convert-output-schema-to-zod.ts b/packages/twenty-server/src/engine/metadata-modules/agent/utils/convert-output-schema-to-zod.ts new file mode 100644 index 00000000000..824b279cfc7 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/agent/utils/convert-output-schema-to-zod.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type'; + +export const convertOutputSchemaToZod = ( + schema: OutputSchema, +): z.ZodObject> => { + const shape: Record = {}; + + for (const [fieldName, field] of Object.entries(schema)) { + if (field.isLeaf) { + let fieldSchema: z.ZodTypeAny; + + switch (field.type) { + case 'TEXT': + fieldSchema = z.string(); + break; + case 'NUMBER': + fieldSchema = z.number(); + break; + case 'BOOLEAN': + fieldSchema = z.boolean(); + break; + case 'DATE': + fieldSchema = z.string().describe('Date-time string'); + break; + default: + throw new Error( + `Unsupported field type for AI agent output: ${field.type}`, + ); + } + + if (field.description) { + fieldSchema = fieldSchema.describe(field.description); + } + + shape[fieldName] = fieldSchema; + } + } + + return z.object(shape); +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts index df3dc6cc7cc..2a1894d2545 100644 --- a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; +import { AgentModule } from 'src/engine/metadata-modules/agent/agent.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'; @@ -16,6 +17,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace- FieldMetadataModule, ObjectMetadataModule, ServerlessFunctionModule, + AgentModule, WorkspaceMetadataVersionModule, WorkspaceMigrationModule, RemoteServerModule, @@ -28,6 +30,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace- FieldMetadataModule, ObjectMetadataModule, ServerlessFunctionModule, + AgentModule, RemoteServerModule, RoleModule, PermissionsModule, diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts index f6ea4ced2a0..ab6c3ffab6e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts @@ -5,6 +5,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { AuditModule } from 'src/engine/core-modules/audit/audit.module'; import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; 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'; @@ -20,6 +21,7 @@ import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverles FileModule, ThrottlerModule, AuditModule, + FeatureFlagModule, ], providers: [ServerlessFunctionService, ServerlessFunctionResolver], exports: [ServerlessFunctionService], diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts index 1a990c62690..f1628e8acc4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts @@ -5,9 +5,9 @@ import { InjectRepository } from '@nestjs/typeorm'; import graphqlTypeJson from 'graphql-type-json'; import { Repository } from 'typeorm'; -import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { FeatureFlagGuard } from 'src/engine/guards/feature-flag.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input'; import { ExecuteServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/execute-serverless-function.input'; @@ -21,13 +21,11 @@ import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; import { serverlessFunctionGraphQLApiExceptionHandler } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils'; -@UseGuards(WorkspaceAuthGuard) +@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard) @Resolver() export class ServerlessFunctionResolver { constructor( private readonly serverlessFunctionService: ServerlessFunctionService, - @InjectRepository(FeatureFlag, 'core') - private readonly featureFlagRepository: Repository, @InjectRepository(ServerlessFunctionEntity, 'core') private readonly serverlessFunctionRepository: Repository, ) {} diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type.ts index 75706b148f3..1d21aa0e2db 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type.ts @@ -5,6 +5,7 @@ export type Leaf = { type?: InputSchemaPropertyType; icon?: string; label?: string; + description?: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any; }; @@ -14,6 +15,7 @@ export type Node = { type?: InputSchemaPropertyType; icon?: string; label?: string; + description?: string; value: OutputSchema; }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.module.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.module.ts index 3fe1617da8d..b16f4c9a3cc 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; +import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module'; import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module'; @@ -11,6 +12,7 @@ import { WorkflowRunnerModule } from 'src/modules/workflow/workflow-runner/workf @Module({ imports: [ + AgentModule, WorkflowSchemaModule, ServerlessFunctionModule, WorkflowRunnerModule, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts index d9cf09ce3be..e0067f02473 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts @@ -8,6 +8,7 @@ import { v4 } from 'uuid'; import { BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA } from 'src/engine/core-modules/serverless/drivers/constants/base-typescript-project-input-schema'; import { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto'; import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto'; +import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; @@ -50,6 +51,7 @@ export class WorkflowVersionStepWorkspaceService { private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly workflowSchemaWorkspaceService: WorkflowSchemaWorkspaceService, private readonly serverlessFunctionService: ServerlessFunctionService, + private readonly agentService: AgentService, @InjectRepository(ObjectMetadataEntity, 'core') private readonly objectMetadataRepository: Repository, private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService, @@ -350,11 +352,13 @@ export class WorkflowVersionStepWorkspaceService { }): Promise { // We don't enrich on the fly for code and HTTP request workflow actions. // For code actions, OutputSchema is computed and updated when testing the serverless function. - // For HTTP requests, OutputSchema is determined by the expamle response input + // For HTTP requests and AI agent, OutputSchema is determined by the expamle response input if ( - [WorkflowActionType.CODE, WorkflowActionType.HTTP_REQUEST].includes( - step.type, - ) + [ + WorkflowActionType.CODE, + WorkflowActionType.HTTP_REQUEST, + WorkflowActionType.AI_AGENT, + ].includes(step.type) ) { return step; } @@ -396,6 +400,17 @@ export class WorkflowVersionStepWorkspaceService { } break; } + case WorkflowActionType.AI_AGENT: { + const agent = await this.agentService.findOneAgent( + step.settings.input.agentId, + workspaceId, + ); + + if (agent) { + await this.agentService.deleteOneAgent(agent.id, workspaceId); + } + break; + } } } @@ -578,6 +593,37 @@ export class WorkflowVersionStepWorkspaceService { }, }; } + case WorkflowActionType.AI_AGENT: { + const newAgent = await this.agentService.createOneAgent( + { + name: 'AI Agent Workflow Step', + description: 'Created automatically for workflow step', + prompt: '', + modelId: 'gpt-4o', + }, + workspaceId, + ); + + if (!isDefined(newAgent)) { + throw new WorkflowVersionStepException( + 'Failed to create AI Agent Step', + WorkflowVersionStepExceptionCode.FAILURE, + ); + } + + return { + id: newStepId, + name: 'AI Agent', + type: WorkflowActionType.AI_AGENT, + valid: false, + settings: { + ...BASE_STEP_DEFINITION, + input: { + agentId: newAgent.id, + }, + }, + }; + } default: throw new WorkflowVersionStepException( `WorkflowActionType '${type}' unknown`, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-executor.factory.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-executor.factory.ts index e0a0579e30f..f7ed3980838 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-executor.factory.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-executor.factory.ts @@ -6,6 +6,7 @@ import { WorkflowStepExecutorException, WorkflowStepExecutorExceptionCode, } from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; +import { AiAgentWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action'; import { CodeWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action'; import { FilterWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/filter.workflow-action'; import { FormWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/form/form.workflow-action'; @@ -29,6 +30,7 @@ export class WorkflowExecutorFactory { private readonly formWorkflowAction: FormWorkflowAction, private readonly filterWorkflowAction: FilterWorkflowAction, private readonly httpRequestWorkflowAction: HttpRequestWorkflowAction, + private readonly aiAgentWorkflowAction: AiAgentWorkflowAction, ) {} get(stepType: WorkflowActionType): WorkflowExecutor { @@ -51,6 +53,8 @@ export class WorkflowExecutorFactory { return this.filterWorkflowAction; case WorkflowActionType.HTTP_REQUEST: return this.httpRequestWorkflowAction; + case WorkflowActionType.AI_AGENT: + return this.aiAgentWorkflowAction; default: throw new WorkflowStepExecutorException( `Workflow step executor not found for step type '${stepType}'`, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module.ts new file mode 100644 index 00000000000..849148d2c72 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { AiDriver } from 'src/engine/core-modules/ai/interfaces/ai.interface'; + +import { AiModule } from 'src/engine/core-modules/ai/ai.module'; +import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; +import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module'; +import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; + +import { AiAgentWorkflowAction } from './ai-agent.workflow-action'; + +@Module({ + imports: [ + AgentModule, + AiModule.forRoot({ + useFactory: () => ({ type: AiDriver.OPENAI }), + }), + TypeOrmModule.forFeature([AgentEntity], 'core'), + ], + providers: [ScopedWorkspaceContextFactory, AiAgentWorkflowAction], + exports: [AiAgentWorkflowAction], +}) +export class AiAgentActionModule {} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts new file mode 100644 index 00000000000..aec5ef3bef3 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface'; + +import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service'; +import { AgentExecutionService } from 'src/engine/metadata-modules/agent/agent-execution.service'; +import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; +import { + AgentException, + AgentExceptionCode, +} from 'src/engine/metadata-modules/agent/agent.exception'; +import { + WorkflowStepExecutorException, + WorkflowStepExecutorExceptionCode, +} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; +import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input'; +import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type'; + +import { isWorkflowAiAgentAction } from './guards/is-workflow-ai-agent-action.guard'; + +@Injectable() +export class AiAgentWorkflowAction implements WorkflowExecutor { + constructor( + private readonly agentExecutionService: AgentExecutionService, + private readonly aiBillingService: AIBillingService, + @InjectRepository(AgentEntity, 'core') + private readonly agentRepository: Repository, + ) {} + + async execute({ + currentStepId, + steps, + context, + }: WorkflowExecutorInput): Promise { + const step = steps.find((step) => step.id === currentStepId); + + if (!step) { + throw new WorkflowStepExecutorException( + 'Step not found', + WorkflowStepExecutorExceptionCode.STEP_NOT_FOUND, + ); + } + + if (!isWorkflowAiAgentAction(step)) { + throw new WorkflowStepExecutorException( + 'Step is not an AI Agent action', + WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE, + ); + } + + const { agentId } = step.settings.input; + const workspaceId = context.workspaceId as string; + + try { + const agent = await this.agentRepository.findOne({ + where: { + id: agentId, + workspaceId, + }, + }); + + if (!agent) { + throw new AgentException( + `Agent with id ${agentId} not found`, + AgentExceptionCode.AGENT_NOT_FOUND, + ); + } + + const executionResult = await this.agentExecutionService.executeAgent({ + agent, + context, + schema: step.settings.outputSchema, + }); + + await this.aiBillingService.calculateAndBillUsage( + agent.modelId, + executionResult.usage, + workspaceId, + ); + + return { result: executionResult.object }; + } catch (error) { + if (error instanceof AgentException) { + return { + error: `${error.message} (${error.code})`, + }; + } + + return { + error: + error instanceof Error ? error.message : 'AI Agent execution failed', + }; + } + } +} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/guards/is-workflow-ai-agent-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/guards/is-workflow-ai-agent-action.guard.ts new file mode 100644 index 00000000000..c9e43b5fad9 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/guards/is-workflow-ai-agent-action.guard.ts @@ -0,0 +1,11 @@ +import { + WorkflowAction, + WorkflowActionType, + WorkflowAiAgentAction, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; + +export const isWorkflowAiAgentAction = ( + action: WorkflowAction, +): action is WorkflowAiAgentAction => { + return action.type === WorkflowActionType.AI_AGENT; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-input.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-input.type.ts new file mode 100644 index 00000000000..f4c53b0bd19 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-input.type.ts @@ -0,0 +1,3 @@ +export type WorkflowAiAgentActionInput = { + agentId: string; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-settings.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-settings.type.ts new file mode 100644 index 00000000000..b95c0c2ef83 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-settings.type.ts @@ -0,0 +1,6 @@ +import { WorkflowAiAgentActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-input.type'; +import { BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; + +export type WorkflowAiAgentActionSettings = BaseWorkflowActionSettings & { + input: WorkflowAiAgentActionInput; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts index 23d454d6ff0..78172621512 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts @@ -1,4 +1,5 @@ import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type'; +import { WorkflowAiAgentActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-settings.type'; import { WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type'; import { WorkflowFilterActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type'; import { WorkflowFormActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type'; @@ -32,4 +33,5 @@ export type WorkflowActionSettings = | WorkflowFindRecordsActionSettings | WorkflowFormActionSettings | WorkflowFilterActionSettings - | WorkflowHttpRequestActionSettings; + | WorkflowHttpRequestActionSettings + | WorkflowAiAgentActionSettings; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts index 6e0fec70da5..6f7bbb1c568 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts @@ -1,3 +1,4 @@ +import { WorkflowAiAgentActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-settings.type'; import { WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type'; import { WorkflowFilterActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type'; import { WorkflowFormActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type'; @@ -21,6 +22,7 @@ export enum WorkflowActionType { FORM = 'FORM', FILTER = 'FILTER', HTTP_REQUEST = 'HTTP_REQUEST', + AI_AGENT = 'AI_AGENT', } type BaseWorkflowAction = { @@ -77,6 +79,11 @@ export type WorkflowHttpRequestAction = BaseWorkflowAction & { settings: WorkflowHttpRequestActionSettings; }; +export type WorkflowAiAgentAction = BaseWorkflowAction & { + type: WorkflowActionType.AI_AGENT; + settings: WorkflowAiAgentActionSettings; +}; + export type WorkflowAction = | WorkflowCodeAction | WorkflowSendEmailAction @@ -86,4 +93,5 @@ export type WorkflowAction = | WorkflowFindRecordsAction | WorkflowFormAction | WorkflowFilterAction - | WorkflowHttpRequestAction; + | WorkflowHttpRequestAction + | WorkflowAiAgentAction; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts index 4186cd678fb..4ae95444500 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; import { WorkflowExecutorFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-executor.factory'; +import { AiAgentActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module'; import { CodeActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/code/code-action.module'; import { FilterActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/filter-action.module'; import { FormActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/form/form-action.module'; @@ -24,6 +26,8 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow BillingModule, FilterActionModule, HttpRequestActionModule, + AiAgentActionModule, + FeatureFlagModule, ], providers: [ WorkflowExecutorWorkspaceService, diff --git a/packages/twenty-server/src/utils/typed-reflect.ts b/packages/twenty-server/src/utils/typed-reflect.ts index a871540f90b..823df5f1dd1 100644 --- a/packages/twenty-server/src/utils/typed-reflect.ts +++ b/packages/twenty-server/src/utils/typed-reflect.ts @@ -3,6 +3,7 @@ import 'reflect-metadata'; import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface'; import { WorkspaceEntityDuplicateCriteria } from 'src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { ConfigVariablesMetadataMap } from 'src/engine/core-modules/twenty-config/decorators/config-variables-metadata.decorator'; export interface ReflectMetadataTypeMap { @@ -16,6 +17,7 @@ export interface ReflectMetadataTypeMap { ['workspace:duplicate-criteria-metadata-args']: WorkspaceEntityDuplicateCriteria[]; ['config-variables']: ConfigVariablesMetadataMap; ['workspace:is-searchable-metadata-args']: boolean; + ['feature-flag-metadata-args']: FeatureFlagKey; } export class TypedReflect { diff --git a/yarn.lock b/yarn.lock index 06bdf67f97d..1e94dc983b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24,6 +24,18 @@ __metadata: languageName: node linkType: hard +"@ai-sdk/anthropic@npm:^1.2.12": + version: 1.2.12 + resolution: "@ai-sdk/anthropic@npm:1.2.12" + dependencies: + "@ai-sdk/provider": "npm:1.1.3" + "@ai-sdk/provider-utils": "npm:2.2.8" + peerDependencies: + zod: ^3.0.0 + checksum: 10c0/da13e1ed3c03efe207dbb0fd5fe9f399e4119e6687ec1096418a33a7eeea3c5f912a51c74b185bba3c203b15ee0c1b9cdf649711815ff8e769e31af266ac00fb + languageName: node + linkType: hard + "@ai-sdk/openai@npm:^1.3.22": version: 1.3.22 resolution: "@ai-sdk/openai@npm:1.3.22" @@ -56506,6 +56518,7 @@ __metadata: version: 0.0.0-use.local resolution: "twenty-server@workspace:packages/twenty-server" dependencies: + "@ai-sdk/anthropic": "npm:^1.2.12" "@ai-sdk/openai": "npm:^1.3.22" "@blocknote/server-util": "npm:^0.31.1" "@clickhouse/client": "npm:^1.11.0"