[AI] agent node prompt tab new design + refactor (#19012)
This commit is contained in:
@@ -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} />
|
||||
|
||||
+5
-4
@@ -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,
|
||||
}));
|
||||
};
|
||||
+1
-1
@@ -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};
|
||||
|
||||
+3
-3
@@ -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 }) =>
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
+2
@@ -8,6 +8,8 @@ type WorkflowDiagramHandleTargetProps = {
|
||||
};
|
||||
|
||||
const StyledHandleContainer = styled.div`
|
||||
position: absolute;
|
||||
|
||||
& .react-flow__handle {
|
||||
border-radius: ${themeCssVariables.border.radius.md};
|
||||
height: 100%;
|
||||
|
||||
+137
-49
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+13
-114
@@ -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>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -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),
|
||||
|
||||
+137
-96
@@ -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>
|
||||
);
|
||||
|
||||
+29
-14
@@ -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>
|
||||
);
|
||||
|
||||
+19
-46
@@ -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),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
+18
-1
@@ -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,
|
||||
|
||||
+4
-11
@@ -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', () => {
|
||||
|
||||
+1
-11
@@ -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);
|
||||
|
||||
+9
-52
@@ -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);
|
||||
|
||||
+2
-2
@@ -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: [],
|
||||
});
|
||||
|
||||
+231
@@ -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
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
+3
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user