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 74cc90eb1..906b42c39 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 ad41cef42..01b468fb3 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 3840df974..a5de390c8 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 bbdd889d6..b57dd3822 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 000000000..65235a603 --- /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 8dd2abb85..1aafec1f9 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 44793cb92..0b92edfe0 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 782e1099b..d7dd470c0 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 32fb5fb37..9929c7711 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 8c6514dce..e2e316a13 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 000000000..42b15756a --- /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 000000000..3973c8ce6 --- /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 000000000..ab7d1a5e5 --- /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 000000000..579792600 --- /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 000000000..b4bb01adb --- /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 000000000..9c3607a90 --- /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 000000000..76f40b092 --- /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 000000000..04686b9ef --- /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 000000000..211ac2f7a --- /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 b2c738ecf..927ebba56 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 000000000..347507f1e --- /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 9bf7078e0..f86b119e1 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 40e43b55b..7ae50765f 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 fe3fee273..13652d1c1 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 5ffccbd41..459b281fb 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 6ce009a97..8ec5eb187 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 000000000..dcf795b9f --- /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 99cb7b322..384dfd91b 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 b5816aba0..524b0603e 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 000000000..eb2218880 --- /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 000000000..6ec65a06b --- /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 000000000..eeed3b492 --- /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 000000000..3bf3b3530 --- /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 000000000..cff71218f --- /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 000000000..e90dd835b --- /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 ebb230410..df30fbf16 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 612610fc6..fb9ee9e5a 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 6a179fdca..b7c8f35a5 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 9f3fa0b24..b61c3ebc9 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 88c883ebf..35743be25 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 ca292d9e1..3f815d30d 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 d64747046..9b005cbf0 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 db43eeea5..aab34aaf8 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 69d13f70d..1569c10e2 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 000000000..84408eca5 --- /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 000000000..c1eea2f4a --- /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 000000000..e3037ade0 --- /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 000000000..ccf6ed9cb --- /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 000000000..65d80e032 --- /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 000000000..f353c6115 --- /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 000000000..5cf881752 --- /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 000000000..3864ec417 --- /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 000000000..4c2cb7552 --- /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 000000000..6c3b4ff21 --- /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 000000000..5e4466fd1 --- /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 000000000..824b279cf --- /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 df3dc6cc7..2a1894d25 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 f6ea4ced2..ab6c3ffab 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 1a990c626..f1628e8ac 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 75706b148..1d21aa0e2 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 3fe1617da..b16f4c9a3 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 d9cf09ce3..e0067f024 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 e0a0579e3..f7ed39808 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 000000000..849148d2c --- /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 000000000..aec5ef3be --- /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 000000000..c9e43b5fa --- /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 000000000..f4c53b0bd --- /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 000000000..b95c0c2ef --- /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 23d454d6f..781726215 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 6e0fec70d..6f7bbb1c5 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 4186cd678..4ae954445 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 a871540f9..823df5f1d 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 06bdf67f9..1e94dc983 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"