feat: Add AI Agent workflow action node (#12650)

https://github.com/user-attachments/assets/8593e488-cb00-4fd2-b903-5ba5766e0254

---------

Co-authored-by: Antoine Moreaux <moreaux.antoine@gmail.com>
Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Baptiste Devessier <baptiste@devessier.fr>
Co-authored-by: Joseph Chiang <josephj6802@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Guillim <guillim@users.noreply.github.com>
Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Marie <51697796+ijreilly@users.noreply.github.com>
Co-authored-by: Naifer <161821705+omarNaifer12@users.noreply.github.com>
Co-authored-by: prastoin <paul@twenty.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
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 <raphael.bosi@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Marty <91310557+real-marty@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Co-authored-by: Weiko <corentin@twenty.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: nitin <142569587+ehconitin@users.noreply.github.com>
This commit is contained in:
Abdul Rahman
2025-06-23 01:12:04 +05:30
committed by GitHub
parent 22e126869c
commit 65df511179
75 changed files with 2268 additions and 30 deletions

View File

@ -57,6 +57,23 @@ export type AdminPanelWorkerQueueHealth = {
status: AdminPanelHealthServiceStatus;
};
export type Agent = {
__typename?: 'Agent';
createdAt: Scalars['DateTime']['output'];
description?: Maybe<Scalars['String']['output']>;
id: Scalars['UUID']['output'];
modelId: Scalars['String']['output'];
name: Scalars['String']['output'];
prompt: Scalars['String']['output'];
responseFormat?: Maybe<Scalars['JSON']['output']>;
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<ClientAiModelConfig>;
analyticsEnabled: Scalars['Boolean']['output'];
api: ApiConfig;
authProviders: AuthProviders;
@ -413,6 +440,14 @@ export type ConfigVariablesOutput = {
groups: Array<ConfigVariablesGroupData>;
};
export type CreateAgentInput = {
description?: InputMaybe<Scalars['String']['input']>;
modelId: Scalars['String']['input'];
name: Scalars['String']['input'];
prompt: Scalars['String']['input'];
responseFormat?: InputMaybe<Scalars['JSON']['input']>;
};
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<RemoteTable>;
findManyAgents: Array<Agent>;
findManyRemoteServersByType: Array<RemoteServer>;
findManyServerlessFunctions: Array<ServerlessFunction>;
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<Scalars['UUID']['input']>;
};
export type UpdateAgentInput = {
description?: InputMaybe<Scalars['String']['input']>;
id: Scalars['UUID']['input'];
modelId: Scalars['String']['input'];
name: Scalars['String']['input'];
prompt: Scalars['String']['input'];
responseFormat?: InputMaybe<Scalars['JSON']['input']>;
};
export type UpdateFieldInput = {
defaultValue?: InputMaybe<Scalars['JSON']['input']>;
description?: InputMaybe<Scalars['String']['input']>;

View File

@ -49,6 +49,23 @@ export type AdminPanelWorkerQueueHealth = {
status: AdminPanelHealthServiceStatus;
};
export type Agent = {
__typename?: 'Agent';
createdAt: Scalars['DateTime'];
description?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
modelId: Scalars['String'];
name: Scalars['String'];
prompt: Scalars['String'];
responseFormat?: Maybe<Scalars['JSON']>;
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<ClientAiModelConfig>;
analyticsEnabled: Scalars['Boolean'];
api: ApiConfig;
authProviders: AuthProviders;
@ -405,6 +432,14 @@ export type ConfigVariablesOutput = {
groups: Array<ConfigVariablesGroupData>;
};
export type CreateAgentInput = {
description?: InputMaybe<Scalars['String']>;
modelId: Scalars['String'];
name: Scalars['String'];
prompt: Scalars['String'];
responseFormat?: InputMaybe<Scalars['JSON']>;
};
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<Agent>;
findManyServerlessFunctions: Array<ServerlessFunction>;
findOneAgent: Agent;
findOneServerlessFunction: ServerlessFunction;
findWorkspaceFromInviteHash: Workspace;
findWorkspaceInvitations: Array<WorkspaceInvitation>;
@ -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<Scalars['UUID']>;
};
export type UpdateAgentInput = {
description?: InputMaybe<Scalars['String']>;
id: Scalars['UUID'];
modelId: Scalars['String'];
name: Scalars['String'];
prompt: Scalars['String'];
responseFormat?: InputMaybe<Scalars['JSON']>;
};
export type UpdateFieldInput = {
defaultValue?: InputMaybe<Scalars['JSON']>;
description?: InputMaybe<Scalars['String']>;
@ -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<any> | 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<GetMeteredPr
export const GetClientConfigDocument = gql`
query GetClientConfig {
clientConfig {
aiModels {
modelId
label
provider
inputCostPer1kTokensInCredits
outputCostPer1kTokensInCredits
}
billing {
isBillingEnabled
billingUrl
@ -6426,6 +6521,84 @@ export function useUpdateWorkflowVersionStepMutation(baseOptions?: Apollo.Mutati
export type UpdateWorkflowVersionStepMutationHookResult = ReturnType<typeof useUpdateWorkflowVersionStepMutation>;
export type UpdateWorkflowVersionStepMutationResult = Apollo.MutationResult<UpdateWorkflowVersionStepMutation>;
export type UpdateWorkflowVersionStepMutationOptions = Apollo.BaseMutationOptions<UpdateWorkflowVersionStepMutation, UpdateWorkflowVersionStepMutationVariables>;
export const UpdateOneAgentDocument = gql`
mutation UpdateOneAgent($input: UpdateAgentInput!) {
updateOneAgent(input: $input) {
id
name
description
prompt
modelId
responseFormat
}
}
`;
export type UpdateOneAgentMutationFn = Apollo.MutationFunction<UpdateOneAgentMutation, UpdateOneAgentMutationVariables>;
/**
* __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<UpdateOneAgentMutation, UpdateOneAgentMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpdateOneAgentMutation, UpdateOneAgentMutationVariables>(UpdateOneAgentDocument, options);
}
export type UpdateOneAgentMutationHookResult = ReturnType<typeof useUpdateOneAgentMutation>;
export type UpdateOneAgentMutationResult = Apollo.MutationResult<UpdateOneAgentMutation>;
export type UpdateOneAgentMutationOptions = Apollo.BaseMutationOptions<UpdateOneAgentMutation, UpdateOneAgentMutationVariables>;
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<FindOneAgentQuery, FindOneAgentQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<FindOneAgentQuery, FindOneAgentQueryVariables>(FindOneAgentDocument, options);
}
export function useFindOneAgentLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<FindOneAgentQuery, FindOneAgentQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<FindOneAgentQuery, FindOneAgentQueryVariables>(FindOneAgentDocument, options);
}
export type FindOneAgentQueryHookResult = ReturnType<typeof useFindOneAgentQuery>;
export type FindOneAgentLazyQueryHookResult = ReturnType<typeof useFindOneAgentLazyQuery>;
export type FindOneAgentQueryResult = Apollo.QueryResult<FindOneAgentQuery, FindOneAgentQueryVariables>;
export const SubmitFormStepDocument = gql`
mutation SubmitFormStep($input: SubmitFormStepInput!) {
submitFormStep(input: $input)

View File

@ -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,

View File

@ -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

View File

@ -0,0 +1,7 @@
import { createState } from 'twenty-ui/utilities';
import { ClientAiModelConfig } from '~/generated-metadata/graphql';
export const aiModelsState = createState<ClientAiModelConfig[]>({
key: 'aiModelsState',
defaultValue: [],
});

View File

@ -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 (
<RightDrawerStepListContainer>
@ -33,7 +34,7 @@ export const CommandMenuWorkflowSelectActionContent = ({
<RightDrawerWorkflowSelectStepTitle>
Other
</RightDrawerWorkflowSelectStepTitle>
{OTHER_ACTIONS.map((action) => (
{filteredOtherActions.map((action) => (
<MenuItemCommand
key={action.type}
LeftIcon={getIcon(action.icon)}

View File

@ -1,5 +1,6 @@
import {
workflowActionSchema,
workflowAiAgentActionSchema,
workflowAiAgentActionSettingsSchema,
workflowCodeActionSchema,
workflowCodeActionSettingsSchema,
workflowCreateRecordActionSchema,
@ -72,7 +73,23 @@ export type WorkflowHttpRequestAction = z.infer<
typeof workflowHttpRequestActionSchema
>;
export type WorkflowAction = z.infer<typeof workflowActionSchema>;
export type WorkflowAiAgentActionSettings = z.infer<
typeof workflowAiAgentActionSettingsSchema
>;
export type WorkflowAiAgentAction = z.infer<typeof workflowAiAgentActionSchema>;
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'];

View File

@ -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

View File

@ -64,6 +64,13 @@ export const WorkflowDiagramStepNodeIcon = ({
</StyledStepNodeLabelIconContainer>
);
}
case 'AI_AGENT': {
return (
<StyledStepNodeLabelIconContainer>
<Icon size={theme.icon.size.md} color={theme.color.pink} />
</StyledStepNodeLabelIconContainer>
);
}
default: {
return (
<StyledStepNodeLabelIconContainer>

View File

@ -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 (
<WorkflowEditActionAiAgent
key={stepId}
action={stepDefinition.definition}
actionOptions={{
readonly: true,
}}
/>
);
}
}
}
}

View File

@ -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 (
<WorkflowEditActionAiAgent
key={stepId}
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
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)}`,
);
};

View File

@ -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 ? (
<RightDrawerSkeletonLoader />
) : (
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate?.({ ...action, name: newName });
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={actionOptions.readonly}
/>
<WorkflowStepBody>
<div>
<Select
dropdownId="select-model"
label={t`AI Model`}
options={modelOptions}
value={formValues.modelId}
onChange={(value) => handleFieldChange('modelId', value)}
disabled={actionOptions.readonly || noModelsAvailable}
emptyOption={{
label: t`No AI models available`,
value: '',
}}
/>
{noModelsAvailable && (
<StyledErrorMessage>
{t`You haven't configured any model provider. Please set up an API Key in your instance's admin panel or as an environment variable.`}
</StyledErrorMessage>
)}
</div>
<FormTextFieldInput
key={`prompt-${formValues.modelId ? action.id : 'empty'}`}
label={t`Instructions for AI`}
placeholder={t`Describe what you want the AI to do...`}
readonly={actionOptions.readonly}
defaultValue={formValues.prompt}
onChange={(value) => handleFieldChange('prompt', value)}
VariablePicker={WorkflowVariablePicker}
multiline
/>
<WorkflowOutputSchemaBuilder
fields={outputFields}
onChange={handleOutputSchemaChange}
readonly={actionOptions.readonly}
/>
</WorkflowStepBody>
</>
);
};

View File

@ -0,0 +1,32 @@
import { Select } from '@/ui/input/components/Select';
import { InputSchemaPropertyType } from '@/workflow/types/InputSchema';
import { t } from '@lingui/core/macro';
import { OUTPUT_FIELD_TYPE_OPTIONS } from '../constants/output-field-type-options';
type WorkflowOutputFieldTypeSelectorProps = {
value?: InputSchemaPropertyType;
onChange: (value: InputSchemaPropertyType) => void;
disabled?: boolean;
dropdownId: string;
};
export const WorkflowOutputFieldTypeSelector = ({
value,
onChange,
disabled,
dropdownId,
}: WorkflowOutputFieldTypeSelectorProps) => {
return (
<Select
dropdownId={dropdownId}
label="Field Type"
options={OUTPUT_FIELD_TYPE_OPTIONS.map((option) => ({
...option,
label: t(option.label),
}))}
value={value}
onChange={onChange}
disabled={disabled}
/>
);
};

View File

@ -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<OutputSchemaField>) => {
onChange(
fields.map((field) =>
field.id === id ? { ...field, ...updates } : field,
),
);
};
return (
<StyledOutputSchemaContainer>
<InputLabel>{t`AI Response Schema`}</InputLabel>
{fields.length === 0 && (
<StyledOutputSchemaFieldContainer>
<StyledMessageContentContainer>
<StyledMessageDescription data-testid="empty-output-schema-message-description">
{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.`}
</StyledMessageDescription>
</StyledMessageContentContainer>
</StyledOutputSchemaFieldContainer>
)}
{fields.length > 0 && (
<StyledFieldsContainer>
{fields.map((field, index) => {
const fieldNumber = index + 1;
return (
<StyledOutputSchemaFieldContainer key={field.id}>
<StyledSettingsHeader>
<StyledTitleContainer>
<span>{t`Output Field ${fieldNumber}`}</span>
</StyledTitleContainer>
<StyledCloseButtonContainer>
{!readonly && (
<LightIconButton
testId="close-button"
Icon={IconTrash}
size="small"
accent="secondary"
onClick={() => removeField(field.id)}
/>
)}
</StyledCloseButtonContainer>
</StyledSettingsHeader>
<StyledSettingsContent>
<FormFieldInputContainer>
<FormTextFieldInput
label={t`Field Name`}
placeholder={t`e.g., summary, status, count`}
defaultValue={field.name}
onChange={(value) =>
updateField(field.id, { name: value })
}
readonly={readonly}
/>
</FormFieldInputContainer>
<FormFieldInputContainer>
<WorkflowOutputFieldTypeSelector
onChange={(value) =>
updateField(field.id, { type: value })
}
value={field.type}
disabled={readonly}
dropdownId={`output-field-type-selector-${field.id}`}
/>
</FormFieldInputContainer>
<FormFieldInputContainer>
<FormTextFieldInput
label={t`Description`}
placeholder={t`Brief explanation of this output field`}
defaultValue={field.description}
onChange={(value) =>
updateField(field.id, { description: value })
}
readonly={readonly}
/>
</FormFieldInputContainer>
</StyledSettingsContent>
</StyledOutputSchemaFieldContainer>
);
})}
</StyledFieldsContainer>
)}
{!readonly && (
<StyledAddFieldButton onClick={addField}>
<IconPlus size={theme.icon.size.sm} />
{t`Add Output Field`}
</StyledAddFieldButton>
)}
</StyledOutputSchemaContainer>
);
};

View File

@ -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,
},
];

View File

@ -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
}
}
`;

View File

@ -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
}
}
`;

View File

@ -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<AgentFormValues>({
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<AgentFormValues>) => {
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,
};
};

View File

@ -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<OutputSchemaField[]>(
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<BaseOutputSchema>(
(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,
};
};

View File

@ -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<string>[] => {
const aiModels = useRecoilValue(aiModelsState);
return aiModels
.map((model) => ({
value: model.modelId,
label: `${model.label} (${model.provider})`,
}))
.sort((a, b) => a.label.localeCompare(b.label));
};

View File

@ -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';
}
};

View File

@ -28,4 +28,9 @@ export const OTHER_ACTIONS: Array<{
type: 'HTTP_REQUEST',
icon: 'IconWorld',
},
{
label: 'AI Agent',
type: 'AI_AGENT',
icon: 'IconBrain',
},
];

View File

@ -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;
});
};

View File

@ -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}`);
}

View File

@ -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}`);
}

View File

@ -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 = {

View File

@ -5,6 +5,7 @@ import {
} from '~/generated/graphql';
export const mockedClientConfig: ClientConfig = {
aiModels: [],
signInPrefilled: true,
isMultiWorkspaceEnabled: false,
isEmailVerificationRequired: false,

View File

@ -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",

View File

@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateAgentTable1747401483136 implements MigrationInterface {
name = 'CreateAgentTable1747401483136';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

View File

@ -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')

View File

@ -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],
};
}
}

View File

@ -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,
},
];

View File

@ -0,0 +1,2 @@
// Configuration: $0.001 = 1 credit
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1000; // 1 / 0.001 = 1000 credits per dollar

View File

@ -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<WorkspaceEventEmitter>;
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>(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',
);
});
});
});

View File

@ -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<number> {
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<void> {
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<BillingUsageEvent>(
BILLING_FEATURE_USED,
[
{
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
value: creditsUsed,
},
],
workspaceId,
);
}
}

View File

@ -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;

View File

@ -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);
};

View File

@ -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,

View File

@ -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();
});
});
});

View File

@ -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;

View File

@ -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 () => {

View File

@ -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<ClientConfig> {
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<ClientAIModelConfig[]>((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,

View File

@ -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',

View File

@ -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<WorkflowActionDTO> {
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,

View File

@ -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,

View File

@ -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<WorkspaceSSOIdentityProvider[]>;
@OneToMany(() => AgentEntity, (agent) => agent.workspace, {
onDelete: 'CASCADE',
})
agents: Relation<AgentEntity[]>;
@Field()
@Column({ default: 1 })
metadataVersion: number;

View File

@ -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<boolean> {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
const workspaceId = request.workspace?.id;
if (!workspaceId) {
return false;
}
const featureFlag = this.reflector.get<FeatureFlagKey>(
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;
}
}

View File

@ -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<void> {
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<string, unknown>;
schema: OutputSchema;
}): Promise<AgentExecutionResult> {
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,
);
}
}
}

View File

@ -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<Workspace>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date;
}

View File

@ -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',
}

View File

@ -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 {}

View File

@ -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);
}
}

View File

@ -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<AgentEntity>,
) {}
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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<Record<string, z.ZodTypeAny>> => {
const shape: Record<string, z.ZodTypeAny> = {};
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);
};

View File

@ -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,

View File

@ -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],

View File

@ -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<FeatureFlag>,
@InjectRepository(ServerlessFunctionEntity, 'core')
private readonly serverlessFunctionRepository: Repository<ServerlessFunctionEntity>,
) {}

View File

@ -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;
};

View File

@ -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,

View File

@ -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<ObjectMetadataEntity>,
private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService,
@ -350,11 +352,13 @@ export class WorkflowVersionStepWorkspaceService {
}): Promise<WorkflowAction> {
// 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`,

View File

@ -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}'`,

View File

@ -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 {}

View File

@ -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<AgentEntity>,
) {}
async execute({
currentStepId,
steps,
context,
}: WorkflowExecutorInput): Promise<WorkflowExecutorOutput> {
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',
};
}
}
}

View File

@ -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;
};

View File

@ -0,0 +1,3 @@
export type WorkflowAiAgentActionInput = {
agentId: string;
};

View File

@ -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;
};

View File

@ -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;

View File

@ -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;

View File

@ -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,

View File

@ -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 {

View File

@ -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"