[AI] agent node prompt tab new design + refactor (#19012)

This commit is contained in:
nitin
2026-04-02 13:54:58 +05:30
committed by GitHub
parent 19c710b87f
commit ade6ed9c32
28 changed files with 786 additions and 475 deletions
@@ -11,17 +11,13 @@ import { AIChatEditorFocusEffect } from '@/ai/components/internal/AIChatEditorFo
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
import { useAIChatEditor } from '@/ai/hooks/useAIChatEditor';
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { agentChatUserSelectedModelState } from '@/ai/states/agentChatUserSelectedModelState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { getModelIcon } from '@/settings/admin-panel/ai/utils/getModelIcon';
import { Select } from '@/ui/input/components/Select';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { type SelectOption } from 'twenty-ui/input';
const StyledInputArea = styled.div<{ isMobile: boolean }>`
align-items: flex-end;
@@ -110,9 +106,18 @@ const StyledRightButtonsContainer = styled.div`
export const AIChatEditorSection = () => {
const isMobile = useIsMobile();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const aiModels = useAtomStateValue(aiModelsState);
const { enabledModels } = useWorkspaceAiModelAvailability();
const { options, pinnedOption } = useAiModelOptions({
variant: 'pinned-default',
});
const smartModelOptions: SelectOption<string | null>[] = options;
const defaultPinnedOption: SelectOption<string | null> | undefined =
pinnedOption
? {
...pinnedOption,
value: null,
}
: undefined;
const setAgentChatUserSelectedModel = useSetAtomState(
agentChatUserSelectedModelState,
);
@@ -120,36 +125,6 @@ export const AIChatEditorSection = () => {
const { editor, handleSendAndClear } = useAIChatEditor();
const workspaceSmartModel = aiModels.find(
(model) => model.modelId === currentWorkspace?.smartModel,
);
const resolvedDefaultModelId = enabledModels.find(
(model) =>
model.label === workspaceSmartModel?.label &&
model.providerName === workspaceSmartModel?.providerName,
)?.modelId;
const defaultPinnedOption = workspaceSmartModel
? {
value: null as string | null,
label: workspaceSmartModel.label,
Icon: getModelIcon(
workspaceSmartModel.modelFamily,
workspaceSmartModel.providerName,
),
contextualText: t`default`,
}
: undefined;
const smartModelOptions = enabledModels
.filter((model) => model.modelId !== resolvedDefaultModelId)
.map((model) => ({
value: model.modelId as string | null,
label: model.label,
Icon: getModelIcon(model.modelFamily, model.providerName),
}));
return (
<>
<AIChatEditorFocusEffect editor={editor} />
@@ -1,13 +1,13 @@
import { styled } from '@linaria/react';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconBrandX, IconWorld } from 'twenty-ui/display';
import { Checkbox } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext } from 'react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledCheckboxContainer = styled.div<{ disabled: boolean }>`
@@ -15,8 +15,9 @@ const StyledCheckboxContainer = styled.div<{ disabled: boolean }>`
border-radius: ${themeCssVariables.border.radius.sm};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
height: ${themeCssVariables.spacing[8]};
justify-content: space-between;
padding: ${themeCssVariables.spacing[1]};
padding-inline: ${themeCssVariables.spacing[2]};
transition: background-color
calc(${themeCssVariables.animation.duration.normal} * 1s) ease;
@@ -1,27 +1,68 @@
import { type SelectOption } from 'twenty-ui/input';
import { t } from '@lingui/core/macro';
import { isAutoSelectModelId } from 'twenty-shared/utils';
import { type SelectOption } from 'twenty-ui/input';
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { getModelIcon } from '@/settings/admin-panel/ai/utils/getModelIcon';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useAiModelOptions = (): SelectOption<string>[] => {
const aiModels = useAtomStateValue(aiModelsState);
const { isModelEnabled } = useWorkspaceAiModelAvailability();
type UseAiModelOptionsVariant = 'all' | 'pinned-default';
return aiModels
.filter(
(model) => !model.isDeprecated && isModelEnabled(model.modelId, model),
)
type UseAiModelOptionsOptions = {
variant?: UseAiModelOptionsVariant;
};
export const useAiModelOptions = ({
variant = 'all',
}: UseAiModelOptionsOptions = {}): {
options: SelectOption<string>[];
pinnedOption?: SelectOption<string>;
} => {
const aiModels = useAtomStateValue(aiModelsState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { enabledModels } = useWorkspaceAiModelAvailability();
const workspaceSmartModel = aiModels.find(
(model) => model.modelId === currentWorkspace?.smartModel,
);
const resolvedDefaultModelId = enabledModels.find(
(model) =>
model.label === workspaceSmartModel?.label &&
model.providerName === workspaceSmartModel?.providerName,
)?.modelId;
const allOptions = enabledModels
.map((model) => ({
value: model.modelId,
label: isAutoSelectModelId(model.modelId)
? model.label
: model.modelFamilyLabel
? `${model.label} (${model.modelFamilyLabel})`
: model.label,
label: model.label,
Icon: getModelIcon(model.modelFamily, model.providerName),
}))
.sort((a, b) => a.label.localeCompare(b.label));
const pinnedOption = workspaceSmartModel
? {
value: resolvedDefaultModelId ?? workspaceSmartModel.modelId,
label: workspaceSmartModel.label,
Icon: getModelIcon(
workspaceSmartModel.modelFamily,
workspaceSmartModel.providerName,
),
contextualText: t`default`,
}
: undefined;
const options =
variant === 'pinned-default' && resolvedDefaultModelId
? allOptions.filter((model) => model.value !== resolvedDefaultModelId)
: allOptions;
return {
options,
pinnedOption: variant === 'pinned-default' ? pinnedOption : undefined,
};
};
export const useAiModelLabel = (
@@ -0,0 +1,30 @@
import { type AgentResponseSchema } from 'twenty-shared/ai';
import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow';
export const agentResponseSchemaToOutputSchema = (
schema: AgentResponseSchema | undefined,
): BaseOutputSchemaV2 => {
if (!schema?.properties || Object.keys(schema.properties).length === 0) {
return {
response: {
isLeaf: true,
type: 'string',
label: 'Response',
value: null,
},
};
}
const outputSchema: BaseOutputSchemaV2 = {};
for (const [fieldName, field] of Object.entries(schema.properties)) {
outputSchema[fieldName] = {
isLeaf: true,
type: field.type,
label: fieldName,
value: null,
};
}
return outputSchema;
};
@@ -0,0 +1,9 @@
import { type OutputSchemaField } from '@/ai/constants/OutputFieldTypeOptions';
import { v4 } from 'uuid';
export const createDefaultOutputSchemaField = (): OutputSchemaField => ({
id: v4(),
name: '',
description: '',
type: 'string',
});
@@ -0,0 +1,35 @@
import { type OutputSchemaField } from '@/ai/constants/OutputFieldTypeOptions';
import {
type AgentResponseFieldType,
type AgentResponseSchema,
} from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
export const fieldsToSchema = (
fields: OutputSchemaField[],
): AgentResponseSchema => {
const properties: Record<
string,
{ type: AgentResponseFieldType; description?: string }
> = {};
const required: string[] = [];
for (const field of fields) {
if (!field.name.trim() || !isDefined(field.type)) {
continue;
}
properties[field.name] = {
type: field.type,
description: field.description || field.name,
};
required.push(field.name);
}
return {
type: 'object' as const,
properties,
required,
additionalProperties: false as const,
};
};
@@ -0,0 +1,17 @@
import { type OutputSchemaField } from '@/ai/constants/OutputFieldTypeOptions';
import { type AgentResponseSchema } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
export const schemaToFields = (
schema: AgentResponseSchema,
): OutputSchemaField[] => {
if (!isDefined(schema.properties)) return [];
return Object.entries(schema.properties).map(([key, field]) => ({
id: v4(),
name: key,
description: field.description || '',
type: field.type,
}));
};
@@ -17,7 +17,7 @@ type FormFieldInputInnerContainerProps = {
const StyledFormFieldInputInnerContainer = styled.div<
Omit<FormFieldInputInnerContainerProps, 'formFieldInputInstanceId'>
>`
align-items: center;
align-items: ${({ multiline }) => (multiline ? 'flex-start' : 'center')};
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-bottom-left-radius: ${themeCssVariables.border.radius.sm};
@@ -9,16 +9,16 @@ const StyledEditor = styled.div<{
}>`
box-sizing: border-box;
display: flex;
height: 100%;
padding-right: ${({ multiline }) =>
multiline ? themeCssVariables.spacing[4] : '0'};
multiline ? themeCssVariables.spacing[8] : '0'};
width: 100%;
.editor-content {
width: 100%;
}
.tiptap {
align-items: ${({ multiline }) => (multiline ? 'top' : 'center')};
align-items: ${({ multiline }) => (multiline ? 'flex-start' : 'center')};
border: none !important;
box-sizing: border-box;
color: ${({ readonly }) =>
@@ -1,15 +1,15 @@
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { WidgetSettingsFooter } from '@/side-panel/pages/page-layout/components/WidgetSettingsFooter';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isNonEmptyString, isString } from '@sniptt/guards';
import { useState } from 'react';
import { isDefined, isValidUrl } from 'twenty-shared/utils';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
const StyledOuterContainer = styled.div`
display: flex;
@@ -8,6 +8,8 @@ type WorkflowDiagramHandleTargetProps = {
};
const StyledHandleContainer = styled.div`
position: absolute;
& .react-flow__handle {
border-radius: ${themeCssVariables.border.radius.md};
height: 100%;
@@ -1,86 +1,174 @@
import { SettingsAgentModelCapabilities } from '@/ai/components/SettingsAgentModelCapabilities';
import { type OutputSchemaField } from '@/ai/constants/OutputFieldTypeOptions';
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
import { agentResponseSchemaToOutputSchema } from '@/ai/utils/agentResponseSchemaToOutputSchema';
import { createDefaultOutputSchemaField } from '@/ai/utils/createDefaultOutputSchemaField';
import { fieldsToSchema } from '@/ai/utils/fieldsToSchema';
import { schemaToFields } from '@/ai/utils/schemaToFields';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { Select } from '@/ui/input/components/Select';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { type WorkflowAiAgentAction } from '@/workflow/types/Workflow';
import { WorkflowOutputSchemaBuilder } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowOutputSchemaBuilder';
import { workflowAiAgentActionAgentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useMutation } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import {
type AgentResponseSchema,
type ModelConfiguration,
} from 'twenty-shared/ai';
import { type SelectOption } from 'twenty-ui/input';
import { SettingsAgentModelCapabilities } from '~/pages/settings/ai/components/SettingsAgentModelCapabilities';
import { SettingsAgentResponseFormat } from '~/pages/settings/ai/components/SettingsAgentResponseFormat';
import { useDebouncedCallback } from 'use-debounce';
import {
UpdateOneAgentDocument,
type UpdateOneAgentMutationVariables,
} from '~/generated-metadata/graphql';
type WorkflowAiAgentPromptTabProps = {
action: WorkflowAiAgentAction;
prompt: string;
readonly: boolean;
aiModelOptions: SelectOption[];
onPromptChange: (value: string) => void;
onModelChange: (modelId: string) => void;
onModelConfigurationChange: (configuration: ModelConfiguration) => void;
onResponseFormatChange: (format: {
type: 'text' | 'json';
schema?: AgentResponseSchema;
}) => void;
onActionUpdate?: (action: WorkflowAiAgentAction) => void;
};
export const WorkflowAiAgentPromptTab = ({
action,
prompt,
readonly,
aiModelOptions,
onPromptChange,
onModelChange,
onModelConfigurationChange,
onResponseFormatChange,
onActionUpdate,
}: WorkflowAiAgentPromptTabProps) => {
const workflowAiAgentActionAgent = useAtomStateValue(
workflowAiAgentActionAgentState,
const [workflowAiAgentActionAgent, setWorkflowAiAgentActionAgent] =
useAtomState(workflowAiAgentActionAgentState);
const { options: aiModelOptions, pinnedOption } = useAiModelOptions({
variant: 'pinned-default',
});
const [updateAgent] = useMutation(UpdateOneAgentDocument);
const [outputSchemaFields, setOutputSchemaFields] = useState<
OutputSchemaField[]
>(() => {
const schema: AgentResponseSchema = workflowAiAgentActionAgent
?.responseFormat?.schema || {
type: 'object' as const,
properties: {},
required: [],
additionalProperties: false as const,
};
const existingFields = schemaToFields(schema);
return existingFields.length > 0
? existingFields
: [createDefaultOutputSchemaField()];
});
const updateAgentField = async (
input: Omit<UpdateOneAgentMutationVariables['input'], 'id'>,
) => {
if (readonly || !workflowAiAgentActionAgent) {
return;
}
const response = await updateAgent({
variables: {
input: {
id: workflowAiAgentActionAgent.id,
...input,
},
},
});
setWorkflowAiAgentActionAgent((currentWorkflowAiAgentActionAgent) =>
currentWorkflowAiAgentActionAgent
? {
...currentWorkflowAiAgentActionAgent,
...response.data?.updateOneAgent,
}
: currentWorkflowAiAgentActionAgent,
);
};
const updateResponseSchema = async (schema: AgentResponseSchema) => {
await updateAgentField({
responseFormat: { type: 'json' as const, schema },
});
onActionUpdate?.({
...action,
settings: {
...action.settings,
outputSchema: agentResponseSchemaToOutputSchema(schema),
},
});
};
const debouncedUpdateResponseSchema = useDebouncedCallback(
updateResponseSchema,
300,
);
if (!workflowAiAgentActionAgent) {
return null;
}
const agent = workflowAiAgentActionAgent;
const handleModelChange = async (modelId: string) => {
await updateAgentField({
modelId,
});
};
const handleModelConfigurationChange = async (
configuration: ModelConfiguration,
) => {
await updateAgentField({
modelConfiguration: configuration,
});
};
const handleOutputSchemaChange = (updatedFields: OutputSchemaField[]) => {
setOutputSchemaFields(updatedFields);
void debouncedUpdateResponseSchema(fieldsToSchema(updatedFields));
};
return (
<>
<Select
label={t`Model`}
dropdownId="select-agent-model"
options={aiModelOptions}
pinnedOption={pinnedOption}
value={agent.modelId}
onChange={handleModelChange}
showContextualTextInControl={false}
disabled={readonly}
/>
<FormTextFieldInput
multiline
VariablePicker={WorkflowVariablePicker}
label={t`Instructions for AI`}
label={t`Input (Prompt)`}
placeholder={t`Describe what you want the AI to do...`}
defaultValue={prompt}
onChange={onPromptChange}
readonly={readonly}
/>
{workflowAiAgentActionAgent ? (
<>
<Select
dropdownId="select-agent-model"
label={t`AI Model`}
options={aiModelOptions}
value={workflowAiAgentActionAgent.modelId}
onChange={onModelChange}
disabled={readonly}
/>
<SettingsAgentModelCapabilities
selectedModelId={agent.modelId}
modelConfiguration={agent.modelConfiguration || {}}
onConfigurationChange={handleModelConfigurationChange}
disabled={readonly}
/>
<SettingsAgentModelCapabilities
selectedModelId={workflowAiAgentActionAgent.modelId}
modelConfiguration={
workflowAiAgentActionAgent.modelConfiguration || {}
}
onConfigurationChange={onModelConfigurationChange}
disabled={readonly}
/>
<SettingsAgentResponseFormat
responseFormat={
workflowAiAgentActionAgent.responseFormat || {
type: 'text',
schema: {},
}
}
onResponseFormatChange={onResponseFormatChange}
disabled={readonly}
/>
</>
) : null}
<WorkflowOutputSchemaBuilder
fields={outputSchemaFields}
onChange={handleOutputSchemaChange}
readonly={readonly}
/>
</>
);
};
@@ -1,42 +1,34 @@
import { WorkflowStepCmdEnterButton } from '@/workflow/workflow-steps/components/WorkflowStepCmdEnterButton';
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { type WorkflowAiAgentAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepCmdEnterButton } from '@/workflow/workflow-steps/components/WorkflowStepCmdEnterButton';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { useUpdateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep';
import { WorkflowAiAgentPermissionsTab } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsTab';
import { WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/WorkflowAiAgentTabListComponentId';
import { WORKFLOW_AI_AGENT_TABS } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/WorkflowAiAgentTabs';
import { useResetWorkflowAiAgentPermissionsStateOnSidePanelClose } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useResetWorkflowAiAgentPermissionsStateOnSidePanelClose';
import { workflowAiAgentActionAgentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState';
import { workflowAiAgentPermissionsIsAddingPermissionState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsIsAddingPermissionState';
import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import {
type AgentResponseSchema,
type ModelConfiguration,
} from 'twenty-shared/ai';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconLock, IconSparkles } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useDebouncedCallback } from 'use-debounce';
import { useQuery, useMutation } from '@apollo/client/react';
import {
FindOneAgentDocument,
GetRolesDocument,
UpdateOneAgentDocument,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { SidePanelSkeletonLoader } from '~/loading/components/SidePanelSkeletonLoader';
import { WorkflowAiAgentPromptTab } from './WorkflowAiAgentPromptTab';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export type WorkflowAiAgentTabId =
(typeof WORKFLOW_AI_AGENT_TABS)[keyof typeof WORKFLOW_AI_AGENT_TABS];
@@ -80,10 +72,6 @@ export const WorkflowEditActionAiAgent = ({
}
}, [agentData, setWorkflowAiAgentActionAgent]);
useResetWorkflowAiAgentPermissionsStateOnSidePanelClose();
const [updateAgent] = useMutation(UpdateOneAgentDocument);
const aiModelOptions = useAiModelOptions();
const { updateWorkflowVersionStep } = useUpdateWorkflowVersionStep();
const flow = useFlowOrThrow();
const actionPrompt = action.settings.input.prompt || '';
const [prompt, setPrompt] = useState(actionPrompt);
@@ -110,89 +98,6 @@ export const WorkflowEditActionAiAgent = ({
savePrompt(newPrompt);
};
const handleAgentModelChange = async (modelId: string) => {
if (
actionOptions.readonly === true ||
!isDefined(workflowAiAgentActionAgent)
) {
return;
}
const response = await updateAgent({
variables: {
input: {
id: workflowAiAgentActionAgent.id,
modelId,
},
},
});
setWorkflowAiAgentActionAgent({
...workflowAiAgentActionAgent,
...response.data?.updateOneAgent,
});
};
const handleModelConfigurationChange = async (
configuration: ModelConfiguration,
) => {
if (
actionOptions.readonly === true ||
!isDefined(workflowAiAgentActionAgent)
) {
return;
}
const response = await updateAgent({
variables: {
input: {
id: workflowAiAgentActionAgent.id,
modelConfiguration: configuration,
},
},
});
setWorkflowAiAgentActionAgent({
...workflowAiAgentActionAgent,
...response.data?.updateOneAgent,
});
};
const updateAgentResponseFormat = async (format: {
type: 'text' | 'json';
schema?: AgentResponseSchema;
}) => {
if (
actionOptions.readonly === true ||
!isDefined(workflowAiAgentActionAgent)
) {
return;
}
const response = await updateAgent({
variables: {
input: {
id: workflowAiAgentActionAgent.id,
responseFormat: format,
},
},
});
setWorkflowAiAgentActionAgent({
...workflowAiAgentActionAgent,
...response.data?.updateOneAgent,
});
await updateWorkflowVersionStep({
workflowVersionId: flow.workflowVersionId,
step: action,
});
};
const debouncedUpdateAgentResponseFormat = useDebouncedCallback(
updateAgentResponseFormat,
300,
);
const tabs: SingleTabProps[] = [
{
id: WORKFLOW_AI_AGENT_TABS.PROMPT,
@@ -225,17 +130,9 @@ export const WorkflowEditActionAiAgent = ({
(item) => item.id === workflowAiAgentActionAgent?.roleId,
);
const handleAgentResponseFormatChange = async (format: {
type: 'text' | 'json';
schema?: AgentResponseSchema;
}) => {
if (format.type !== workflowAiAgentActionAgent?.responseFormat?.type) {
debouncedUpdateAgentResponseFormat.cancel();
void updateAgentResponseFormat(format);
} else {
void debouncedUpdateAgentResponseFormat(format);
}
};
const isCurrentAgentLoaded =
isDefined(workflowAiAgentActionAgent) &&
workflowAiAgentActionAgent.id === agentId;
const handleViewRole = () => {
if (isDefined(role?.id)) {
@@ -272,7 +169,7 @@ export const WorkflowEditActionAiAgent = ({
];
};
return agentLoading ? (
return agentLoading || !isCurrentAgentLoaded ? (
<SidePanelSkeletonLoader />
) : (
<>
@@ -295,13 +192,15 @@ export const WorkflowEditActionAiAgent = ({
) : (
<WorkflowStepBody>
<WorkflowAiAgentPromptTab
action={action}
prompt={prompt}
readonly={actionOptions.readonly === true}
aiModelOptions={aiModelOptions}
onPromptChange={handleAgentPromptChange}
onModelChange={handleAgentModelChange}
onModelConfigurationChange={handleModelConfigurationChange}
onResponseFormatChange={handleAgentResponseFormatChange}
onActionUpdate={
actionOptions.readonly === true
? undefined
: actionOptions.onActionUpdate
}
/>
</WorkflowStepBody>
)}
@@ -19,7 +19,7 @@ export const WorkflowOutputFieldTypeSelector = ({
return (
<Select
dropdownId={dropdownId}
label="Field Type"
label={t`Type`}
options={OUTPUT_FIELD_TYPE_OPTIONS.map((option) => ({
...option,
label: t(option.label),
@@ -2,15 +2,22 @@ import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-ty
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { type OutputSchemaField } from '@/ai/constants/OutputFieldTypeOptions';
import { createDefaultOutputSchemaField } from '@/ai/utils/createDefaultOutputSchemaField';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { IconPlus, IconTrash } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { v4 } from 'uuid';
import { useContext, useState } from 'react';
import {
IconChevronDown,
IconPlus,
IconVariable,
IconX,
} from 'twenty-ui/display';
import { AnimatedLightIconButton, LightIconButton } from 'twenty-ui/input';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { MenuItem } from 'twenty-ui/navigation';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { WorkflowOutputFieldTypeSelector } from './WorkflowOutputFieldTypeSelector';
import { themeCssVariables, ThemeContext } from 'twenty-ui/theme-constants';
import { useContext } from 'react';
type WorkflowOutputSchemaBuilderProps = {
fields: OutputSchemaField[];
onChange: (fields: OutputSchemaField[]) => void;
@@ -31,7 +38,7 @@ const StyledFieldsContainer = styled.div`
const StyledOutputSchemaFieldContainer = styled.div`
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.md};
border-radius: ${themeCssVariables.border.radius.sm};
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
@@ -41,50 +48,40 @@ const StyledSettingsContent = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[3]};
padding-bottom: ${themeCssVariables.spacing[3]};
padding-inline: ${themeCssVariables.spacing[3]};
padding-top: ${themeCssVariables.spacing[2]};
`;
const StyledSettingsHeader = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
const StyledSettingsHeader = styled.div<{
showRemoveFieldButton: boolean;
isExpanded: boolean;
}>`
align-items: center;
border-bottom: ${({ isExpanded }) =>
isExpanded ? `1px solid ${themeCssVariables.border.color.medium}` : 'none'};
cursor: pointer;
display: grid;
gap: ${themeCssVariables.spacing[1]};
grid-template-columns: 1fr 24px;
padding-bottom: ${themeCssVariables.spacing[2]};
padding-left: ${themeCssVariables.spacing[3]};
padding-right: ${themeCssVariables.spacing[2]};
grid-template-columns: ${({ showRemoveFieldButton }) =>
showRemoveFieldButton
? `1fr ${themeCssVariables.spacing[6]} ${themeCssVariables.spacing[6]}`
: `1fr ${themeCssVariables.spacing[6]}`};
height: ${themeCssVariables.spacing[8]};
padding-left: ${themeCssVariables.spacing[2]};
padding-right: ${themeCssVariables.spacing[1]};
`;
const StyledTitleContainer = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.primary};
display: flex;
flex-direction: row;
gap: ${themeCssVariables.spacing[1]};
padding-top: ${themeCssVariables.spacing[3]};
`;
const StyledCloseButtonContainer = styled.div`
padding-top: ${themeCssVariables.spacing[2]};
`;
const StyledAddFieldButton = styled.button`
align-items: center;
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.sm};
color: ${themeCssVariables.font.color.secondary};
cursor: pointer;
display: flex;
font-family: inherit;
font-weight: ${themeCssVariables.font.weight.medium};
gap: ${themeCssVariables.spacing[1]};
justify-content: center;
const StyledAddFieldButtonContainer = styled.div`
margin-top: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[2]};
width: 100%;
&:hover {
background-color: ${themeCssVariables.background.transparent.light};
}
`;
const StyledMessageContentContainer = styled.div`
@@ -107,21 +104,47 @@ export const WorkflowOutputSchemaBuilder = ({
readonly,
}: WorkflowOutputSchemaBuilderProps) => {
const { theme } = useContext(ThemeContext);
const [expandedFieldIds, setExpandedFieldIds] = useState<Set<string>>(
() => new Set(fields.map((field) => field.id)),
);
const toggleField = (id: string) => {
setExpandedFieldIds((previousExpandedFieldIds) => {
const nextExpandedFieldIds = new Set(previousExpandedFieldIds);
if (nextExpandedFieldIds.has(id)) {
nextExpandedFieldIds.delete(id);
} else {
nextExpandedFieldIds.add(id);
}
return nextExpandedFieldIds;
});
};
const addField = () => {
const newField: OutputSchemaField = {
id: v4(),
name: '',
description: '',
type: 'string',
};
const newField = createDefaultOutputSchemaField();
setExpandedFieldIds(
(previousExpandedFieldIds) =>
new Set([...previousExpandedFieldIds, newField.id]),
);
onChange([...fields, newField]);
};
const removeField = (id: string) => {
setExpandedFieldIds((previousExpandedFieldIds) => {
const nextExpandedFieldIds = new Set(previousExpandedFieldIds);
nextExpandedFieldIds.delete(id);
return nextExpandedFieldIds;
});
onChange(fields.filter((field) => field.id !== id));
};
const showRemoveFieldButton = !readonly && fields.length > 1;
const updateField = (id: string, updates: Partial<OutputSchemaField>) => {
onChange(
fields.map((field) =>
@@ -132,7 +155,7 @@ export const WorkflowOutputSchemaBuilder = ({
return (
<StyledOutputSchemaContainer>
<InputLabel>{t`AI Response Schema`}</InputLabel>
<InputLabel>{t`Output`}</InputLabel>
{fields.length === 0 && (
<StyledOutputSchemaFieldContainer>
@@ -146,63 +169,78 @@ export const WorkflowOutputSchemaBuilder = ({
{fields.length > 0 && (
<StyledFieldsContainer>
{fields.map((field, index) => {
const fieldNumber = index + 1;
{fields.map((field) => {
const isExpanded = expandedFieldIds.has(field.id);
return (
<StyledOutputSchemaFieldContainer key={field.id}>
<StyledSettingsHeader>
<StyledSettingsHeader
showRemoveFieldButton={showRemoveFieldButton}
onClick={() => toggleField(field.id)}
isExpanded={isExpanded}
>
<StyledTitleContainer>
<span>{t`Output Field ${fieldNumber}`}</span>
<IconVariable size={theme.icon.size.sm} />
<span>{field.name || t`Untitled field`}</span>
</StyledTitleContainer>
<StyledCloseButtonContainer>
{!readonly && (
<LightIconButton
testId="close-button"
Icon={IconTrash}
size="small"
accent="secondary"
onClick={() => removeField(field.id)}
/>
)}
</StyledCloseButtonContainer>
<AnimatedLightIconButton
Icon={IconChevronDown}
size="small"
animate={{ rotate: isExpanded ? -180 : 0 }}
/>
{showRemoveFieldButton && (
<LightIconButton
testId="remove-output-field-button"
Icon={IconX}
size="small"
onClick={() => {
removeField(field.id);
}}
/>
)}
</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>
<AnimatedExpandableContainer
isExpanded={isExpanded}
initial={false}
mode="fit-content"
>
<StyledSettingsContent>
<FormFieldInputContainer>
<FormTextFieldInput
label={t`Variable Name`}
placeholder={t`e.g., summary, status, count`}
defaultValue={field.name}
onChange={(value) =>
updateField(field.id, { name: value.trim() })
}
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>
<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>
<FormFieldInputContainer>
<FormTextFieldInput
label={t`Instruction for AI`}
placeholder={t`Brief explanation of this output field`}
defaultValue={field.description}
onChange={(value) =>
updateField(field.id, { description: value })
}
readonly={readonly}
/>
</FormFieldInputContainer>
</StyledSettingsContent>
</AnimatedExpandableContainer>
</StyledOutputSchemaFieldContainer>
);
})}
@@ -210,10 +248,13 @@ export const WorkflowOutputSchemaBuilder = ({
)}
{!readonly && (
<StyledAddFieldButton onClick={addField}>
<IconPlus size={theme.icon.size.sm} />
{t`Add Output Field`}
</StyledAddFieldButton>
<StyledAddFieldButtonContainer>
<MenuItem
LeftIcon={IconPlus}
text={t`Add Output Field`}
onClick={addField}
/>
</StyledAddFieldButtonContainer>
)}
</StyledOutputSchemaContainer>
);
@@ -1,17 +1,23 @@
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { WorkflowVariablesDropdown } from '@/workflow/workflow-variables/components/WorkflowVariablesDropdown';
import { SEARCH_VARIABLES_DROPDOWN_ID } from '@/workflow/workflow-variables/constants/SearchVariablesDropdownId';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledSearchVariablesDropdownContainer = styled.div<{
multiline?: boolean;
isReadonly?: boolean;
isUnfolded?: boolean;
multiline?: boolean;
}>`
align-items: center;
background-color: ${({ multiline }) =>
multiline
? 'transparent'
: themeCssVariables.background.transparent.lighter};
background-color: ${({ isUnfolded, multiline }) =>
isUnfolded
? themeCssVariables.background.transparent.light
: multiline
? 'transparent'
: themeCssVariables.background.transparent.lighter};
border: ${({ multiline }) =>
multiline ? 'none' : `1px solid ${themeCssVariables.border.color.medium}`};
@@ -21,28 +27,31 @@ const StyledSearchVariablesDropdownContainer = styled.div<{
: `0 ${themeCssVariables.border.radius.sm} ${themeCssVariables.border.radius.sm} 0`};
display: flex;
height: ${({ multiline }) =>
multiline ? themeCssVariables.spacing[7] : 'auto'};
justify-content: center;
padding: ${({ multiline }) =>
multiline
? `${themeCssVariables.spacing[0.5]} ${themeCssVariables.spacing[0]}`
: '0'};
margin: ${({ multiline }) =>
multiline ? `${themeCssVariables.spacing[1]}` : '0'};
position: ${({ multiline }) => (multiline ? 'absolute' : 'static')};
right: ${({ multiline }) =>
multiline ? themeCssVariables.spacing[0] : 'auto'};
top: ${({ multiline }) =>
multiline ? themeCssVariables.spacing[0] : 'auto'};
width: ${({ multiline }) =>
multiline ? themeCssVariables.spacing[7] : 'auto'};
&:hover {
background-color: ${({ isReadonly, multiline }) => {
background-color: ${({ isReadonly, isUnfolded, multiline }) => {
if (isReadonly === true) {
return multiline
? 'transparent'
: themeCssVariables.background.transparent.lighter;
}
return themeCssVariables.background.transparent.light;
return isUnfolded
? themeCssVariables.background.transparent.medium
: themeCssVariables.background.transparent.light;
}};
}
`;
@@ -55,10 +64,17 @@ export const WorkflowVariablePicker: VariablePickerComponent = ({
shouldDisplayRecordObjects = false,
shouldDisplayRecordFields = true,
}) => {
const dropdownId = `${SEARCH_VARIABLES_DROPDOWN_ID}-${instanceId}`;
const isDropdownOpen = useAtomComponentStateValue(
isDropdownOpenComponentState,
dropdownId,
);
return (
<StyledSearchVariablesDropdownContainer
multiline={multiline}
isReadonly={disabled}
isUnfolded={isDropdownOpen}
multiline={multiline}
>
<WorkflowVariablesDropdown
instanceId={instanceId}
@@ -66,7 +82,6 @@ export const WorkflowVariablePicker: VariablePickerComponent = ({
disabled={disabled}
shouldDisplayRecordObjects={shouldDisplayRecordObjects}
shouldDisplayRecordFields={shouldDisplayRecordFields}
multiline={multiline}
/>
</StyledSearchVariablesDropdownContainer>
);
@@ -1,11 +1,9 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { type InputSchemaPropertyType } from 'twenty-shared/workflow';
import { WorkflowVariablesDropdownStepItems } from '@/workflow/workflow-variables/components/WorkflowVariablesDropdownStepItems';
import { WorkflowVariablesDropdownSteps } from '@/workflow/workflow-variables/components/WorkflowVariablesDropdownSteps';
import { SEARCH_VARIABLES_DROPDOWN_ID } from '@/workflow/workflow-variables/constants/SearchVariablesDropdownId';
import { type InputSchemaPropertyType } from 'twenty-shared/workflow';
import { useAvailableVariablesInWorkflowStep } from '@/workflow/workflow-variables/hooks/useAvailableVariablesInWorkflowStep';
import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
@@ -13,59 +11,42 @@ import { styled } from '@linaria/react';
import { useContext, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconVariablePlus } from 'twenty-ui/display';
import { themeCssVariables, ThemeContext } from 'twenty-ui/theme-constants';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledDropdownVariableButtonContainer = styled.div<{
isUnfolded?: boolean;
transparentBackground?: boolean;
disabled?: boolean;
}>`
align-items: center;
background-color: ${({ transparentBackground }) =>
transparentBackground
? 'transparent'
: themeCssVariables.background.transparent.lighter};
border-radius: ${themeCssVariables.border.radius.sm};
background-color: transparent;
border-bottom-right-radius: ${themeCssVariables.border.radius.sm};
border-top-right-radius: ${themeCssVariables.border.radius.sm};
color: ${themeCssVariables.font.color.tertiary};
cursor: pointer;
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
justify-content: center;
padding: ${themeCssVariables.spacing[2]};
user-select: none;
&:hover {
background: ${({ isUnfolded, transparentBackground }) =>
transparentBackground
? 'transparent'
: isUnfolded
? themeCssVariables.background.transparent.medium
: themeCssVariables.background.transparent.light};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
}
`;
export const WorkflowVariablesDropdown = ({
clickableComponent,
disabled,
fieldTypesToExclude,
instanceId,
onVariableSelect,
disabled,
shouldDisplayRecordFields,
shouldDisplayRecordObjects,
fieldTypesToExclude,
multiline,
clickableComponent,
}: {
clickableComponent?: React.ReactNode;
disabled?: boolean;
fieldTypesToExclude?: InputSchemaPropertyType[];
instanceId: string;
onVariableSelect: (variableName: string) => void;
shouldDisplayRecordFields: boolean;
shouldDisplayRecordObjects: boolean;
fieldTypesToExclude?: InputSchemaPropertyType[];
disabled?: boolean;
multiline?: boolean;
clickableComponent?: React.ReactNode;
}) => {
const { theme } = useContext(ThemeContext);
const dropdownId = `${SEARCH_VARIABLES_DROPDOWN_ID}-${instanceId}`;
const isDropdownOpen = useAtomComponentStateValue(
isDropdownOpenComponentState,
dropdownId,
);
const { closeDropdown } = useCloseDropdown();
const availableVariablesInWorkflowStep = useAvailableVariablesInWorkflowStep({
shouldDisplayRecordFields,
@@ -102,13 +83,9 @@ export const WorkflowVariablesDropdown = ({
if (disabled === true || noAvailableVariables) {
return (
<StyledDropdownVariableButtonContainer
isUnfolded={isDropdownOpen}
disabled={true}
transparentBackground
>
<StyledDropdownVariableButtonContainer disabled={true}>
<IconVariablePlus
size={theme.icon.size.sm}
size={theme.icon.size.md}
color={theme.font.color.light}
/>
</StyledDropdownVariableButtonContainer>
@@ -121,11 +98,8 @@ export const WorkflowVariablesDropdown = ({
isDropdownInModal={true}
clickableComponent={
clickableComponent ?? (
<StyledDropdownVariableButtonContainer
isUnfolded={isDropdownOpen}
transparentBackground
>
<IconVariablePlus size={theme.icon.size.sm} />
<StyledDropdownVariableButtonContainer>
<IconVariablePlus size={theme.icon.size.md} />
</StyledDropdownVariableButtonContainer>
)
}
@@ -147,8 +121,7 @@ export const WorkflowVariablesDropdown = ({
}
dropdownPlacement="bottom-end"
dropdownOffset={{
x: 2,
y: parseInt(theme.spacing[multiline ? 11 : 1], 10),
y: parseInt(theme.spacing[1], 10),
}}
/>
);
@@ -44,12 +44,29 @@ export const useStepsOutputSchema = () => {
return;
}
// TODO: Remove this fallback after upgrade command
// `upgrade:1-21:migrate-ai-agent-text-to-json-response-format`
// has run on all workspaces.
const persistedOutputSchema =
step.type === 'AI_AGENT' &&
(!isDefined(step.settings?.outputSchema) ||
Object.keys(step.settings.outputSchema).length === 0)
? {
response: {
isLeaf: true,
type: 'string',
label: 'Response',
value: null,
},
}
: step.settings?.outputSchema;
const outputSchema = shouldComputeOnFrontend
? computeStepOutputSchema({
step,
objectMetadataItems,
})
: step.settings?.outputSchema;
: persistedOutputSchema;
const stepOutputSchema: StepOutputSchemaV2 = {
id: step.id,
@@ -409,20 +409,13 @@ describe('computeStepOutputSchema', () => {
});
describe('AI_AGENT step', () => {
it('should return response schema', () => {
it('should return undefined for AI_AGENT step type', () => {
const result = computeStepOutputSchema({
step: { type: 'AI_AGENT', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toEqual({
response: {
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Response',
value: null,
},
});
expect(result).toBeUndefined();
});
});
@@ -461,8 +454,8 @@ describe('shouldComputeOutputSchemaOnFrontend', () => {
expect(shouldComputeOutputSchemaOnFrontend('HTTP_REQUEST')).toBe(false);
});
it('should return true for AI_AGENT', () => {
expect(shouldComputeOutputSchemaOnFrontend('AI_AGENT')).toBe(true);
it('should return false for AI_AGENT', () => {
expect(shouldComputeOutputSchemaOnFrontend('AI_AGENT')).toBe(false);
});
it('should return false for WEBHOOK', () => {
@@ -14,6 +14,7 @@ import { isDefined } from 'twenty-shared/utils';
import { DatabaseEventAction } from '~/generated-metadata/graphql';
const PERSISTED_OUTPUT_SCHEMA_TYPES = [
'AI_AGENT',
'CODE',
'HTTP_REQUEST',
'WEBHOOK',
@@ -190,17 +191,6 @@ export const computeStepOutputSchema = ({
return generateFormOutputSchema(formFields, objectMetadataItems);
}
case 'AI_AGENT': {
return {
response: {
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Response',
value: null,
},
};
}
case 'SEND_EMAIL':
case 'DRAFT_EMAIL': {
return {
@@ -113,7 +113,8 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => {
prompt: agent.prompt,
isCustom: agent.isCustom,
modelConfiguration: agent.modelConfiguration || {},
responseFormat: agent.responseFormat || { type: 'text', schema: {} },
// TODO: Fallback can be removed once all text response format agents are migrated.
responseFormat: agent.responseFormat || { type: 'text' },
evaluationInputs: agent.evaluationInputs ?? [],
};
resetForm(initialValues);
@@ -1,15 +1,12 @@
import { isDefined } from 'twenty-shared/utils';
import { type OutputSchemaField } from '@/ai/constants/OutputFieldTypeOptions';
import { fieldsToSchema } from '@/ai/utils/fieldsToSchema';
import { schemaToFields } from '@/ai/utils/schemaToFields';
import { Select } from '@/ui/input/components/Select';
import { WorkflowOutputSchemaBuilder } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowOutputSchemaBuilder';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import {
type AgentResponseFieldType,
type AgentResponseSchema,
} from 'twenty-shared/ai';
import { v4 } from 'uuid';
import { type AgentResponseSchema } from 'twenty-shared/ai';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContainer = styled.div`
@@ -30,42 +27,6 @@ type SettingsAgentResponseFormatProps = {
disabled?: boolean;
};
const schemaToFields = (schema: AgentResponseSchema): OutputSchemaField[] => {
if (!isDefined(schema.properties)) return [];
return Object.entries(schema.properties).map(([key, field]) => ({
id: v4(),
name: key,
description: field.description || '',
type: field.type,
}));
};
const fieldsToSchema = (fields: OutputSchemaField[]): AgentResponseSchema => {
const properties: Record<
string,
{ type: AgentResponseFieldType; description?: string }
> = {};
const required: string[] = [];
fields
.filter((field) => field.name.trim() && field.type)
.forEach((field) => {
properties[field.name] = {
type: field.type!,
description: field.description || field.name,
};
required.push(field.name);
});
return {
type: 'object' as const,
properties,
required,
additionalProperties: false as const,
};
};
export const SettingsAgentResponseFormat = ({
responseFormat,
onResponseFormatChange,
@@ -87,16 +48,11 @@ export const SettingsAgentResponseFormat = ({
if (newType === 'json') {
setVisualBuilderFields(schemaToFields(schema));
}
const emptySchema: AgentResponseSchema = {
type: 'object' as const,
properties: {},
required: [],
additionalProperties: false as const,
};
onResponseFormatChange({
type: newType,
schema: newType === 'text' ? emptySchema : schema,
});
// TODO: Remove text response format support once prod migration upgrades legacy agents to JSON format.
onResponseFormatChange(
newType === 'text' ? { type: 'text' } : { type: 'json', schema },
);
};
const handleVisualBuilderChange = (updatedFields: OutputSchemaField[]) => {
@@ -115,6 +71,7 @@ export const SettingsAgentResponseFormat = ({
value={formatType}
onChange={handleFormatTypeChange}
options={[
// TODO: Remove string option once text response format support is fully dropped.
{ label: t`String`, value: 'text' as const },
{ label: t`JSON`, value: 'json' as const },
]}
@@ -5,6 +5,7 @@ import {
useAiModelLabel,
useAiModelOptions,
} from '@/ai/hooks/useAiModelOptions';
import { SettingsAgentModelCapabilities } from '@/ai/components/SettingsAgentModelCapabilities';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { Select } from '@/ui/input/components/Select';
@@ -20,7 +21,6 @@ import { type Agent } from '~/generated-metadata/graphql';
import { SettingsAgentDeleteConfirmationModal } from '~/pages/settings/ai/components/SettingsAgentDeleteConfirmationModal';
import { SettingsAgentResponseFormat } from '~/pages/settings/ai/components/SettingsAgentResponseFormat';
import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/computeMetadataNameFromLabel';
import { SettingsAgentModelCapabilities } from '~/pages/settings/ai/components/SettingsAgentModelCapabilities';
import { type SettingsAIAgentFormValues } from '~/pages/settings/ai/hooks/useSettingsAgentFormState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -68,7 +68,7 @@ export const SettingsAgentSettingsTab = ({
const { openModal } = useModal();
const aiModels = useAtomStateValue(aiModelsState);
const activeModelOptions = useAiModelOptions();
const { options: activeModelOptions } = useAiModelOptions();
const currentModelLabel = useAiModelLabel(formValues.modelId);
const currentModel = aiModels.find((m) => m.modelId === formValues.modelId);
@@ -1,6 +1,7 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { SettingsAgentModelCapabilities } from '@/ai/components/SettingsAgentModelCapabilities';
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { Select } from '@/ui/input/components/Select';
@@ -9,7 +10,6 @@ import { TextArea } from '@/ui/input/components/TextArea';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/computeMetadataNameFromLabel';
import { SettingsAgentModelCapabilities } from '~/pages/settings/ai/components/SettingsAgentModelCapabilities';
import { type SettingsAIAgentFormValues } from '~/pages/settings/ai/hooks/useSettingsAgentFormState';
const StyledFormContainer = styled.div`
@@ -50,7 +50,7 @@ export const SettingsAIAgentForm = ({
}: SettingsAIAgentFormProps) => {
const { t } = useLingui();
const modelOptions = useAiModelOptions();
const { options: modelOptions } = useAiModelOptions();
const noModelsAvailable = modelOptions.length === 0;
@@ -19,13 +19,8 @@ export const useSettingsAgentFormState = (mode: 'create' | 'edit') => {
isCustom: true,
modelConfiguration: {},
responseFormat: {
// TODO: Keep text default until legacy text agents are migrated in production.
type: 'text',
schema: {
type: 'object' as const,
properties: {},
required: [],
additionalProperties: false as const,
},
},
evaluationInputs: [],
});
@@ -63,13 +58,8 @@ export const useSettingsAgentFormState = (mode: 'create' | 'edit') => {
isCustom: true,
modelConfiguration: {},
responseFormat: {
// TODO: Keep text default until legacy text agents are migrated in production.
type: 'text',
schema: {
type: 'object' as const,
properties: {},
required: [],
additionalProperties: false as const,
},
},
evaluationInputs: [],
});
@@ -0,0 +1,231 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { type FlatAgent } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import { type WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
const TEXT_AGENT_DEFAULT_JSON_RESPONSE_FORMAT = {
type: 'json' as const,
schema: {
type: 'object' as const,
properties: {
response: {
type: 'string' as const,
description: 'Response of the agent',
},
},
required: ['response'],
additionalProperties: false as const,
},
};
const TEXT_AGENT_DEFAULT_OUTPUT_SCHEMA = {
response: {
isLeaf: true,
type: 'string',
label: 'Response',
value: null,
},
};
@Command({
name: 'upgrade:1-21:migrate-ai-agent-text-to-json-response-format',
description:
'Migrate AI agents with text response format to JSON with a default response field',
})
export class MigrateAiAgentTextToJsonResponseFormatCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const textAgents = await this.findTextFormatCustomAgents(workspaceId);
if (textAgents.length === 0) {
this.logger.log(
`No text-format agents found for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Found ${textAgents.length} text-format agent(s) for workspace ${workspaceId}`,
);
if (isDryRun) {
return;
}
await this.migrateAgentsToJson(workspaceId, textAgents);
const textAgentIds = textAgents.map((agent) => agent.id);
await this.updateWorkflowStepOutputSchemas(workspaceId, textAgentIds);
this.logger.log(
`Successfully migrated ${textAgents.length} agent(s) to JSON response format for workspace ${workspaceId}`,
);
}
private async findTextFormatCustomAgents(
workspaceId: string,
): Promise<FlatAgent[]> {
const { flatAgentMaps } = await this.workspaceCacheService.getOrRecompute(
workspaceId,
['flatAgentMaps'],
);
return Object.values(flatAgentMaps.byUniversalIdentifier)
.filter(isDefined)
.filter((flatAgent) => this.isTextFormatCustomAgent(flatAgent));
}
private isTextFormatCustomAgent(flatAgent: FlatAgent): boolean {
if (!flatAgent.isCustom) {
return false;
}
const responseFormat = flatAgent.responseFormat as
| {
type?: string;
}
| null
| undefined;
return !isDefined(responseFormat?.type) || responseFormat.type === 'text';
}
private async migrateAgentsToJson(
workspaceId: string,
textAgents: FlatAgent[],
): Promise<void> {
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
agent: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: textAgents.map((textAgent) => ({
...textAgent,
responseFormat: TEXT_AGENT_DEFAULT_JSON_RESPONSE_FORMAT,
})),
},
},
workspaceId,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to migrate agents to JSON response format:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to migrate text-format agents for workspace ${workspaceId}`,
);
}
}
private async updateWorkflowStepOutputSchemas(
workspaceId: string,
agentIds: string[],
): Promise<void> {
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepository<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
const allVersions = await workflowVersionRepository.find();
let updatedVersionCount = 0;
for (const version of allVersions) {
if (!version.steps || !Array.isArray(version.steps)) {
continue;
}
let versionModified = false;
const updatedSteps = version.steps.map((step: WorkflowAction) => {
if (step.type !== 'AI_AGENT') {
return step;
}
const agentId = step.settings?.input?.agentId;
if (!agentId || !agentIds.includes(agentId)) {
return step;
}
const currentOutputSchema = step.settings?.outputSchema;
if (
currentOutputSchema &&
Object.keys(currentOutputSchema).length > 0
) {
return step;
}
versionModified = true;
return {
...step,
settings: {
...step.settings,
outputSchema: TEXT_AGENT_DEFAULT_OUTPUT_SCHEMA,
},
};
});
if (versionModified) {
await workflowVersionRepository.update(version.id, {
steps: updatedSteps as WorkflowAction[],
});
updatedVersionCount++;
}
}
if (updatedVersionCount > 0) {
this.logger.log(
`Updated output schemas in ${updatedVersionCount} workflow version(s) for workspace ${workspaceId}`,
);
}
}
}
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-datasource-to-workspace.command';
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-page-layouts-and-fields-widget-view-fields.command';
import { MigrateAiAgentTextToJsonResponseFormatCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-migrate-ai-agent-text-to-json-response-format.command';
import { UpdateEditLayoutCommandMenuItemLabelCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-update-edit-layout-command-menu-item-label.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
@@ -24,11 +25,13 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
providers: [
BackfillDatasourceToWorkspaceCommand,
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
MigrateAiAgentTextToJsonResponseFormatCommand,
UpdateEditLayoutCommandMenuItemLabelCommand,
],
exports: [
BackfillDatasourceToWorkspaceCommand,
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
MigrateAiAgentTextToJsonResponseFormatCommand,
UpdateEditLayoutCommandMenuItemLabelCommand,
],
})
@@ -27,6 +27,7 @@ import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upg
import { UpdateStandardIndexViewNamesCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-update-standard-index-view-names.command';
import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-datasource-to-workspace.command';
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-page-layouts-and-fields-widget-view-fields.command';
import { MigrateAiAgentTextToJsonResponseFormatCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-migrate-ai-agent-text-to-json-response-format.command';
import { UpdateEditLayoutCommandMenuItemLabelCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-update-edit-layout-command-menu-item-label.command';
import { CoreEngineVersionService } from 'src/engine/core-engine-version/services/core-engine-version.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -72,6 +73,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
// 1.21 Commands
protected readonly backfillDatasourceToWorkspaceCommand: BackfillDatasourceToWorkspaceCommand,
protected readonly backfillPageLayoutsAndFieldsWidgetViewFieldsCommand: BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
protected readonly migrateAiAgentTextToJsonResponseFormatCommand: MigrateAiAgentTextToJsonResponseFormatCommand,
protected readonly updateEditLayoutCommandMenuItemLabelCommand: UpdateEditLayoutCommandMenuItemLabelCommand,
) {
super(
@@ -108,6 +110,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
const commands_1210: VersionCommands = [
this.backfillDatasourceToWorkspaceCommand,
this.backfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
this.migrateAiAgentTextToJsonResponseFormatCommand,
this.updateEditLayoutCommandMenuItemLabelCommand,
];