From ade6ed9c32023299bbe67b2ed1e2b3c303ccaa89 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:54:58 +0530 Subject: [PATCH] [AI] agent node prompt tab new design + refactor (#19012) --- .../ai/components/AIChatEditorSection.tsx | 53 ++-- .../SettingsAgentModelCapabilities.tsx | 9 +- .../src/modules/ai/hooks/useAiModelOptions.ts | 67 ++++- .../agentResponseSchemaToOutputSchema.ts | 30 +++ .../utils/createDefaultOutputSchemaField.ts | 9 + .../src/modules/ai/utils/fieldsToSchema.ts | 35 +++ .../src/modules/ai/utils/schemaToFields.ts | 17 ++ .../FormFieldInputInnerContainer.tsx | 2 +- .../components/TextVariableEditor.tsx | 6 +- .../SidePanelPageLayoutIframeSettings.tsx | 6 +- .../WorkflowDiagramHandleTarget.tsx | 2 + .../components/WorkflowAiAgentPromptTab.tsx | 186 ++++++++++---- .../components/WorkflowEditActionAiAgent.tsx | 127 +--------- .../WorkflowOutputFieldTypeSelector.tsx | 2 +- .../WorkflowOutputSchemaBuilder.tsx | 233 ++++++++++-------- .../components/WorkflowVariablePicker.tsx | 43 ++-- .../components/WorkflowVariablesDropdown.tsx | 65 ++--- .../hooks/useStepsOutputSchema.ts | 19 +- .../__tests__/computeStepOutputSchema.test.ts | 15 +- .../utils/generate/computeStepOutputSchema.ts | 12 +- .../pages/settings/ai/SettingsAgentForm.tsx | 3 +- .../SettingsAgentResponseFormat.tsx | 61 +---- .../components/SettingsAgentSettingsTab.tsx | 4 +- .../forms/components/SettingsAIAgentForm.tsx | 4 +- .../ai/hooks/useSettingsAgentFormState.ts | 14 +- ...nt-text-to-json-response-format.command.ts | 231 +++++++++++++++++ .../1-21-upgrade-version-command.module.ts | 3 + .../upgrade.command.ts | 3 + 28 files changed, 786 insertions(+), 475 deletions(-) rename packages/twenty-front/src/{pages/settings => modules}/ai/components/SettingsAgentModelCapabilities.tsx (97%) create mode 100644 packages/twenty-front/src/modules/ai/utils/agentResponseSchemaToOutputSchema.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/createDefaultOutputSchemaField.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/fieldsToSchema.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/schemaToFields.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-migrate-ai-agent-text-to-json-response-format.command.ts diff --git a/packages/twenty-front/src/modules/ai/components/AIChatEditorSection.tsx b/packages/twenty-front/src/modules/ai/components/AIChatEditorSection.tsx index ff7eff864b..b6350bdc67 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatEditorSection.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatEditorSection.tsx @@ -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[] = options; + const defaultPinnedOption: SelectOption | 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 ( <> diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentModelCapabilities.tsx b/packages/twenty-front/src/modules/ai/components/SettingsAgentModelCapabilities.tsx similarity index 97% rename from packages/twenty-front/src/pages/settings/ai/components/SettingsAgentModelCapabilities.tsx rename to packages/twenty-front/src/modules/ai/components/SettingsAgentModelCapabilities.tsx index f1fd4604dc..07303056f8 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentModelCapabilities.tsx +++ b/packages/twenty-front/src/modules/ai/components/SettingsAgentModelCapabilities.tsx @@ -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; diff --git a/packages/twenty-front/src/modules/ai/hooks/useAiModelOptions.ts b/packages/twenty-front/src/modules/ai/hooks/useAiModelOptions.ts index 27398e3543..d65dc5d90c 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAiModelOptions.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAiModelOptions.ts @@ -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[] => { - 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[]; + pinnedOption?: SelectOption; +} => { + 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 = ( diff --git a/packages/twenty-front/src/modules/ai/utils/agentResponseSchemaToOutputSchema.ts b/packages/twenty-front/src/modules/ai/utils/agentResponseSchemaToOutputSchema.ts new file mode 100644 index 0000000000..142f6aa4a8 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/agentResponseSchemaToOutputSchema.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/createDefaultOutputSchemaField.ts b/packages/twenty-front/src/modules/ai/utils/createDefaultOutputSchemaField.ts new file mode 100644 index 0000000000..b2cc399cc8 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/createDefaultOutputSchemaField.ts @@ -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', +}); diff --git a/packages/twenty-front/src/modules/ai/utils/fieldsToSchema.ts b/packages/twenty-front/src/modules/ai/utils/fieldsToSchema.ts new file mode 100644 index 0000000000..a6897424a5 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/fieldsToSchema.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/schemaToFields.ts b/packages/twenty-front/src/modules/ai/utils/schemaToFields.ts new file mode 100644 index 0000000000..4bf725dbde --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/schemaToFields.ts @@ -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, + })); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer.tsx index 7fac5d6254..4c1febabdc 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer.tsx @@ -17,7 +17,7 @@ type FormFieldInputInnerContainerProps = { const StyledFormFieldInputInnerContainer = styled.div< Omit >` - 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}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/TextVariableEditor.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/TextVariableEditor.tsx index 49f85ae2b6..32a8434b1c 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/TextVariableEditor.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/TextVariableEditor.tsx @@ -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 }) => diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutIframeSettings.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutIframeSettings.tsx index df55353333..27f02a552d 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutIframeSettings.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutIframeSettings.tsx @@ -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; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleTarget.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleTarget.tsx index fc7ee82801..3958abf9e2 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleTarget.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleTarget.tsx @@ -8,6 +8,8 @@ type WorkflowDiagramHandleTargetProps = { }; const StyledHandleContainer = styled.div` + position: absolute; + & .react-flow__handle { border-radius: ${themeCssVariables.border.radius.md}; height: 100%; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPromptTab.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPromptTab.tsx index 60934b98db..1487bbd2f3 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPromptTab.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPromptTab.tsx @@ -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, + ) => { + 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 ( <> + + - - - - - ) : null} + ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent.tsx index 70ff7f483b..1e04fe1e41 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent.tsx @@ -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 ? ( ) : ( <> @@ -295,13 +192,15 @@ export const WorkflowEditActionAiAgent = ({ ) : ( )} diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowOutputFieldTypeSelector.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowOutputFieldTypeSelector.tsx index cd12285438..15aa6c05ec 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowOutputFieldTypeSelector.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowOutputFieldTypeSelector.tsx @@ -19,7 +19,7 @@ export const WorkflowOutputFieldTypeSelector = ({ return (