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:
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
import { ClientAiModelConfig } from '~/generated-metadata/graphql';
|
||||
|
||||
export const aiModelsState = createState<ClientAiModelConfig[]>({
|
||||
key: 'aiModelsState',
|
||||
defaultValue: [],
|
||||
});
|
||||
@ -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)}
|
||||
|
||||
@ -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'];
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)}`,
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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));
|
||||
};
|
||||
@ -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';
|
||||
}
|
||||
};
|
||||
@ -28,4 +28,9 @@ export const OTHER_ACTIONS: Array<{
|
||||
type: 'HTTP_REQUEST',
|
||||
icon: 'IconWorld',
|
||||
},
|
||||
{
|
||||
label: 'AI Agent',
|
||||
type: 'AI_AGENT',
|
||||
icon: 'IconBrain',
|
||||
},
|
||||
];
|
||||
|
||||
@ -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;
|
||||
});
|
||||
};
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user