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

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