diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 717bbb026c..61ed7e1b8a 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -6384,6 +6384,11 @@ export type UpsertFieldsWidgetMutationVariables = Exact<{ export type UpsertFieldsWidgetMutation = { __typename?: 'Mutation', upsertFieldsWidget: { __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, mainGroupByFieldMetadataId?: string | null, shouldHideEmptyGroups: boolean, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFieldGroups: Array<{ __typename?: 'CoreViewFieldGroup', id: string, name: string, position: number, isVisible: boolean, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }> }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string, createdAt: string, deletedAt?: string | null, updatedAt: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }> } }; +export type FindAllPageLayoutsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type FindAllPageLayoutsQuery = { __typename?: 'Query', getPageLayouts: Array<{ __typename?: 'PageLayout', id: string, name: string, objectMetadataId?: string | null, type: PageLayoutType, defaultTabToFocusOnMobileAndSidePanelId?: string | null, createdAt: string, updatedAt: string, tabs?: Array<{ __typename?: 'PageLayoutTab', id: string, applicationId: string, title: string, icon?: string | null, position: number, layoutMode?: PageLayoutTabLayoutMode | null, pageLayoutId: string, createdAt: string, updatedAt: string, widgets?: Array<{ __typename?: 'PageLayoutWidget', id: string, title: string, type: WidgetType, objectMetadataId?: string | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: string, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, position?: { __typename?: 'PageLayoutWidgetCanvasPosition', layoutMode: PageLayoutTabLayoutMode } | { __typename?: 'PageLayoutWidgetGridPosition', layoutMode: PageLayoutTabLayoutMode, row: number, column: number, rowSpan: number, columnSpan: number } | { __typename?: 'PageLayoutWidgetVerticalListPosition', layoutMode: PageLayoutTabLayoutMode, index: number } | null, configuration: { __typename?: 'AggregateChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, prefix?: string | null, suffix?: string | null, timezone?: string | null, firstDayOfTheWeek?: number | null, ratioAggregateConfig?: { __typename?: 'RatioAggregateConfig', fieldMetadataId: string, optionValue: string } | null } | { __typename?: 'BarChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: string, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, primaryAxisManualSortOrder?: Array | null, secondaryAxisGroupByFieldMetadataId?: string | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, layout: BarChartLayout, isCumulative?: boolean | null, splitMultiValueFields?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'CalendarConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'EmailsConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FieldConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FieldRichTextConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FieldsConfiguration', configurationType: WidgetConfigurationType, viewId?: string | null } | { __typename?: 'FilesConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FrontComponentConfiguration', configurationType: WidgetConfigurationType, frontComponentId: string } | { __typename?: 'GaugeChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', configurationType: WidgetConfigurationType, url?: string | null } | { __typename?: 'LineChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: string, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, primaryAxisManualSortOrder?: Array | null, secondaryAxisGroupByFieldMetadataId?: string | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, isCumulative?: boolean | null, splitMultiValueFields?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'NotesConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'PieChartConfiguration', configurationType: WidgetConfigurationType, groupByFieldMetadataId: string, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, manualSortOrder?: Array | null, displayDataLabel?: boolean | null, showCenterMetric?: boolean | null, displayLegend?: boolean | null, hideEmptyCategory?: boolean | null, splitMultiValueFields?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'StandaloneRichTextConfiguration', configurationType: WidgetConfigurationType, body: { __typename?: 'RichTextV2Body', blocknote?: string | null, markdown?: string | null } } | { __typename?: 'TasksConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'TimelineConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'ViewConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'WorkflowConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'WorkflowRunConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'WorkflowVersionConfiguration', configurationType: WidgetConfigurationType } }> | null }> | null }> }; + export type FindAllRecordPageLayoutsQueryVariables = Exact<{ [key: string]: never; }>; @@ -12286,6 +12291,40 @@ export function useUpsertFieldsWidgetMutation(baseOptions?: Apollo.MutationHookO export type UpsertFieldsWidgetMutationHookResult = ReturnType; export type UpsertFieldsWidgetMutationResult = Apollo.MutationResult; export type UpsertFieldsWidgetMutationOptions = Apollo.BaseMutationOptions; +export const FindAllPageLayoutsDocument = gql` + query FindAllPageLayouts { + getPageLayouts { + ...PageLayoutFragment + } +} + ${PageLayoutFragmentFragmentDoc}`; + +/** + * __useFindAllPageLayoutsQuery__ + * + * To run a query within a React component, call `useFindAllPageLayoutsQuery` and pass it any options that fit your needs. + * When your component renders, `useFindAllPageLayoutsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useFindAllPageLayoutsQuery({ + * variables: { + * }, + * }); + */ +export function useFindAllPageLayoutsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(FindAllPageLayoutsDocument, options); + } +export function useFindAllPageLayoutsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(FindAllPageLayoutsDocument, options); + } +export type FindAllPageLayoutsQueryHookResult = ReturnType; +export type FindAllPageLayoutsLazyQueryHookResult = ReturnType; +export type FindAllPageLayoutsQueryResult = Apollo.QueryResult; export const FindAllRecordPageLayoutsDocument = gql` query FindAllRecordPageLayouts { getPageLayouts(pageLayoutType: RECORD_PAGE) { diff --git a/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx b/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx index 2f74e21334..eb6537e736 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx @@ -2,6 +2,12 @@ import { styled } from '@linaria/react'; import { type Editor } from '@tiptap/react'; import { AIChatSuggestedPrompts } from '@/ai/components/suggested-prompts/AIChatSuggestedPrompts'; +import { agentChatErrorState } from '@/ai/states/agentChatErrorState'; +import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector'; +import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState'; +import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { isDefined } from 'twenty-shared/utils'; const StyledEmptyState = styled.div` display: flex; @@ -16,6 +22,21 @@ type AIChatEmptyStateProps = { }; export const AIChatEmptyState = ({ editor }: AIChatEmptyStateProps) => { + const agentChatIsLoading = useAtomStateValue(agentChatIsLoadingState); + + const agentChatError = useAtomStateValue(agentChatErrorState); + + const hasMessages = useAtomComponentSelectorValue( + agentChatHasMessageComponentSelector, + ); + + const shouldRender = + !hasMessages && !isDefined(agentChatError) && !agentChatIsLoading; + + if (!shouldRender) { + return null; + } + return ( diff --git a/packages/twenty-front/src/modules/ai/components/AIChatErrorMessage.tsx b/packages/twenty-front/src/modules/ai/components/AIChatErrorMessage.tsx index 9026ef92a9..2082e6a6a4 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatErrorMessage.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatErrorMessage.tsx @@ -1,4 +1,7 @@ -import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow'; +import { AGENT_CHAT_RETRY_EVENT_NAME } from '@/ai/constants/AgentChatRetryEventName'; +import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState'; +import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { styled } from '@linaria/react'; import { t } from '@lingui/core/macro'; import { useContext } from 'react'; @@ -47,8 +50,13 @@ type AIChatErrorMessageProps = { }; export const AIChatErrorMessage = ({ error }: AIChatErrorMessageProps) => { + const agentChatIsStreaming = useAtomStateValue(agentChatIsStreamingState); + + const handleRetryClick = () => { + dispatchBrowserEvent(AGENT_CHAT_RETRY_EVENT_NAME); + }; + const { theme } = useContext(ThemeContext); - const { handleRetry, isStreaming } = useAgentChatContextOrThrow(); return ( @@ -65,8 +73,8 @@ export const AIChatErrorMessage = ({ error }: AIChatErrorMessageProps) => { variant="secondary" size="small" Icon={IconRefresh} - onClick={handleRetry} - disabled={isStreaming} + onClick={handleRetryClick} + disabled={agentChatIsStreaming} title={t`Retry`} /> diff --git a/packages/twenty-front/src/modules/ai/components/AIChatErrorUnderMessageList.tsx b/packages/twenty-front/src/modules/ai/components/AIChatErrorUnderMessageList.tsx new file mode 100644 index 0000000000..1e17d8a833 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/AIChatErrorUnderMessageList.tsx @@ -0,0 +1,35 @@ +import { AIChatStandaloneError } from '@/ai/components/AIChatStandaloneError'; +import { AgentMessageRole } from '@/ai/constants/AgentMessageRole'; +import { agentChatErrorState } from '@/ai/states/agentChatErrorState'; +import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState'; +import { agentChatMessageComponentFamilySelector } from '@/ai/states/agentChatMessageComponentFamilySelector'; +import { agentChatMessageIdsComponentSelector } from '@/ai/states/agentChatMessageIdsComponentSelector'; +import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue'; +import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; + +export const AIChatErrorUnderMessageList = () => { + const agentChatError = useAtomStateValue(agentChatErrorState); + const agentChatIsStreaming = useAtomStateValue(agentChatIsStreamingState); + + const agentChatMessageIds = useAtomComponentSelectorValue( + agentChatMessageIdsComponentSelector, + ); + + const lastMessageId = agentChatMessageIds.at(-1); + const agentChatMessage = useAtomComponentFamilySelectorValue( + agentChatMessageComponentFamilySelector, + { messageId: lastMessageId }, + ); + + const showError = + agentChatError && + !agentChatIsStreaming && + agentChatMessage?.role === AgentMessageRole.USER; + + if (!showError) { + return null; + } + + return ; +}; diff --git a/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx index 95820d19d7..b12172e210 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx @@ -5,9 +5,16 @@ import { AgentMessageRole } from '@/ai/constants/AgentMessageRole'; import { AIChatAssistantMessageRenderer } from '@/ai/components/AIChatAssistantMessageRenderer'; import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer'; +import { agentChatErrorState } from '@/ai/states/agentChatErrorState'; +import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState'; +import { agentChatMessageComponentFamilySelector } from '@/ai/states/agentChatMessageComponentFamilySelector'; +import { agentChatMessageIdsComponentSelector } from '@/ai/states/agentChatMessageIdsComponentSelector'; import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton'; +import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue'; +import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { isExtendedFileUIPart, type ExtendedUIMessage } from 'twenty-shared/ai'; + +import { isExtendedFileUIPart } from 'twenty-shared/ai'; import { isDefined } from 'twenty-shared/utils'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { dateLocaleState } from '~/localization/states/dateLocaleState'; @@ -136,31 +143,45 @@ const StyledFilesContainer = styled.div` margin-top: ${themeCssVariables.spacing[2]}; `; -export const AIChatMessage = ({ - message, - isLastMessageStreaming, - error, -}: { - message: ExtendedUIMessage; - isLastMessageStreaming: boolean; - error?: Error | null; -}) => { +export const AIChatMessage = ({ messageId }: { messageId: string }) => { + const agentChatMessage = useAtomComponentFamilySelectorValue( + agentChatMessageComponentFamilySelector, + { messageId }, + ); + + const agentChatMessageIds = useAtomComponentSelectorValue( + agentChatMessageIdsComponentSelector, + ); + + const agentChatIsStreaming = useAtomStateValue(agentChatIsStreamingState); + + const agentChatError = useAtomStateValue(agentChatErrorState); + const { localeCatalog } = useAtomStateValue(dateLocaleState); - const isUser = message.role === AgentMessageRole.USER; - const showError = - isDefined(error) && message.role === AgentMessageRole.ASSISTANT; + if (!isDefined(agentChatMessage)) { + return null; + } - const fileParts = message.parts.filter(isExtendedFileUIPart); + const isLastMessage = agentChatMessageIds.at(-1) === messageId; + + const isLastMessageStreaming = agentChatIsStreaming && isLastMessage; + const isLastAssistantMessage = + isLastMessage && agentChatMessage?.role === AgentMessageRole.ASSISTANT; + const shouldShowError = isDefined(agentChatError) && isLastAssistantMessage; + + const isUser = agentChatMessage.role === AgentMessageRole.USER; + + const fileParts = agentChatMessage.parts.filter(isExtendedFileUIPart); return ( - + {fileParts.length > 0 && ( @@ -170,23 +191,25 @@ export const AIChatMessage = ({ ))} )} - {showError && } + {shouldShowError && } - {message.parts.length > 0 && message.metadata?.createdAt && ( - - - {beautifyPastDateRelativeToNow( - message.metadata?.createdAt, - localeCatalog, - )} - - part.type === 'text')?.text ?? '' - } - /> - - )} + {agentChatMessage.parts.length > 0 && + agentChatMessage.metadata?.createdAt && ( + + + {beautifyPastDateRelativeToNow( + agentChatMessage.metadata?.createdAt, + localeCatalog, + )} + + part.type === 'text') + ?.text ?? '' + } + /> + + )} ); }; diff --git a/packages/twenty-front/src/modules/ai/components/AIChatStandaloneError.tsx b/packages/twenty-front/src/modules/ai/components/AIChatStandaloneError.tsx index 7fc55fd2c2..965c09c20e 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatStandaloneError.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatStandaloneError.tsx @@ -1,6 +1,12 @@ import { styled } from '@linaria/react'; import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer'; +import { agentChatErrorState } from '@/ai/states/agentChatErrorState'; +import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector'; +import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState'; +import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { isDefined } from 'twenty-shared/utils'; const StyledErrorContainer = styled.div` display: flex; @@ -9,16 +15,25 @@ const StyledErrorContainer = styled.div` width: 100%; `; -type AIChatStandaloneErrorProps = { - error: Error; -}; +export const AIChatStandaloneError = () => { + const agentChatIsLoading = useAtomStateValue(agentChatIsLoadingState); + + const agentChatError = useAtomStateValue(agentChatErrorState); + + const hasMessages = useAtomComponentSelectorValue( + agentChatHasMessageComponentSelector, + ); + + const shouldRender = + !hasMessages && isDefined(agentChatError) && !agentChatIsLoading; + + if (!shouldRender) { + return null; + } -export const AIChatStandaloneError = ({ - error, -}: AIChatStandaloneErrorProps) => { return ( - + ); }; diff --git a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx index f2a5ef8f0e..d7b03a7328 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx @@ -7,20 +7,16 @@ import { themeCssVariables } from 'twenty-ui/theme-constants'; import { DropZone } from '@/activities/files/components/DropZone'; import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton'; import { useAiModelLabel } from '@/ai/hooks/useAiModelOptions'; -import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState'; -import { AIChatMessage } from '@/ai/components/AIChatMessage'; import { AIChatStandaloneError } from '@/ai/components/AIChatStandaloneError'; +import { AIChatTabMessageList } from '@/ai/components/AIChatTabMessageList'; import { AIChatContextUsageButton } from '@/ai/components/internal/AIChatContextUsageButton'; import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader'; import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview'; import { SendMessageButton } from '@/ai/components/internal/SendMessageButton'; -import { AgentMessageRole } from '@/ai/constants/AgentMessageRole'; -import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId'; import { useAIChatEditor } from '@/ai/hooks/useAIChatEditor'; import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload'; -import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; @@ -100,16 +96,6 @@ const StyledEditorWrapper = styled.div` } `; -const StyledScrollWrapper = styled(ScrollWrapper)` - display: flex; - flex: 1; - flex-direction: column; - gap: ${themeCssVariables.spacing[2]}; - overflow-y: auto; - padding: ${themeCssVariables.spacing[3]}; - width: calc(100% - 24px) !important; -`; - const StyledButtonsContainer = styled.div` align-items: center; display: flex; @@ -141,17 +127,12 @@ const StyledReadOnlyModelButton = styled(LightButton)` export const AIChatTab = () => { const [isDraggingFile, setIsDraggingFile] = useState(false); const isMobile = useIsMobile(); - const { isLoading, messages, isStreaming, error, handleSendMessage } = - useAgentChatContextOrThrow(); - const hasMessages = messages.length > 0; const { uploadFiles } = useAIChatFileUpload(); const currentWorkspace = useAtomStateValue(currentWorkspaceState); const smartModelLabel = useAiModelLabel(currentWorkspace?.smartModel, false); - const { editor, handleSendAndClear } = useAIChatEditor({ - onSendMessage: handleSendMessage, - }); + const { editor, handleSendAndClear } = useAIChatEditor(); return ( { )} {!isDraggingFile && ( <> - {hasMessages && ( - - {messages.map((message, index) => { - const isLastMessage = index === messages.length - 1; - const isLastMessageStreaming = isStreaming && isLastMessage; - const isLastAssistantMessage = - isLastMessage && message.role === AgentMessageRole.ASSISTANT; - const shouldShowError = error && isLastAssistantMessage; - - return ( - - ); - })} - {error && - !isStreaming && - messages.at(-1)?.role === AgentMessageRole.USER && ( - - )} - - )} - {!hasMessages && !error && !isLoading && ( - - )} - {!hasMessages && error && !isLoading && ( - - )} - {isLoading && !hasMessages && } - + + + + @@ -210,7 +160,7 @@ export const AIChatTab = () => { - {hasMessages && } + { + const agentChatMessageIdsComponent = useAtomComponentSelectorValue( + agentChatMessageIdsComponentSelector, + ); + + const hasMessages = isNonEmptyArray(agentChatMessageIdsComponent); + + if (!hasMessages) { + return null; + } + + return ( + + {agentChatMessageIdsComponent.map((messageId) => { + return ; + })} + + + ); +}; diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatDataEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatDataEffect.tsx new file mode 100644 index 0000000000..3dbad9f67a --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/AgentChatDataEffect.tsx @@ -0,0 +1,81 @@ +import { useAgentChat } from '@/ai/hooks/useAgentChat'; +import { useAgentChatData } from '@/ai/hooks/useAgentChatData'; +import { useAgentChatScrollToBottom } from '@/ai/hooks/useAgentChatScrollToBottom'; +import { useProcessIncrementalStreamMessages } from '@/ai/hooks/useProcessIncrementalStreamMessages'; +import { agentChatErrorState } from '@/ai/states/agentChatErrorState'; +import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState'; +import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState'; +import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState'; +import { agentChatUISessionStartTimeState } from '@/ai/states/agentChatUISessionStartTimeState'; +import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState'; +import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; +import { useEffect } from 'react'; +import { Temporal } from 'temporal-polyfill'; + +export const AgentChatDataEffect = () => { + const { uiMessages, isLoading } = useAgentChatData(); + const chatState = useAgentChat(uiMessages); + + const combinedIsLoading = chatState.isLoading || isLoading; + const isStreaming = chatState.status === 'streaming'; + + const setAgentChatIsLoading = useSetAtomState(agentChatIsLoadingState); + + const setAgentChatError = useSetAtomState(agentChatErrorState); + + const [agentChatUISessionStartTime, setAgentChatUISessionStartTime] = + useAtomState(agentChatUISessionStartTimeState); + + const [, setAgentChatMessages] = useAtomComponentState( + agentChatMessagesComponentState, + ); + + useEffect(() => { + setAgentChatMessages(chatState.messages); + }, [chatState.messages, setAgentChatMessages]); + + useEffect(() => { + setAgentChatIsLoading(combinedIsLoading); + }, [combinedIsLoading, setAgentChatIsLoading]); + + useEffect(() => { + setAgentChatError(chatState.error); + }, [chatState.error, setAgentChatError]); + + useEffect(() => { + if (agentChatUISessionStartTime === null) { + setAgentChatUISessionStartTime(Temporal.Now.instant()); + } + }, [agentChatUISessionStartTime, setAgentChatUISessionStartTime]); + + const setAgentChatIsStreaming = useSetAtomState(agentChatIsStreamingState); + + useEffect(() => { + setAgentChatIsStreaming(isStreaming); + }, [setAgentChatIsStreaming, isStreaming]); + + const { scrollToBottom, isNearBottom } = useAgentChatScrollToBottom(); + + const { processIncrementalStreamMessages } = + useProcessIncrementalStreamMessages(); + + useEffect(() => { + if (chatState.messages.length === 0) { + return; + } + + if (isNearBottom) { + scrollToBottom(); + } + + processIncrementalStreamMessages(chatState.messages); + }, [ + chatState.messages, + scrollToBottom, + isNearBottom, + processIncrementalStreamMessages, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatMessagesEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatMessagesEffect.tsx deleted file mode 100644 index 6ea6a5632f..0000000000 --- a/packages/twenty-front/src/modules/ai/components/AgentChatMessagesEffect.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useAgentChatScrollToBottom } from '@/ai/hooks/useAgentChatScrollToBottom'; -import { useEffect, useState } from 'react'; -import { type ExtendedUIMessage } from 'twenty-shared/ai'; -import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; - -export const AgentChatMessagesEffect = ({ - messages, -}: { - messages: ExtendedUIMessage[]; -}) => { - const { scrollToBottom, isNearBottom } = useAgentChatScrollToBottom(); - const [, setPreviousMessages] = useState(null); - - useEffect(() => { - setPreviousMessages((previousMessages) => { - if ( - previousMessages !== null && - isDeeplyEqual(previousMessages, messages) - ) { - return previousMessages; - } - - // We intentionally force this effect because the chat transport streams messages incrementally - // and the only reliable way to react to those chunks is through useEffect updates. - - const isNewMessage = - previousMessages === null || - messages.length !== previousMessages.length; - - if (isNewMessage || isNearBottom) { - scrollToBottom(); - } - - return messages; - }); - }, [messages, scrollToBottom, isNearBottom]); - - return null; -}; diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatProvider.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatProvider.tsx index c482a5c978..412685c518 100644 --- a/packages/twenty-front/src/modules/ai/components/AgentChatProvider.tsx +++ b/packages/twenty-front/src/modules/ai/components/AgentChatProvider.tsx @@ -1,33 +1,10 @@ -import { AgentChatMessagesEffect } from '@/ai/components/AgentChatMessagesEffect'; -import { AgentChatContext } from '@/ai/contexts/AgentChatContext'; -import { useAgentChat } from '@/ai/hooks/useAgentChat'; -import { useAgentChatData } from '@/ai/hooks/useAgentChatData'; +import { AgentChatDataEffect } from '@/ai/components/AgentChatDataEffect'; +import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext'; + import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { Suspense } from 'react'; import { FeatureFlagKey } from '~/generated-metadata/graphql'; -const AgentChatProviderContent = ({ - children, -}: { - children: React.ReactNode; -}) => { - const { uiMessages, isLoading } = useAgentChatData(); - const chatState = useAgentChat(uiMessages); - const combinedIsLoading = chatState.isLoading || isLoading; - - return ( - - - {children} - - ); -}; - export const AgentChatProvider = ({ children, }: { @@ -36,16 +13,17 @@ export const AgentChatProvider = ({ const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); if (!isAiEnabled) { - return ( - - {children} - - ); + return <>{children}; } return ( - {children} + + + {children} + ); }; diff --git a/packages/twenty-front/src/modules/ai/components/RecordLink.tsx b/packages/twenty-front/src/modules/ai/components/RecordLink.tsx index 0019480444..d40a0727d2 100644 --- a/packages/twenty-front/src/modules/ai/components/RecordLink.tsx +++ b/packages/twenty-front/src/modules/ai/components/RecordLink.tsx @@ -1,5 +1,6 @@ -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage'; +import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue'; import { t } from '@lingui/core/macro'; import { isNonEmptyString } from '@sniptt/guards'; import { AvatarOrIcon, ChipVariant, LinkChip } from 'twenty-ui/components'; @@ -15,9 +16,13 @@ export const RecordLink = ({ recordId, displayName, }: RecordLinkProps) => { - const { objectMetadataItem } = useObjectMetadataItem({ - objectNameSingular, - }); + const objectMetadataItem = useAtomFamilySelectorValue( + objectMetadataItemFamilySelector, + { + objectName: objectNameSingular, + objectNameType: 'singular', + }, + ); if (!objectMetadataItem || !isNonEmptyString(recordId)) { return {displayName}; diff --git a/packages/twenty-front/src/modules/ai/components/__stories__/AIChatMessage.stories.tsx b/packages/twenty-front/src/modules/ai/components/__stories__/AIChatMessage.stories.tsx index aec40c4e44..90a28e68bd 100644 --- a/packages/twenty-front/src/modules/ai/components/__stories__/AIChatMessage.stories.tsx +++ b/packages/twenty-front/src/modules/ai/components/__stories__/AIChatMessage.stories.tsx @@ -1,10 +1,20 @@ -import { styled } from '@linaria/react'; -import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { + type Decorator, + type Meta, + type StoryObj, +} from '@storybook/react-vite'; +import { useEffect } from 'react'; import { userEvent, within } from 'storybook/test'; import { type ExtendedUIMessage } from 'twenty-shared/ai'; import { ComponentDecorator } from 'twenty-ui/testing'; import { AIChatMessage } from '@/ai/components/AIChatMessage'; + +import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext'; +import { agentChatMessageComponentFamilyState } from '@/ai/states/agentChatMessageComponentFamilyState'; +import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState'; +import { styled } from '@linaria/react'; +import { useStore } from 'jotai'; import { RootDecorator } from '~/testing/decorators/RootDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; @@ -16,6 +26,8 @@ const StyledConversationContainer = styled.div` padding: 24px; `; +const INSTANCE_ID = 'agentChatStoryInstance'; + // Mock messages for the conversation showcase const mockUserMessage: ExtendedUIMessage = { id: 'msg-user-1', @@ -224,10 +236,62 @@ const mockThinkingStepsDone: ExtendedUIMessage = { }, }; +const allMockMessages = [ + mockUserMessage, + mockAssistantWithCodeExecution, + mockSimpleTextResponse, + mockStreamingMessage, + mockCodeExecutionRunning, + mockCodeExecutionError, + mockThinkingStepsStreaming, + mockThinkingStepsDone, +]; + +const AgentChatMessagesSetterEffect = ({ + messages, +}: { + messages: ExtendedUIMessage[]; +}) => { + const store = useStore(); + + useEffect(() => { + store.set( + agentChatMessagesComponentState.atomFamily({ instanceId: INSTANCE_ID }), + messages, + ); + + for (const message of messages) { + store.set( + agentChatMessageComponentFamilyState.atomFamily({ + instanceId: INSTANCE_ID, + familyKey: message.id, + }), + message, + ); + } + }, [messages, store]); + + return null; +}; + +const AgentChatInstanceDecorator: Decorator = (Story) => ( + + + + +); + const meta: Meta = { title: 'Modules/AI/AIChatMessage', component: AIChatMessage, - decorators: [ComponentDecorator, RootDecorator, SnackBarDecorator], + decorators: [ + ComponentDecorator, + RootDecorator, + SnackBarDecorator, + AgentChatInstanceDecorator, + ], parameters: { container: { width: 700 }, }, @@ -240,69 +304,42 @@ type Story = StoryObj; export const ConversationWithCodeExecution: Story = { render: () => ( - - + + ), }; export const UserMessage: Story = { - args: { - message: mockUserMessage, - isLastMessageStreaming: false, - }, + render: () => , }; export const AssistantTextResponse: Story = { - args: { - message: mockSimpleTextResponse, - isLastMessageStreaming: false, - }, + render: () => , }; export const AssistantStreaming: Story = { - args: { - message: mockStreamingMessage, - isLastMessageStreaming: true, - }, + render: () => , }; export const CodeExecutionRunning: Story = { - args: { - message: mockCodeExecutionRunning, - isLastMessageStreaming: false, - }, + render: () => , }; export const CodeExecutionWithError: Story = { - args: { - message: mockCodeExecutionError, - isLastMessageStreaming: false, - }, + render: () => , }; export const ThinkingStepsThinkingState: Story = { - args: { - message: mockThinkingStepsStreaming, - isLastMessageStreaming: true, - }, + render: () => , }; export const ThinkingStepsDoneCollapsed: Story = { - args: { - message: mockThinkingStepsDone, - isLastMessageStreaming: false, - }, + render: () => , }; export const ThinkingStepsDoneExpanded: Story = { - args: { - message: mockThinkingStepsDone, - isLastMessageStreaming: false, - }, + render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); const summaryButton = await canvas.findByRole('button', { diff --git a/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx b/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx index a69d5c2cc4..8ff23b5aa9 100644 --- a/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx +++ b/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx @@ -9,11 +9,13 @@ import { ThemeContext } from 'twenty-ui/theme'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { ContextUsageProgressRing } from '@/ai/components/internal/ContextUsageProgressRing'; -import { SettingsBillingLabelValueItem } from '@/billing/components/internal/SettingsBillingLabelValueItem'; +import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector'; import { agentChatUsageState, type AgentChatLastMessageUsage, } from '@/ai/states/agentChatUsageState'; +import { SettingsBillingLabelValueItem } from '@/billing/components/internal/SettingsBillingLabelValueItem'; +import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; const StyledContainer = styled.div` @@ -118,6 +120,14 @@ export const AIChatContextUsageButton = () => { const [isHovered, setIsHovered] = useState(false); const agentChatUsage = useAtomStateValue(agentChatUsageState); + const hasMessages = useAtomComponentSelectorValue( + agentChatHasMessageComponentSelector, + ); + + if (!hasMessages) { + return null; + } + if (!agentChatUsage) { return ( diff --git a/packages/twenty-front/src/modules/ai/components/internal/AIChatSkeletonLoader.tsx b/packages/twenty-front/src/modules/ai/components/internal/AIChatSkeletonLoader.tsx index 70677d08b6..79663c48e1 100644 --- a/packages/twenty-front/src/modules/ai/components/internal/AIChatSkeletonLoader.tsx +++ b/packages/twenty-front/src/modules/ai/components/internal/AIChatSkeletonLoader.tsx @@ -1,3 +1,7 @@ +import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector'; +import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState'; +import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { styled } from '@linaria/react'; import { useContext } from 'react'; import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; @@ -27,6 +31,18 @@ const NUMBER_OF_SKELETONS = 6; export const AIChatSkeletonLoader = () => { const { theme } = useContext(ThemeContext); + const agentChatIsLoading = useAtomStateValue(agentChatIsLoadingState); + + const hasMessages = useAtomComponentSelectorValue( + agentChatHasMessageComponentSelector, + ); + + const shouldRender = agentChatIsLoading && !hasMessages; + + if (!shouldRender) { + return null; + } + return ( { - const agentChatInput = useAtomStateValue(agentChatInputState); - const { handleStop, isLoading, isStreaming } = useAgentChatContextOrThrow(); + const agentChatInputIsEmpty = useAtomStateValue( + agentChatInputIsEmptySelector, + ); - if (isStreaming) { + const agentChatIsLoading = useAtomStateValue(agentChatIsLoadingState); + + const agentChatIsStreaming = useAtomStateValue(agentChatIsStreamingState); + + const handleStopClick = () => { + dispatchBrowserEvent(AGENT_CHAT_STOP_EVENT_NAME); + }; + + if (agentChatIsStreaming) { return ( handleStop()} + onClick={handleStopClick} /> ); } @@ -27,7 +39,7 @@ export const SendMessageButton = ({ onSend }: SendMessageButtonProps) => { Icon={IconArrowUp} size="medium" onClick={onSend} - disabled={!agentChatInput || isLoading} + disabled={agentChatInputIsEmpty || agentChatIsLoading} /> ); }; diff --git a/packages/twenty-front/src/modules/ai/components/suggested-prompts/default-suggested-prompts.ts b/packages/twenty-front/src/modules/ai/components/suggested-prompts/default-suggested-prompts.ts index fbb7b45306..4b34a4c84e 100644 --- a/packages/twenty-front/src/modules/ai/components/suggested-prompts/default-suggested-prompts.ts +++ b/packages/twenty-front/src/modules/ai/components/suggested-prompts/default-suggested-prompts.ts @@ -2,6 +2,7 @@ import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { type IconComponent, + IconJetpack, IconLayoutDashboard, IconPlus, IconSettingsAutomation, @@ -15,6 +16,14 @@ export type SuggestedPrompt = { }; export const DEFAULT_SUGGESTED_PROMPTS: SuggestedPrompt[] = [ + { + id: 'demo-workspace', + label: msg`Setup a tailor-made workspace`, + Icon: IconJetpack, + prefillPrompts: [ + msg`Seed a demo workspace using the dedicated tool, ask me questions about my activity and goals to tailor the seeded data to my needs, and then help me explore the workspace and data you created.`, + ], + }, { id: 'dashboard', label: msg`Create a dashboard`, diff --git a/packages/twenty-front/src/modules/ai/constants/AgentChatRetryEventName.ts b/packages/twenty-front/src/modules/ai/constants/AgentChatRetryEventName.ts new file mode 100644 index 0000000000..1d908e2cb8 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/AgentChatRetryEventName.ts @@ -0,0 +1 @@ +export const AGENT_CHAT_RETRY_EVENT_NAME = 'agent-chat-retry' as const; diff --git a/packages/twenty-front/src/modules/ai/constants/AgentChatSendMessageEventName.ts b/packages/twenty-front/src/modules/ai/constants/AgentChatSendMessageEventName.ts new file mode 100644 index 0000000000..21f65d9b8a --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/AgentChatSendMessageEventName.ts @@ -0,0 +1,2 @@ +export const AGENT_CHAT_SEND_MESSAGE_EVENT_NAME = + 'agent-chat-send-message' as const; diff --git a/packages/twenty-front/src/modules/ai/constants/AgentChatStopEventName.ts b/packages/twenty-front/src/modules/ai/constants/AgentChatStopEventName.ts new file mode 100644 index 0000000000..ff9901673c --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/AgentChatStopEventName.ts @@ -0,0 +1 @@ +export const AGENT_CHAT_STOP_EVENT_NAME = 'agent-chat-stop' as const; diff --git a/packages/twenty-front/src/modules/ai/contexts/AgentChatContext.ts b/packages/twenty-front/src/modules/ai/contexts/AgentChatContext.ts deleted file mode 100644 index 68d058ea77..0000000000 --- a/packages/twenty-front/src/modules/ai/contexts/AgentChatContext.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createContext } from 'react'; -import { type ExtendedUIMessage } from 'twenty-shared/ai'; - -export type AgentChatContextValue = { - messages: ExtendedUIMessage[]; - isStreaming: boolean; - isLoading: boolean; - error?: Error; - - handleSendMessage: () => Promise; - handleStop: () => void; - handleRetry: () => void; -}; - -export const AgentChatContext = createContext( - null, -); diff --git a/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts b/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts index af37c785fc..5c82572d97 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts @@ -10,6 +10,7 @@ import { isDefined } from 'twenty-shared/utils'; import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId'; import { agentChatInputState } from '@/ai/states/agentChatInputState'; +import { dispatchAgentChatSendMessageEvent } from '@/ai/utils/dispatchAgentChatSendMessageEvent'; import { MENTION_SUGGESTION_PLUGIN_KEY } from '@/mention/constants/MentionSuggestionPluginKey'; import { MentionSuggestion } from '@/mention/extensions/MentionSuggestion'; import { MentionTag } from '@/mention/extensions/MentionTag'; @@ -20,11 +21,7 @@ import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentTyp import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly'; -type UseAIChatEditorProps = { - onSendMessage: () => void; -}; - -export const useAIChatEditor = ({ onSendMessage }: UseAIChatEditorProps) => { +export const useAIChatEditor = () => { const setAgentChatInput = useSetAtomState(agentChatInputState); const { searchMentionRecords } = useMentionSearch(); const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); @@ -61,7 +58,7 @@ export const useAIChatEditor = ({ onSendMessage }: UseAIChatEditorProps) => { } event.preventDefault(); - onSendMessage(); + dispatchAgentChatSendMessageEvent(); const { state } = view; view.dispatch(state.tr.delete(0, state.doc.content.size)); @@ -108,9 +105,9 @@ export const useAIChatEditor = ({ onSendMessage }: UseAIChatEditorProps) => { } const handleSendAndClear = useCallback(() => { - onSendMessage(); + dispatchAgentChatSendMessageEvent(); editor?.commands.clearContent(); - }, [onSendMessage, editor]); + }, [editor]); return { editor, handleSendAndClear }; }; diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts index fc4cfd668e..71674cb3d2 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts @@ -1,3 +1,4 @@ +import { AGENT_CHAT_SEND_MESSAGE_EVENT_NAME } from '@/ai/constants/AgentChatSendMessageEventName'; import { useApolloClient } from '@apollo/client'; import { useGetBrowsingContext } from '@/ai/hooks/useBrowsingContext'; @@ -7,16 +8,20 @@ import { agentChatUsageState } from '@/ai/states/agentChatUsageState'; import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState'; import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState'; +import { AGENT_CHAT_RETRY_EVENT_NAME } from '@/ai/constants/AgentChatRetryEventName'; +import { AGENT_CHAT_STOP_EVENT_NAME } from '@/ai/constants/AgentChatStopEventName'; import { agentChatInputState } from '@/ai/states/agentChatInputState'; import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url'; import { getTokenPair } from '@/apollo/utils/getTokenPair'; import { renewToken } from '@/auth/services/AuthService'; import { tokenPairState } from '@/auth/states/tokenPairState'; +import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent'; import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; +import { useCallback } from 'react'; import { type ExtendedUIMessage } from 'twenty-shared/ai'; import { isDefined } from 'twenty-shared/utils'; import { REACT_APP_SERVER_BASE_URL } from '~/config'; @@ -183,15 +188,15 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => { }); const isStreaming = status === 'streaming'; - const isLoading = isStreaming || agentChatSelectedFiles.length > 0; - const handleSendMessage = async () => { + const handleSendMessage = useCallback(async () => { if (agentChatInput.trim() === '' || isLoading || !currentAIChatThread) { return; } const content = agentChatInput.trim(); + setAgentChatInput(''); const browsingContext = getBrowsingContext(); @@ -208,16 +213,40 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => { }, }, ); + setAgentChatUploadedFiles([]); - }; + }, [ + agentChatInput, + isLoading, + currentAIChatThread, + setAgentChatInput, + getBrowsingContext, + sendMessage, + agentChatUploadedFiles, + setAgentChatUploadedFiles, + ]); + + useListenToBrowserEvent({ + eventName: AGENT_CHAT_SEND_MESSAGE_EVENT_NAME, + onBrowserEvent: handleSendMessage, + }); + + useListenToBrowserEvent({ + eventName: AGENT_CHAT_STOP_EVENT_NAME, + onBrowserEvent: stop, + }); + + useListenToBrowserEvent({ + eventName: AGENT_CHAT_RETRY_EVENT_NAME, + onBrowserEvent: regenerate, + }); return { messages, handleSendMessage, handleStop: stop, isLoading, - isStreaming, error, - handleRetry: regenerate, + status, }; }; diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChatContextOrThrow.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChatContextOrThrow.ts deleted file mode 100644 index c790ad7495..0000000000 --- a/packages/twenty-front/src/modules/ai/hooks/useAgentChatContextOrThrow.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { AgentChatContext } from '@/ai/contexts/AgentChatContext'; -import { useContext } from 'react'; - -export const useAgentChatContextOrThrow = () => { - const context = useContext(AgentChatContext); - - if (!context) { - throw new Error( - 'AgentChatContext not found. Please wrap your component tree with before using useAgentChatContextOrThrow().', - ); - } - - return context; -}; diff --git a/packages/twenty-front/src/modules/ai/hooks/useCreateNewAIChatThread.ts b/packages/twenty-front/src/modules/ai/hooks/useCreateNewAIChatThread.ts index 696170c7fb..2e50557d83 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useCreateNewAIChatThread.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useCreateNewAIChatThread.ts @@ -29,6 +29,7 @@ export const useCreateNewAIChatThread = () => { setCurrentAIChatThread(data.createChatThread.id); setCurrentAIChatThreadTitle(null); setAgentChatUsage(null); + openAskAIPage({ resetNavigationStack: false }); const newThread = data.createChatThread; diff --git a/packages/twenty-front/src/modules/ai/hooks/useProcessIncrementalStreamMessages.ts b/packages/twenty-front/src/modules/ai/hooks/useProcessIncrementalStreamMessages.ts new file mode 100644 index 0000000000..955fa937a5 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/hooks/useProcessIncrementalStreamMessages.ts @@ -0,0 +1,57 @@ +import { useProcessNewMessageStreamIncrement } from '@/ai/hooks/useProcessNewMessageStreamIncrement'; +import { agentChatMessageComponentFamilyState } from '@/ai/states/agentChatMessageComponentFamilyState'; +import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState'; +import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore'; +import { cloneDeep } from '@apollo/client/utilities'; +import { useCallback } from 'react'; +import { type ExtendedUIMessage } from 'twenty-shared/ai'; +import { isDefined } from 'twenty-shared/utils'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; + +export const useProcessIncrementalStreamMessages = () => { + const agentChatMessageFamilyCallbackState = + useAtomComponentFamilyStateCallbackState( + agentChatMessageComponentFamilyState, + ); + + const { processNewMessageStreamIncrement } = + useProcessNewMessageStreamIncrement(); + + const processIncrementalStreamMessages = useCallback( + (incrementalStreamMessages: ExtendedUIMessage[]) => { + for (const updatedMessage of incrementalStreamMessages) { + const alreadyExistingMessage = jotaiStore.get( + agentChatMessageFamilyCallbackState(updatedMessage.id), + ); + + const messageContentHasChanged = !isDeeplyEqual( + alreadyExistingMessage, + updatedMessage, + ); + + const messageAlreadyExists = isDefined(alreadyExistingMessage); + + const shouldProcessMessage = + !messageAlreadyExists || messageContentHasChanged; + + if (!shouldProcessMessage) { + continue; + } + + const clonedMessage = cloneDeep(updatedMessage); + + jotaiStore.set( + agentChatMessageFamilyCallbackState(updatedMessage.id), + clonedMessage, + ); + + processNewMessageStreamIncrement(updatedMessage); + } + }, + [agentChatMessageFamilyCallbackState, processNewMessageStreamIncrement], + ); + + return { + processIncrementalStreamMessages, + }; +}; diff --git a/packages/twenty-front/src/modules/ai/hooks/useProcessNewMessageStreamIncrement.ts b/packages/twenty-front/src/modules/ai/hooks/useProcessNewMessageStreamIncrement.ts new file mode 100644 index 0000000000..86b38bd526 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/hooks/useProcessNewMessageStreamIncrement.ts @@ -0,0 +1,47 @@ +import { useProcessUIToolCallMessage } from '@/ai/hooks/useProcessUIToolCallMessage'; +import { agentChatUISessionStartTimeState } from '@/ai/states/agentChatUISessionStartTimeState'; +import { isUIToolCallMessage } from '@/ai/utils/isUIToolCallMessage'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { isNonEmptyString } from '@sniptt/guards'; +import { Temporal } from 'temporal-polyfill'; +import { type ExtendedUIMessage } from 'twenty-shared/ai'; + +export const useProcessNewMessageStreamIncrement = () => { + const agentChatUISessionStartTime = useAtomStateValue( + agentChatUISessionStartTimeState, + ); + + const { processUIToolCallMessage } = useProcessUIToolCallMessage(); + + const processNewMessageStreamIncrement = ( + messageStreamIncrement: ExtendedUIMessage, + ) => { + if (agentChatUISessionStartTime === null) { + return false; + } + + const messageCreatedAt = messageStreamIncrement.metadata?.createdAt; + + if (isNonEmptyString(messageCreatedAt)) { + const messageCreatedAtInstant = Temporal.Instant.from(messageCreatedAt); + + const messageIsAfterChatSessionStart = + messageCreatedAtInstant.epochNanoseconds >= + agentChatUISessionStartTime.epochNanoseconds; + + if (!messageIsAfterChatSessionStart) { + return false; + } + } + + const messageIsUIToolCall = isUIToolCallMessage(messageStreamIncrement); + + if (messageIsUIToolCall) { + processUIToolCallMessage(messageStreamIncrement); + } + }; + + return { + processNewMessageStreamIncrement, + }; +}; diff --git a/packages/twenty-front/src/modules/ai/hooks/useProcessUIToolCallMessage.ts b/packages/twenty-front/src/modules/ai/hooks/useProcessUIToolCallMessage.ts new file mode 100644 index 0000000000..770202e92a --- /dev/null +++ b/packages/twenty-front/src/modules/ai/hooks/useProcessUIToolCallMessage.ts @@ -0,0 +1,99 @@ +import { processedToolExecutionPartIdsComponentState } from '@/ai/states/processedToolExecutionPartIdsComponentState'; +import { extractUIToolCallParts } from '@/ai/utils/extractUIToolCallParts'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; + +import { useStore } from 'jotai'; +import { type ExtendedUIMessage } from 'twenty-shared/ai'; +import { AppPath } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { useNavigateApp } from '~/hooks/useNavigateApp'; +import { sleep } from '~/utils/sleep'; + +export const useProcessUIToolCallMessage = () => { + const navigateApp = useNavigateApp(); + + const { objectMetadataItems } = useObjectMetadataItems(); + + const processedToolExecutionPartIdsCallbackState = + useAtomComponentStateCallbackState( + processedToolExecutionPartIdsComponentState, + ); + + const store = useStore(); + + const processUIToolCallMessage = async ( + uiToolCallMessage: ExtendedUIMessage, + ) => { + const uiToolCallMessageParts = extractUIToolCallParts( + uiToolCallMessage.parts, + ); + + const alreadyProcessedToolExecutionPartIds = store.get( + processedToolExecutionPartIdsCallbackState, + ); + + const toolCallMessagePartsToProcess = uiToolCallMessageParts.filter( + (part) => !alreadyProcessedToolExecutionPartIds.includes(part.toolCallId), + ); + + for (const toolExecutionPart of toolCallMessagePartsToProcess) { + if (!isDefined(toolExecutionPart.output)) { + continue; + } + + if (toolExecutionPart.output.result.success !== true) { + continue; + } + + store.set(processedToolExecutionPartIdsCallbackState, [ + ...alreadyProcessedToolExecutionPartIds, + toolExecutionPart.toolCallId, + ]); + + const navigateAppOutput = toolExecutionPart.output.result.result; + + switch (navigateAppOutput.action) { + case 'navigateToObject': { + const objectNamePlural = objectMetadataItems.find( + (item) => + item.nameSingular === navigateAppOutput.objectNameSingular, + )?.namePlural; + + if (!isDefined(objectNamePlural)) { + throw new Error( + `Object with singular name ${navigateAppOutput.objectNameSingular} not found, cannot navigate to object page from chat.`, + ); + } + + navigateApp(AppPath.RecordIndexPage, { + objectNamePlural: objectNamePlural, + }); + + break; + } + case 'navigateToRecord': { + navigateApp(AppPath.RecordShowPage, { + objectNameSingular: navigateAppOutput.objectNameSingular, + objectRecordId: navigateAppOutput.recordId, + }); + + break; + } + case 'navigateToView': + // TODO: implement + break; + case 'wait': { + await sleep(navigateAppOutput.durationMs); + break; + } + default: + break; + } + } + }; + + return { + processUIToolCallMessage, + }; +}; diff --git a/packages/twenty-front/src/modules/ai/states/AgentChatComponentInstanceContext.ts b/packages/twenty-front/src/modules/ai/states/AgentChatComponentInstanceContext.ts new file mode 100644 index 0000000000..31753932ff --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/AgentChatComponentInstanceContext.ts @@ -0,0 +1,4 @@ +import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext'; + +export const AgentChatComponentInstanceContext = + createComponentInstanceContext(); diff --git a/packages/twenty-front/src/modules/ai/states/agentChatErrorState.ts b/packages/twenty-front/src/modules/ai/states/agentChatErrorState.ts new file mode 100644 index 0000000000..e745ec0afc --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/agentChatErrorState.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const agentChatErrorState = createAtomState({ + key: 'agentChatErrorState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/ai/states/agentChatHasMessageComponentSelector.ts b/packages/twenty-front/src/modules/ai/states/agentChatHasMessageComponentSelector.ts new file mode 100644 index 0000000000..92f30eba84 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/agentChatHasMessageComponentSelector.ts @@ -0,0 +1,17 @@ +import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext'; +import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState'; +import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector'; +import { isNonEmptyArray } from '@sniptt/guards'; + +export const agentChatHasMessageComponentSelector = + createAtomComponentSelector({ + key: 'agentChatHasMessageComponentSelector', + componentInstanceContext: AgentChatComponentInstanceContext, + get: + ({ instanceId }) => + ({ get }) => { + const messages = get(agentChatMessagesComponentState, { instanceId }); + + return isNonEmptyArray(messages); + }, + }); diff --git a/packages/twenty-front/src/modules/ai/states/agentChatInputIsEmptySelector.ts b/packages/twenty-front/src/modules/ai/states/agentChatInputIsEmptySelector.ts new file mode 100644 index 0000000000..37035abb90 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/agentChatInputIsEmptySelector.ts @@ -0,0 +1,12 @@ +import { agentChatInputState } from '@/ai/states/agentChatInputState'; +import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector'; +import { isNonEmptyString } from '@sniptt/guards'; + +export const agentChatInputIsEmptySelector = createAtomSelector({ + key: 'agentChatInputIsEmptySelector', + get: ({ get }) => { + const agentChatInput = get(agentChatInputState); + + return !isNonEmptyString(agentChatInput); + }, +}); diff --git a/packages/twenty-front/src/modules/ai/states/agentChatIsLoadingState.ts b/packages/twenty-front/src/modules/ai/states/agentChatIsLoadingState.ts new file mode 100644 index 0000000000..4fa0aee02f --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/agentChatIsLoadingState.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const agentChatIsLoadingState = createAtomState({ + key: 'agentChatIsLoadingState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/ai/states/agentChatIsStreamingState.ts b/packages/twenty-front/src/modules/ai/states/agentChatIsStreamingState.ts new file mode 100644 index 0000000000..321be705d1 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/agentChatIsStreamingState.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const agentChatIsStreamingState = createAtomState({ + key: 'agentChatIsStreamingState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/ai/states/agentChatMessageComponentFamilySelector.ts b/packages/twenty-front/src/modules/ai/states/agentChatMessageComponentFamilySelector.ts new file mode 100644 index 0000000000..94c67e56e4 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/agentChatMessageComponentFamilySelector.ts @@ -0,0 +1,21 @@ +import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext'; +import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState'; +import { createAtomComponentFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilySelector'; +import { type ExtendedUIMessage } from 'twenty-shared/ai'; +import { type Nullable } from 'twenty-shared/types'; + +export const agentChatMessageComponentFamilySelector = + createAtomComponentFamilySelector< + Nullable, + { messageId: Nullable } + >({ + key: 'agentChatMessageComponentFamilySelector', + get: + ({ instanceId, familyKey: { messageId } }) => + ({ get }) => { + const messages = get(agentChatMessagesComponentState, { instanceId }); + + return messages.find((message) => message.id === messageId); + }, + componentInstanceContext: AgentChatComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/ai/states/agentChatMessageComponentFamilyState.ts b/packages/twenty-front/src/modules/ai/states/agentChatMessageComponentFamilyState.ts new file mode 100644 index 0000000000..46f5a0dec4 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/agentChatMessageComponentFamilyState.ts @@ -0,0 +1,10 @@ +import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext'; +import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState'; +import { type ExtendedUIMessage } from 'twenty-shared/ai'; + +export const agentChatMessageComponentFamilyState = + createAtomComponentFamilyState({ + key: 'agentChatMessageComponentFamilyState', + defaultValue: null, + componentInstanceContext: AgentChatComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/ai/states/agentChatMessageIdsComponentSelector.ts b/packages/twenty-front/src/modules/ai/states/agentChatMessageIdsComponentSelector.ts new file mode 100644 index 0000000000..6469deeb82 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/agentChatMessageIdsComponentSelector.ts @@ -0,0 +1,17 @@ +import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext'; +import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState'; +import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector'; + +export const agentChatMessageIdsComponentSelector = createAtomComponentSelector< + string[] +>({ + key: 'agentChatMessageIdsComponentSelector', + componentInstanceContext: AgentChatComponentInstanceContext, + get: + ({ instanceId }) => + ({ get }) => { + const messages = get(agentChatMessagesComponentState, { instanceId }); + + return messages.map((message) => message.id); + }, +}); diff --git a/packages/twenty-front/src/modules/ai/states/agentChatMessagesComponentState.ts b/packages/twenty-front/src/modules/ai/states/agentChatMessagesComponentState.ts index 7333116fc9..82de87ab0f 100644 --- a/packages/twenty-front/src/modules/ai/states/agentChatMessagesComponentState.ts +++ b/packages/twenty-front/src/modules/ai/states/agentChatMessagesComponentState.ts @@ -1,14 +1,11 @@ -import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext'; +import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext'; import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; -import { type AgentMessage } from '~/generated-metadata/graphql'; - -export const AgentChatMessagesComponentInstanceContext = - createComponentInstanceContext(); +import { type ExtendedUIMessage } from 'twenty-shared/ai'; export const agentChatMessagesComponentState = createAtomComponentState< - AgentMessage[] + ExtendedUIMessage[] >({ key: 'agentChatMessagesComponentState', defaultValue: [], - componentInstanceContext: AgentChatMessagesComponentInstanceContext, + componentInstanceContext: AgentChatComponentInstanceContext, }); diff --git a/packages/twenty-front/src/modules/ai/states/agentChatUISessionStartTimeState.ts b/packages/twenty-front/src/modules/ai/states/agentChatUISessionStartTimeState.ts new file mode 100644 index 0000000000..267903df88 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/agentChatUISessionStartTimeState.ts @@ -0,0 +1,8 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; +import { type Temporal } from 'temporal-polyfill'; + +export const agentChatUISessionStartTimeState = + createAtomState({ + key: 'agentChatUISessionStartTimeState', + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/ai/states/processedToolExecutionPartIdsComponentState.ts b/packages/twenty-front/src/modules/ai/states/processedToolExecutionPartIdsComponentState.ts new file mode 100644 index 0000000000..898d77ab71 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/processedToolExecutionPartIdsComponentState.ts @@ -0,0 +1,9 @@ +import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext'; +import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; + +export const processedToolExecutionPartIdsComponentState = + createAtomComponentState({ + key: 'processedToolExecutionPartIdsComponentState', + defaultValue: [], + componentInstanceContext: AgentChatComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/ai/types/AgentChatMessageUIToolCallPart.ts b/packages/twenty-front/src/modules/ai/types/AgentChatMessageUIToolCallPart.ts new file mode 100644 index 0000000000..9e6aa1c06d --- /dev/null +++ b/packages/twenty-front/src/modules/ai/types/AgentChatMessageUIToolCallPart.ts @@ -0,0 +1,15 @@ +import { type NavigateAppToolOutput } from 'twenty-shared/ai'; + +export type AgentChatMessageUIToolCallPart = { + type: 'tool-execute_tool'; + toolCallId: string; + state: string; + output: { + toolName: string; + result: { + message: string; + result: NavigateAppToolOutput; + success: boolean; + }; + }; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/dispatchAgentChatSendMessageEvent.ts b/packages/twenty-front/src/modules/ai/utils/dispatchAgentChatSendMessageEvent.ts new file mode 100644 index 0000000000..ad3a5e7cb9 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/dispatchAgentChatSendMessageEvent.ts @@ -0,0 +1,5 @@ +import { AGENT_CHAT_SEND_MESSAGE_EVENT_NAME } from '@/ai/constants/AgentChatSendMessageEventName'; + +export const dispatchAgentChatSendMessageEvent = () => { + window.dispatchEvent(new CustomEvent(AGENT_CHAT_SEND_MESSAGE_EVENT_NAME)); +}; diff --git a/packages/twenty-front/src/modules/ai/utils/extractUIToolCallParts.ts b/packages/twenty-front/src/modules/ai/utils/extractUIToolCallParts.ts new file mode 100644 index 0000000000..8550ee405c --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/extractUIToolCallParts.ts @@ -0,0 +1,14 @@ +import { type AgentChatMessageUIToolCallPart } from '@/ai/types/AgentChatMessageUIToolCallPart'; +import { type UIMessagePart } from 'ai'; + +export const extractUIToolCallParts = ( + messageParts: UIMessagePart[], +): AgentChatMessageUIToolCallPart[] => { + const uiToolCallParts = messageParts.filter( + (probablePart) => + probablePart.type === 'tool-execute_tool' && + probablePart.input?.toolName === 'navigate_app', + ) as unknown as AgentChatMessageUIToolCallPart[]; + + return uiToolCallParts; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/isUIToolCallMessage.ts b/packages/twenty-front/src/modules/ai/utils/isUIToolCallMessage.ts new file mode 100644 index 0000000000..56b6664836 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/isUIToolCallMessage.ts @@ -0,0 +1,9 @@ +import { type ExtendedUIMessage } from 'twenty-shared/ai'; + +export const isUIToolCallMessage = (message: ExtendedUIMessage) => { + return message.parts.some( + (part) => + part.type === 'tool-execute_tool' && + (part.input as any)?.toolName === 'navigate_app', + ); +}; diff --git a/packages/twenty-front/src/modules/ai/utils/mapDBMessagesToUIMessages.ts b/packages/twenty-front/src/modules/ai/utils/mapDBMessagesToUIMessages.ts index 0d8395b44f..c25525a350 100644 --- a/packages/twenty-front/src/modules/ai/utils/mapDBMessagesToUIMessages.ts +++ b/packages/twenty-front/src/modules/ai/utils/mapDBMessagesToUIMessages.ts @@ -12,5 +12,6 @@ export const mapDBMessagesToUIMessages = ( metadata: { createdAt: dbMessage.createdAt, }, + threadId: dbMessage.threadId, })); }; diff --git a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx index 25e3863c09..62cfc70882 100644 --- a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx @@ -12,6 +12,7 @@ import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoad import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState'; +import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState'; import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState'; @@ -115,11 +116,19 @@ export const PageChangeEffect = () => { isMatchingLocation(location, appPath), ); - const closeCommandMenuUnlessOnEditPage = useCallback(() => { + const closeCommandMenuUnlessNotRelevant = useCallback(() => { const currentPage = store.get(commandMenuPageState.atom); + if (currentPage === CommandMenuPages.NavigationMenuItemEdit) { return; } + + const commandMenuIsAiChat = currentPage === CommandMenuPages.AskAI; + + if (commandMenuIsAiChat) { + return; + } + closeCommandMenu(); }, [closeCommandMenu, store]); @@ -130,8 +139,8 @@ export const PageChangeEffect = () => { const { openNewRecordTitleCell } = useOpenNewRecordTitleCell(); useEffect(() => { - closeCommandMenuUnlessOnEditPage(); - }, [location.pathname, closeCommandMenuUnlessOnEditPage]); + closeCommandMenuUnlessNotRelevant(); + }, [location.pathname, closeCommandMenuUnlessNotRelevant]); useEffect(() => { if (!previousLocation || previousLocation !== location.pathname) { @@ -207,6 +216,24 @@ export const PageChangeEffect = () => { break; } case isMatchingLocation(location, AppPath.RecordShowPage): { + const isNewRecord = location.state?.isNewRecord === true; + + if ( + isNewRecord && + isDefined(location.state?.labelIdentifierFieldName) + ) { + openNewRecordTitleCell({ + recordId: location.state.objectRecordId, + fieldName: location.state.labelIdentifierFieldName, + }); + } + + const isCommandMenuOpen = store.get(isCommandMenuOpenedState.atom); + + if (isCommandMenuOpen) { + return; + } + resetFocusStackToFocusItem({ focusStackItem: { focusId: PageFocusId.RecordShowPage, @@ -220,18 +247,6 @@ export const PageChangeEffect = () => { }, }, }); - - const isNewRecord = location.state?.isNewRecord === true; - - if ( - isNewRecord && - isDefined(location.state?.labelIdentifierFieldName) - ) { - openNewRecordTitleCell({ - recordId: location.state.objectRecordId, - fieldName: location.state.labelIdentifierFieldName, - }); - } break; } case isMatchingLocation(location, AppPath.SignInUp): { @@ -376,6 +391,7 @@ export const PageChangeEffect = () => { resetFocusStackToRecordIndex, resetFocusStackToFocusItem, openNewRecordTitleCell, + store, ]); useEffect(() => { diff --git a/packages/twenty-front/src/modules/browser-event/hooks/useListenToBrowserEvent.ts b/packages/twenty-front/src/modules/browser-event/hooks/useListenToBrowserEvent.ts new file mode 100644 index 0000000000..ca08a3041b --- /dev/null +++ b/packages/twenty-front/src/modules/browser-event/hooks/useListenToBrowserEvent.ts @@ -0,0 +1,26 @@ +import { useEffect } from 'react'; + +export const useListenToBrowserEvent = ({ + onBrowserEvent, + eventName, +}: { + onBrowserEvent: (detail?: T) => void; + eventName: string; +}) => { + useEffect(() => { + const handleWindowDOMEvent = (event: CustomEvent) => { + const detail = event.detail; + + onBrowserEvent(detail); + }; + + window.addEventListener(eventName, handleWindowDOMEvent as EventListener); + + return () => { + window.removeEventListener( + eventName, + handleWindowDOMEvent as EventListener, + ); + }; + }, [eventName, onBrowserEvent]); +}; diff --git a/packages/twenty-front/src/modules/browser-event/utils/dispatchBrowserEvent.ts b/packages/twenty-front/src/modules/browser-event/utils/dispatchBrowserEvent.ts new file mode 100644 index 0000000000..e271a2e198 --- /dev/null +++ b/packages/twenty-front/src/modules/browser-event/utils/dispatchBrowserEvent.ts @@ -0,0 +1,9 @@ +import { isDefined } from 'twenty-shared/utils'; + +export const dispatchBrowserEvent = (eventName: string, detail?: T) => { + if (isDefined(detail)) { + window.dispatchEvent(new CustomEvent(eventName, { detail })); + } else { + window.dispatchEvent(new CustomEvent(eventName)); + } +}; diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutSSEEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutSSEEffect.tsx new file mode 100644 index 0000000000..3ffa51a362 --- /dev/null +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutSSEEffect.tsx @@ -0,0 +1,27 @@ +import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent'; +import { useRefreshPageLayouts } from '@/page-layout/hooks/useRefreshPageLayouts'; +import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery'; +import { AllMetadataName } from '~/generated-metadata/graphql'; + +export const PageLayoutSSEEffect = () => { + const queryId = 'page-layout-metadata-sse-effect'; + + const { refreshPageLayouts } = useRefreshPageLayouts(); + + useListenToEventsForQuery({ + queryId, + operationSignature: { + metadataName: AllMetadataName.pageLayout, + variables: {}, + }, + }); + + useListenToMetadataOperationBrowserEvent({ + metadataName: AllMetadataName.pageLayout, + onMetadataOperationBrowserEvent: () => { + refreshPageLayouts(); + }, + }); + + return null; +}; diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutTabSSEEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutTabSSEEffect.tsx new file mode 100644 index 0000000000..e07798f2a1 --- /dev/null +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutTabSSEEffect.tsx @@ -0,0 +1,27 @@ +import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent'; +import { useRefreshPageLayouts } from '@/page-layout/hooks/useRefreshPageLayouts'; +import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery'; +import { AllMetadataName } from '~/generated-metadata/graphql'; + +export const PageLayoutTabSSEEffect = () => { + const queryId = 'page-layout-tab-metadata-sse-effect'; + + const { refreshPageLayouts } = useRefreshPageLayouts(); + + useListenToEventsForQuery({ + queryId, + operationSignature: { + metadataName: AllMetadataName.pageLayoutTab, + variables: {}, + }, + }); + + useListenToMetadataOperationBrowserEvent({ + metadataName: AllMetadataName.pageLayoutTab, + onMetadataOperationBrowserEvent: () => { + refreshPageLayouts(); + }, + }); + + return null; +}; diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutWidgetSSEEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutWidgetSSEEffect.tsx new file mode 100644 index 0000000000..88d7a7f07b --- /dev/null +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutWidgetSSEEffect.tsx @@ -0,0 +1,27 @@ +import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent'; +import { useRefreshPageLayouts } from '@/page-layout/hooks/useRefreshPageLayouts'; +import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery'; +import { AllMetadataName } from '~/generated-metadata/graphql'; + +export const PageLayoutWidgetSSEEffect = () => { + const queryId = 'page-layout-widget-metadata-sse-effect'; + + const { refreshPageLayouts } = useRefreshPageLayouts(); + + useListenToEventsForQuery({ + queryId, + operationSignature: { + metadataName: AllMetadataName.pageLayoutWidget, + variables: {}, + }, + }); + + useListenToMetadataOperationBrowserEvent({ + metadataName: AllMetadataName.pageLayoutWidget, + onMetadataOperationBrowserEvent: () => { + refreshPageLayouts(); + }, + }); + + return null; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useWorkspaceSectionItems.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useWorkspaceSectionItems.ts index 268533df43..dcc0ad81b6 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useWorkspaceSectionItems.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useWorkspaceSectionItems.ts @@ -7,10 +7,10 @@ import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/u import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { coreViewsState } from '@/views/states/coreViewState'; import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; import { isDefined } from 'twenty-shared/utils'; -import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useNavigationMenuItemsByFolder } from './useNavigationMenuItemsByFolder'; import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData'; diff --git a/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForWorkspaceItems.tsx b/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForWorkspaceItems.tsx index 6eb47a242f..83d37feb9c 100644 --- a/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForWorkspaceItems.tsx +++ b/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForWorkspaceItems.tsx @@ -25,13 +25,13 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; -import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper'; import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection'; import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle'; import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { coreViewsState } from '@/views/states/coreViewState'; import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useResetFocusStackToRecordIndex.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useResetFocusStackToRecordIndex.ts index 784bfc4d80..d68a5117fc 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useResetFocusStackToRecordIndex.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useResetFocusStackToRecordIndex.ts @@ -1,11 +1,19 @@ +import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState'; import { PageFocusId } from '@/types/PageFocusId'; import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useResetFocusStackToFocusItem'; import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; +import { useStore } from 'jotai'; export const useResetFocusStackToRecordIndex = () => { const { resetFocusStackToFocusItem } = useResetFocusStackToFocusItem(); + const store = useStore(); + const resetFocusStackToRecordIndex = () => { + const isCommandMenuOpen = store.get(isCommandMenuOpenedState.atom); + + const shouldEnableGlobalHotkeys = !isCommandMenuOpen; + resetFocusStackToFocusItem({ focusStackItem: { focusId: PageFocusId.RecordIndex, @@ -14,8 +22,8 @@ export const useResetFocusStackToRecordIndex = () => { componentInstanceId: PageFocusId.RecordIndex, }, globalHotkeysConfig: { - enableGlobalHotkeysWithModifiers: true, - enableGlobalHotkeysConflictingWithKeyboard: true, + enableGlobalHotkeysWithModifiers: shouldEnableGlobalHotkeys, + enableGlobalHotkeysConflictingWithKeyboard: shouldEnableGlobalHotkeys, }, }, }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBodyEffectsWrapper.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBodyEffectsWrapper.tsx index 758b5b621d..bb6bb80672 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBodyEffectsWrapper.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBodyEffectsWrapper.tsx @@ -1,11 +1,12 @@ import { RecordTableDeactivateRecordTableRowEffect } from '@/object-record/record-table/components/RecordTableDeactivateRecordTableRowEffect'; +import { RecordTableEmptyHasNewRecordEffect } from '@/object-record/record-table/components/RecordTableEmptyHasNewRecordEffect'; import { RecordTableBodyEscapeHotkeyEffect } from '@/object-record/record-table/record-table-body/components/RecordTableBodyEscapeHotkeyEffect'; import { RecordTableBodyFocusClickOutsideEffect } from '@/object-record/record-table/record-table-body/components/RecordTableBodyFocusClickOutsideEffect'; import { RecordTableBodyFocusKeyboardEffect } from '@/object-record/record-table/record-table-body/components/RecordTableBodyFocusKeyboardEffect'; import { RecordTableRecordGroupBodyEffects } from '@/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffects'; import { RecordTableNoRecordGroupScrollToPreviousRecordEffect } from '@/object-record/record-table/virtualization/components/RecordTableNoRecordGroupScrollToPreviousRecordEffect'; -import { RecordTableVirtualizedInitialDataLoadEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedInitialDataLoadEffect'; import { RecordTableVirtualizedFieldMetadataUpdateEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedFieldMetadataUpdateEffect'; +import { RecordTableVirtualizedInitialDataLoadEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedInitialDataLoadEffect'; export interface RecordTableBodyEffectsWrapperProps { hasRecordGroups: boolean; @@ -22,6 +23,7 @@ export const RecordTableBodyEffectsWrapper = ({ ) : ( <> + diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableEmptyHasNewRecordEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableEmptyHasNewRecordEffect.tsx new file mode 100644 index 0000000000..5f940ac3fd --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableEmptyHasNewRecordEffect.tsx @@ -0,0 +1,103 @@ +import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent'; +import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail'; +import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; +import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState'; +import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies'; +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { recordIndexHasRecordsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexHasRecordsComponentSelector'; +import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; +import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState'; +import { recordTableWentFromEmptyToNotEmptyComponentState } from '@/object-record/record-table/states/recordTableWentFromEmptyToNotEmptyComponentState'; +import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery'; +import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; + +import { useStore } from 'jotai'; +import { useMemo } from 'react'; +import { computeRecordGqlOperationFilter } from 'twenty-shared/utils'; + +export const RecordTableEmptyHasNewRecordEffect = () => { + const { objectMetadataItem } = useRecordIndexContextOrThrow(); + + const store = useStore(); + + const isRecordTableInitialLoading = useAtomComponentStateValue( + isRecordTableInitialLoadingComponentState, + ); + + const recordTableHasRecords = useAtomComponentSelectorValue( + recordIndexHasRecordsComponentSelector, + ); + + const recordTableWentFromEmptyToNotEmptyCallbackState = + useAtomComponentStateCallbackState( + recordTableWentFromEmptyToNotEmptyComponentState, + ); + + const { filterValueDependencies } = useFilterValueDependencies(); + + const currentRecordFilters = useAtomComponentStateValue( + currentRecordFiltersComponentState, + ); + + const currentRecordSorts = useAtomComponentStateValue( + currentRecordSortsComponentState, + ); + + const currentRecordFilterGroups = useAtomComponentStateValue( + currentRecordFilterGroupsComponentState, + ); + + const queryId = `record-table-empty-${objectMetadataItem.nameSingular}`; + + const operationSignature = useMemo( + () => ({ + objectNameSingular: objectMetadataItem.nameSingular, + variables: { + filter: computeRecordGqlOperationFilter({ + fields: objectMetadataItem.fields, + recordFilters: currentRecordFilters, + recordFilterGroups: currentRecordFilterGroups, + filterValueDependencies, + }), + orderBy: turnSortsIntoOrderBy(objectMetadataItem, currentRecordSorts), + }, + }), + [ + objectMetadataItem, + currentRecordFilters, + currentRecordFilterGroups, + filterValueDependencies, + currentRecordSorts, + ], + ); + + useListenToEventsForQuery({ + queryId, + operationSignature, + }); + + const handleObjectRecordOperation = ( + objectRecordOperationEventDetail: ObjectRecordOperationBrowserEventDetail, + ) => { + const objectRecordOperation = objectRecordOperationEventDetail.operation; + + if ( + objectRecordOperation.type.includes('update') || + objectRecordOperation.type.includes('create') + ) { + if (!isRecordTableInitialLoading && !recordTableHasRecords) { + store.set(recordTableWentFromEmptyToNotEmptyCallbackState, true); + } + } + }; + + useListenToObjectRecordOperationBrowserEvent({ + onObjectRecordOperationBrowserEvent: handleObjectRecordOperation, + objectMetadataItemId: objectMetadataItem.id, + }); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/recordTableWentFromEmptyToNotEmptyComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/recordTableWentFromEmptyToNotEmptyComponentState.ts new file mode 100644 index 0000000000..66e2fe7827 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/recordTableWentFromEmptyToNotEmptyComponentState.ts @@ -0,0 +1,9 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; + +export const recordTableWentFromEmptyToNotEmptyComponentState = + createAtomComponentState({ + key: 'recordTableWentFromEmptyToNotEmptyComponentState', + defaultValue: false, + componentInstanceContext: RecordTableComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedInitialDataLoadEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedInitialDataLoadEffect.tsx index 3c95c5e65e..1234b7c9be 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedInitialDataLoadEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedInitialDataLoadEffect.tsx @@ -2,15 +2,16 @@ import { useRecordIndexTableFetchMore } from '@/object-record/record-index/hooks import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector'; +import { recordTableWentFromEmptyToNotEmptyComponentState } from '@/object-record/record-table/states/recordTableWentFromEmptyToNotEmptyComponentState'; import { useTriggerInitialRecordTableDataLoad } from '@/object-record/record-table/virtualization/hooks/useTriggerInitialRecordTableDataLoad'; import { isInitializingVirtualTableDataLoadingComponentState } from '@/object-record/record-table/virtualization/states/isInitializingVirtualTableDataLoadingComponentState'; import { lastContextStoreVirtualizedViewIdComponentState } from '@/object-record/record-table/virtualization/states/lastContextStoreVirtualizedViewIdComponentState'; import { lastContextStoreVirtualizedVisibleRecordFieldsComponentState } from '@/object-record/record-table/virtualization/states/lastContextStoreVirtualizedVisibleRecordFieldsComponentState'; import { lastRecordTableQueryIdentifierComponentState } from '@/object-record/record-table/virtualization/states/lastRecordTableQueryIdentifierComponentState'; import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState'; -import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState'; +import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue'; import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; import isEmpty from 'lodash.isempty'; import { useEffect } from 'react'; @@ -31,6 +32,11 @@ export const RecordTableVirtualizedInitialDataLoadEffect = () => { isInitializingVirtualTableDataLoadingComponentState, ); + const [ + recordTableWentFromEmptyToNotEmpty, + setRecordTableWentFromEmptyToNotEmpty, + ] = useAtomComponentState(recordTableWentFromEmptyToNotEmptyComponentState); + const isFetchingMoreRecords = useAtomFamilyStateValue( isFetchingMoreRecordsFamilyState, recordTableId, @@ -78,6 +84,10 @@ export const RecordTableVirtualizedInitialDataLoadEffect = () => { ) { setLastRecordTableQueryIdentifier(queryIdentifier); + await triggerInitialRecordTableDataLoad(); + } else if (recordTableWentFromEmptyToNotEmpty) { + setRecordTableWentFromEmptyToNotEmpty(false); + await triggerInitialRecordTableDataLoad(); } else if ( JSON.stringify(lastContextStoreVirtualizedVisibleRecordFields) !== @@ -98,6 +108,8 @@ export const RecordTableVirtualizedInitialDataLoadEffect = () => { } })(); }, [ + recordTableWentFromEmptyToNotEmpty, + setRecordTableWentFromEmptyToNotEmpty, queryIdentifier, lastRecordTableQueryIdentifier, triggerInitialRecordTableDataLoad, diff --git a/packages/twenty-front/src/modules/page-layout/graphql/queries/findAllPageLayouts.ts b/packages/twenty-front/src/modules/page-layout/graphql/queries/findAllPageLayouts.ts new file mode 100644 index 0000000000..7b7c4144a5 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/graphql/queries/findAllPageLayouts.ts @@ -0,0 +1,11 @@ +import { PAGE_LAYOUT_FRAGMENT } from '@/dashboards/graphql/fragments/pageLayoutFragment'; +import { gql } from '@apollo/client'; + +export const FIND_ALL_PAGE_LAYOUTS = gql` + ${PAGE_LAYOUT_FRAGMENT} + query FindAllPageLayouts { + getPageLayouts { + ...PageLayoutFragment + } + } +`; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useApplyPageLayouts.ts b/packages/twenty-front/src/modules/page-layout/hooks/useApplyPageLayouts.ts new file mode 100644 index 0000000000..ab20cf2704 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/useApplyPageLayouts.ts @@ -0,0 +1,44 @@ +import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore'; +import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState'; +import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState'; +import { recordPageLayoutsState } from '@/page-layout/states/recordPageLayoutsState'; +import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts'; +import { transformPageLayout } from '@/page-layout/utils/transformPageLayout'; +import { useStore } from 'jotai'; +import { useCallback } from 'react'; +import { type PageLayout as PageLayoutGenerated } from '~/generated-metadata/graphql'; + +export const useApplyPageLayouts = () => { + const store = useStore(); + const { updateDraft, applyChanges } = useMetadataStore(); + + const applyPageLayouts = useCallback( + (pageLayouts: PageLayoutGenerated[]) => { + const transformedPageLayouts = pageLayouts.map(transformPageLayout); + + for (const pageLayout of transformedPageLayouts) { + store.set( + pageLayoutPersistedComponentState.atomFamily({ + instanceId: pageLayout.id, + }), + pageLayout, + ); + store.set( + pageLayoutCurrentLayoutsComponentState.atomFamily({ + instanceId: pageLayout.id, + }), + convertPageLayoutToTabLayouts(pageLayout), + ); + } + + store.set(recordPageLayoutsState.atom, transformedPageLayouts); + updateDraft('pageLayouts', transformedPageLayouts); + applyChanges(); + }, + [store, updateDraft, applyChanges], + ); + + return { + applyPageLayouts, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useRefreshPageLayouts.ts b/packages/twenty-front/src/modules/page-layout/hooks/useRefreshPageLayouts.ts new file mode 100644 index 0000000000..374dfa1d7f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/useRefreshPageLayouts.ts @@ -0,0 +1,26 @@ +import { useApplyPageLayouts } from '@/page-layout/hooks/useApplyPageLayouts'; +import { useCallback } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { useFindAllPageLayoutsLazyQuery } from '~/generated-metadata/graphql'; + +export const useRefreshPageLayouts = () => { + const [findAllPageLayoutsLazy] = useFindAllPageLayoutsLazyQuery(); + + const { applyPageLayouts } = useApplyPageLayouts(); + + const refreshPageLayouts = useCallback(async () => { + const result = await findAllPageLayoutsLazy({ + fetchPolicy: 'network-only', + }); + + if (!isDefined(result.data?.getPageLayouts)) { + return; + } + + applyPageLayouts(result.data.getPageLayouts); + }, [findAllPageLayoutsLazy, applyPageLayouts]); + + return { + refreshPageLayouts, + }; +}; diff --git a/packages/twenty-front/src/modules/sse-db-event/components/SSEProvider.tsx b/packages/twenty-front/src/modules/sse-db-event/components/SSEProvider.tsx index a44ba8b6e5..74d690a93d 100644 --- a/packages/twenty-front/src/modules/sse-db-event/components/SSEProvider.tsx +++ b/packages/twenty-front/src/modules/sse-db-event/components/SSEProvider.tsx @@ -1,6 +1,9 @@ import { FieldMetadataSSEEffect } from '@/metadata-store/effect-components/FieldMetadataSSEEffect'; import { NavigationMenuItemSSEEffect } from '@/metadata-store/effect-components/NavigationMenuItemSSEEffect'; import { ObjectMetadataItemSSEEffect } from '@/metadata-store/effect-components/ObjectMetadataItemSSEEffect'; +import { PageLayoutSSEEffect } from '@/metadata-store/effect-components/PageLayoutSSEEffect'; +import { PageLayoutTabSSEEffect } from '@/metadata-store/effect-components/PageLayoutTabSSEEffect'; +import { PageLayoutWidgetSSEEffect } from '@/metadata-store/effect-components/PageLayoutWidgetSSEEffect'; import { ViewFieldSSEEffect } from '@/metadata-store/effect-components/ViewFieldSSEEffect'; import { ViewSSEEffect } from '@/metadata-store/effect-components/ViewSSEEffect'; import { SSEClientEffect } from '@/sse-db-event/components/SSEClientEffect'; @@ -21,6 +24,9 @@ export const SSEProvider = ({ children }: SSEProviderProps) => { + + + {children} diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/enums/tool-category.enum.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/enums/tool-category.enum.ts index 59ab04280a..126cc1c597 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/enums/tool-category.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/enums/tool-category.enum.ts @@ -5,6 +5,7 @@ export enum ToolCategory { METADATA = 'METADATA', NATIVE_MODEL = 'NATIVE_MODEL', VIEW = 'VIEW', + VIEW_FIELD = 'VIEW_FIELD', DASHBOARD = 'DASHBOARD', LOGIC_FUNCTION = 'LOGIC_FUNCTION', } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts index 51dd44fd8b..25fe033583 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts @@ -19,10 +19,11 @@ import { type ToolIndexEntry, } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool'; -import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; -import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool'; import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool'; import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool'; +import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; +import { NavigateAppTool } from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool'; +import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool'; import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type'; import { type Tool } from 'src/engine/core-modules/tool/types/tool.type'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; @@ -39,6 +40,7 @@ export class ActionToolProvider implements ToolProvider { private readonly draftEmailTool: DraftEmailTool, private readonly searchHelpCenterTool: SearchHelpCenterTool, private readonly codeInterpreterTool: CodeInterpreterTool, + private readonly navigateAppTool: NavigateAppTool, private readonly permissionsService: PermissionsService, private readonly toolExecutorService: ToolExecutorService, ) { @@ -48,6 +50,7 @@ export class ActionToolProvider implements ToolProvider { ['draft_email', this.draftEmailTool], ['search_help_center', this.searchHelpCenterTool], ['code_interpreter', this.codeInterpreterTool], + ['navigate_app', this.navigateAppTool], ]); // Register each action tool as a static handler in the executor @@ -116,6 +119,14 @@ export class ActionToolProvider implements ToolProvider { ), ); + descriptors.push( + this.buildDescriptor( + 'navigate_app', + this.navigateAppTool, + includeSchemas, + ), + ); + const hasCodeInterpreterPermission = await this.permissionsService.hasToolPermission( context.rolePermissionConfig, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-field-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-field-tool.provider.ts new file mode 100644 index 0000000000..5aef78c362 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-field-tool.provider.ts @@ -0,0 +1,98 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; + +import { PermissionFlagType } from 'twenty-shared/constants'; + +import { + type GenerateDescriptorOptions, + type ToolProvider, + type ToolProviderContext, +} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; + +import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service'; +import { + type ToolDescriptor, + type ToolIndexEntry, +} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; +import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; +import { ViewFieldToolsFactory } from 'src/engine/metadata-modules/view-field/tools/view-field-tools.factory'; + +@Injectable() +export class ViewFieldToolProvider implements ToolProvider, OnModuleInit { + readonly category = ToolCategory.VIEW_FIELD; + + constructor( + private readonly viewFieldToolsFactory: ViewFieldToolsFactory, + private readonly permissionsService: PermissionsService, + private readonly toolExecutorService: ToolExecutorService, + ) {} + + onModuleInit(): void { + const factory = this.viewFieldToolsFactory; + + this.toolExecutorService.registerCategoryGenerator( + ToolCategory.VIEW_FIELD, + async (context) => { + const readTools = factory.generateReadTools(context.workspaceId); + + const hasViewPermission = + await this.permissionsService.checkRolesPermissions( + context.rolePermissionConfig, + context.workspaceId, + PermissionFlagType.VIEWS, + ); + + if (hasViewPermission) { + const writeTools = factory.generateWriteTools(context.workspaceId); + + return { ...readTools, ...writeTools }; + } + + return readTools; + }, + ); + } + + async isAvailable(_context: ToolProviderContext): Promise { + return true; + } + + async generateDescriptors( + context: ToolProviderContext, + options?: GenerateDescriptorOptions, + ): Promise<(ToolIndexEntry | ToolDescriptor)[]> { + const schemaOptions = { + includeSchemas: options?.includeSchemas ?? true, + }; + + const readTools = this.viewFieldToolsFactory.generateReadTools( + context.workspaceId, + ); + + const hasViewPermission = + await this.permissionsService.checkRolesPermissions( + context.rolePermissionConfig, + context.workspaceId, + PermissionFlagType.VIEWS, + ); + + if (hasViewPermission) { + const writeTools = this.viewFieldToolsFactory.generateWriteTools( + context.workspaceId, + ); + + return toolSetToDescriptors( + { ...readTools, ...writeTools }, + ToolCategory.VIEW_FIELD, + schemaOptions, + ); + } + + return toolSetToDescriptors( + readTools, + ToolCategory.VIEW_FIELD, + schemaOptions, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts index 4d8282632a..48d0bf41eb 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts @@ -7,9 +7,10 @@ import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/ import { ActionToolProvider } from 'src/engine/core-modules/tool-provider/providers/action-tool.provider'; import { DashboardToolProvider } from 'src/engine/core-modules/tool-provider/providers/dashboard-tool.provider'; import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider'; +import { LogicFunctionToolProvider } from 'src/engine/core-modules/tool-provider/providers/logic-function-tool.provider'; import { MetadataToolProvider } from 'src/engine/core-modules/tool-provider/providers/metadata-tool.provider'; import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/providers/native-model-tool.provider'; -import { LogicFunctionToolProvider } from 'src/engine/core-modules/tool-provider/providers/logic-function-tool.provider'; +import { ViewFieldToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-field-tool.provider'; import { ViewToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-tool.provider'; import { WorkflowToolProvider } from 'src/engine/core-modules/tool-provider/providers/workflow-tool.provider'; import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service'; @@ -19,10 +20,11 @@ import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent- import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module'; import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; -import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; +import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-field.module'; import { ViewModule } from 'src/engine/metadata-modules/view/view.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @@ -45,6 +47,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; FieldMetadataModule, PermissionsModule, ViewModule, + ViewFieldModule, WorkspaceCacheModule, WorkspaceManyOrAllFlatEntityMapsCacheModule, LogicFunctionModule, @@ -60,6 +63,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; MetadataToolProvider, NativeModelToolProvider, LogicFunctionToolProvider, + ViewFieldToolProvider, ViewToolProvider, WorkflowToolProvider, { @@ -72,6 +76,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; databaseProvider: DatabaseToolProvider, metadataProvider: MetadataToolProvider, logicFunctionProvider: LogicFunctionToolProvider, + viewFieldProvider: ViewFieldToolProvider, viewProvider: ViewToolProvider, workflowProvider: WorkflowToolProvider, ) => [ @@ -80,6 +85,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; databaseProvider, metadataProvider, logicFunctionProvider, + viewFieldProvider, viewProvider, workflowProvider, ], @@ -89,6 +95,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; DatabaseToolProvider, MetadataToolProvider, LogicFunctionToolProvider, + ViewFieldToolProvider, ViewToolProvider, WorkflowToolProvider, ], diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/get-tool-catalog.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/get-tool-catalog.tool.ts index abaa29c608..7c5819aac4 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/get-tool-catalog.tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/get-tool-catalog.tool.ts @@ -1,16 +1,21 @@ import { z } from 'zod'; +import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; export const GET_TOOL_CATALOG_TOOL_NAME = 'get_tool_catalog'; +const availableCategories = Object.values(ToolCategory) + .map((entry) => entry.toString()) + .join(', '); + export const getToolCatalogInputSchema = z.object({ categories: z .array(z.string()) .optional() .describe( - 'Filter by category (e.g. DATABASE_CRUD, METADATA, VIEW, WORKFLOW, DASHBOARD, LOGIC_FUNCTION, ACTION). Omit to get all.', + `Filter by category. Available categories: ${availableCategories}. Omit to get all.`, ), }); diff --git a/packages/twenty-server/src/engine/core-modules/tool/enums/tool-type.enum.ts b/packages/twenty-server/src/engine/core-modules/tool/enums/tool-type.enum.ts index 3deef3b33c..698d99d6db 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/enums/tool-type.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/enums/tool-type.enum.ts @@ -3,4 +3,5 @@ export enum ToolType { SEND_EMAIL = 'SEND_EMAIL', SEARCH_HELP_CENTER = 'SEARCH_HELP_CENTER', CODE_INTERPRETER = 'CODE_INTERPRETER', + NAVIGATE_APP = 'NAVIGATE_APP', } diff --git a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts index 886a4f72ec..5cf2b517f1 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts @@ -12,7 +12,12 @@ import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/dr import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service'; import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool'; import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; +import { NavigateAppTool } from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool'; import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module'; +import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; +import { ViewModule } from 'src/engine/metadata-modules/view/view.module'; import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module'; import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbound-manager/messaging-send-manager.module'; @@ -26,6 +31,10 @@ import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbou FileModule, JwtModule, SecureHttpClientModule, + ObjectMetadataModule, + ViewModule, + NavigationMenuItemModule, + WorkspaceManyOrAllFlatEntityMapsCacheModule, ], providers: [ HttpTool, @@ -34,6 +43,7 @@ import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbou EmailComposerService, SearchHelpCenterTool, CodeInterpreterTool, + NavigateAppTool, ], exports: [ HttpTool, @@ -42,6 +52,7 @@ import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbou EmailComposerService, SearchHelpCenterTool, CodeInterpreterTool, + NavigateAppTool, ], }) export class ToolModule {} diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool.schema.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool.schema.ts new file mode 100644 index 0000000000..3cf69f72b2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool.schema.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; + +export const NavigateAppInputZodSchema = z.discriminatedUnion('type', [ + z.object({ + type: z + .literal('navigateToView') + .describe( + 'Navigate to a specific view by name. ONLY use this type when the user explicitly mentions the word "view" (e.g. "go to the My Companies view", "open view All People"). Do NOT use this for general navigation requests.', + ), + viewName: z + .string() + .describe( + 'The name of the view to navigate to (e.g. "My Companies", "All People")', + ), + }), + z.object({ + type: z + .literal('navigateToObject') + .describe( + 'Navigate to the default view for an object. This is the PREFERRED and DEFAULT type for all navigation requests unless the user explicitly mentions the word "view".', + ), + objectNameSingular: z + .string() + .describe( + 'The singular name of the object to navigate to (e.g. "company", "person", "opportunity")', + ), + }), + z.object({ + type: z + .literal('navigateToRecord') + .describe( + 'Navigate to a specific record page. Use this when the user wants to go to a particular record by name (e.g. "go to the company Acme", "open the person John Doe", "show me the deal Enterprise Plan").', + ), + objectNameSingular: z + .string() + .describe( + 'The singular name of the object type (e.g. "company", "person", "opportunity")', + ), + recordName: z + .string() + .describe( + 'The name or label of the record to navigate to (e.g. "Acme", "John Doe", "Enterprise Plan")', + ), + }), + z.object({ + type: z + .literal('wait') + .describe( + 'Wait for a specified duration in milliseconds before continuing. Useful when you need the page to fully load after a navigation before taking further actions (e.g. 2000 for 2 seconds).', + ), + durationMs: z + .number() + .int() + .min(0) + .max(30000) + .describe( + 'The duration in milliseconds to wait (e.g. 2000 for 2 seconds). Maximum 30000 (30 seconds).', + ), + }), +]); + +export type NavigateAppInput = z.infer; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool.ts new file mode 100644 index 0000000000..8d55bcf5d5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool.ts @@ -0,0 +1,357 @@ +import { Injectable } from '@nestjs/common'; + +import { sleep } from 'cloudflare/core'; +import Fuse from 'fuse.js'; +import { NavigateAppToolOutput } from 'twenty-shared/ai'; +import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { + type NavigateAppInput, + NavigateAppInputZodSchema, +} from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool.schema'; +import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type'; +import { ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; +import { + type Tool, + type ToolExecutionContext, +} from 'src/engine/core-modules/tool/types/tool.type'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { NavigationMenuItemService } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service'; +import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; + +@Injectable() +export class NavigateAppTool implements Tool { + description = `Navigate the application. + Use navigateToRecord when the user wants to go to a specific record by name. + Default to navigateToObject for all other navigation requests. + Only use navigateToView when the user explicitly mentions the word "view" in their request. + If the user asks to wait, use the wait tool with the specified duration.`; + + inputSchema = NavigateAppInputZodSchema; + + constructor( + private readonly navigationMenuItemService: NavigationMenuItemService, + private readonly viewService: ViewService, + private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, + ) {} + + async execute( + parameters: ToolInput, + context: ToolExecutionContext, + ): Promise { + const parseResult = NavigateAppInputZodSchema.safeParse(parameters); + + if (!parseResult.success) { + return { + success: false, + message: 'Invalid navigation input', + error: parseResult.error.message, + }; + } + + const input: NavigateAppInput = parseResult.data; + + switch (input.type) { + case 'navigateToView': + return this.navigateToView( + input.viewName, + context.workspaceId, + context.userWorkspaceId, + ); + case 'navigateToObject': + return this.navigateToObject( + input.objectNameSingular, + context.workspaceId, + ); + case 'navigateToRecord': + return this.navigateToRecord( + input.objectNameSingular, + input.recordName, + context.workspaceId, + ); + case 'wait': + return this.wait(input.durationMs); + } + } + + private async wait( + durationMs: number, + ): Promise> { + await sleep(durationMs); + + return { + success: true, + message: `Waited for ${durationMs}ms`, + result: { + action: 'wait', + durationMs, + }, + }; + } + + private async navigateToView( + viewName: string, + workspaceId: string, + userWorkspaceId?: string, + ): Promise> { + const views = await this.viewService.findByWorkspaceId( + workspaceId, + userWorkspaceId, + ); + + const fuse = new Fuse(views, { + keys: ['name'], + threshold: 0.4, + }); + + const results = fuse.search(viewName); + const matchingView = results[0]?.item; + + if (!matchingView) { + const availableViewNames = views.map((view) => view.name).join(', '); + + return { + success: false, + message: `View "${viewName}" not found`, + error: `No view matching "${viewName}" was found in this workspace. Available views: ${availableViewNames}`, + }; + } + + return { + success: true, + message: `Navigating to view "${matchingView.name}"`, + result: { + action: 'navigateToView', + viewName: matchingView.name, + }, + }; + } + + private async navigateToObject( + objectNameSingular: string, + workspaceId: string, + ): Promise> { + const navigationMenuItems = await this.navigationMenuItemService.findAll({ + workspaceId, + }); + + const { flatObjectMetadataMaps, flatViewMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: [ + 'flatObjectMetadataMaps', + 'flatViewMaps', + 'flatNavigationMenuItemMaps', + ], + }, + ); + + const availableObjectNames = navigationMenuItems + .map((navigationMenuItem) => { + if (isDefined(navigationMenuItem.viewId)) { + const correspondingViewUniversalIdentifier = + flatViewMaps.universalIdentifierById[navigationMenuItem.viewId]; + + if (isDefined(correspondingViewUniversalIdentifier)) { + const correspondingView = + flatViewMaps.byUniversalIdentifier[ + correspondingViewUniversalIdentifier + ]; + + if (isDefined(correspondingView)) { + const correspondingObjectMetadataUniversalIdentifier = + flatObjectMetadataMaps.universalIdentifierById[ + correspondingView.objectMetadataId + ]; + + if (isDefined(correspondingObjectMetadataUniversalIdentifier)) { + const correspondingObjectMetadata = + flatObjectMetadataMaps.byUniversalIdentifier[ + correspondingObjectMetadataUniversalIdentifier + ]; + + if (isDefined(correspondingObjectMetadata)) { + const correspondingObjectNameSingular = + correspondingObjectMetadata.nameSingular; + + return correspondingObjectNameSingular; + } + } + } + } + } + + return null; + }) + .filter(isDefined); + + const fuse = new Fuse(availableObjectNames, { + threshold: 0.6, + }); + + const results = fuse.search(objectNameSingular.replace(/\s/g, '')); + const firstMatchingNavigationItemLabel = results[0]?.item; + + if (!isDefined(firstMatchingNavigationItemLabel)) { + return { + success: false, + message: `Object "${objectNameSingular}" not found`, + error: `No object with singular name "${objectNameSingular}" was found in this workspace. Available objects: ${availableObjectNames}`, + }; + } + + return { + success: true, + message: `Navigating to ${firstMatchingNavigationItemLabel} default view`, + result: { + action: 'navigateToObject', + objectNameSingular: firstMatchingNavigationItemLabel, + }, + }; + } + + private async navigateToRecord( + objectNameSingular: string, + recordName: string, + workspaceId: string, + ): Promise> { + const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'], + }, + ); + + const flatObjectMetadata = Object.values( + flatObjectMetadataMaps.byUniversalIdentifier, + ).find( + (metadata): metadata is FlatObjectMetadata => + isDefined(metadata) && + metadata.nameSingular === objectNameSingular && + metadata.isActive, + ); + + if (!isDefined(flatObjectMetadata)) { + const availableObjectNames = Object.values( + flatObjectMetadataMaps.byUniversalIdentifier, + ) + .filter( + (metadata): metadata is FlatObjectMetadata => + isDefined(metadata) && metadata.isActive, + ) + .map((metadata) => metadata.nameSingular) + .join(', '); + + return { + success: false, + message: `Object "${objectNameSingular}" not found`, + error: `No object with singular name "${objectNameSingular}" was found. Available objects: ${availableObjectNames}`, + }; + } + + if (!isDefined(flatObjectMetadata.labelIdentifierFieldMetadataId)) { + return { + success: false, + message: `Object "${objectNameSingular}" has no label identifier field`, + error: `Cannot search records by name for object "${objectNameSingular}" because it has no label identifier field configured.`, + }; + } + + const labelIdentifierField = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: flatObjectMetadata.labelIdentifierFieldMetadataId, + flatEntityMaps: flatFieldMetadataMaps, + }); + + if (!isDefined(labelIdentifierField)) { + return { + success: false, + message: `Label identifier field not found for object "${objectNameSingular}"`, + error: `The label identifier field metadata could not be resolved for object "${objectNameSingular}".`, + }; + } + + const isFullName = + labelIdentifierField.type === FieldMetadataType.FULL_NAME; + + const selectColumns = isFullName + ? [ + 'id', + `${labelIdentifierField.name}FirstName`, + `${labelIdentifierField.name}LastName`, + ] + : ['id', labelIdentifierField.name]; + + const authContext = buildSystemAuthContext(workspaceId); + + const records = + await this.globalWorkspaceOrmManager.executeInWorkspaceContext( + async () => { + const repository = + await this.globalWorkspaceOrmManager.getRepository( + workspaceId, + objectNameSingular, + { shouldBypassPermissionChecks: true }, + ); + + return repository.find({ + select: selectColumns, + }); + }, + authContext, + ); + + const recordsWithDisplayName = records.map((record) => { + let displayName: string; + + if (isFullName) { + const firstName = + (record[`${labelIdentifierField.name}FirstName`] as string) ?? ''; + const lastName = + (record[`${labelIdentifierField.name}LastName`] as string) ?? ''; + + displayName = `${firstName} ${lastName}`.trim(); + } else { + displayName = String(record[labelIdentifierField.name] ?? ''); + } + + return { + id: record.id as string, + displayName, + }; + }); + + const fuse = new Fuse(recordsWithDisplayName, { + keys: ['displayName'], + threshold: 0.4, + }); + + const results = fuse.search(recordName); + const matchingRecord = results[0]?.item; + + if (!isDefined(matchingRecord)) { + return { + success: false, + message: `Record "${recordName}" not found in ${objectNameSingular}`, + error: `No ${objectNameSingular} record matching "${recordName}" was found.`, + }; + } + + return { + success: true, + message: `Navigating to ${objectNameSingular} record "${matchingRecord.displayName}"`, + result: { + action: 'navigateToRecord', + objectNameSingular, + recordId: matchingRecord.id, + }, + }; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts index f132187183..02f65f2b59 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; +import { assertUnreachable } from 'twenty-shared/utils'; + import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const'; import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; @@ -279,15 +281,7 @@ ${otherPreloadedTools.length > 0 ? otherPreloadedTools.map((toolName) => `- \`${ ### Tool Catalog by Category`); - const categoryOrder = [ - ToolCategory.DATABASE_CRUD, - ToolCategory.ACTION, - ToolCategory.WORKFLOW, - ToolCategory.DASHBOARD, - ToolCategory.METADATA, - ToolCategory.VIEW, - ToolCategory.LOGIC_FUNCTION, - ]; + const categoryOrder = Object.values(ToolCategory); for (const category of categoryOrder) { const tools = toolsByCategory.get(category); @@ -321,7 +315,7 @@ ${hasWebSearch ? '3' : '2'}. **Other tools**: First call \`${LEARN_TOOLS_TOOL_NA return sections.join('\n'); } - private getCategoryLabel(category: string): string { + private getCategoryLabel(category: ToolCategory): string { switch (category) { case ToolCategory.DATABASE_CRUD: return 'Database Tools (CRUD operations)'; @@ -337,8 +331,12 @@ ${hasWebSearch ? '3' : '2'}. **Other tools**: First call \`${LEARN_TOOLS_TOOL_NA return 'Dashboard Tools (create/manage dashboards)'; case ToolCategory.LOGIC_FUNCTION: return 'Logic Functions (custom tools)'; + case ToolCategory.NATIVE_MODEL: + return 'Native Model Capabilities (e.g. web search)'; + case ToolCategory.VIEW_FIELD: + return 'View Field Tools (manage view columns)'; default: - return category; + return assertUnreachable(category); } } } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts index 7dce8d1b33..3e291f12b8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts @@ -104,6 +104,22 @@ const DeleteFieldMetadataInputSchema = z.object({ id: z.string().uuid().describe('ID of the field to delete'), }); +const CreateManyFieldMetadataInputSchema = z.object({ + fields: z + .array(CreateFieldMetadataInputSchema) + .min(1) + .max(20) + .describe('Array of field metadata to create (1-20 items).'), +}); + +const UpdateManyFieldMetadataInputSchema = z.object({ + fields: z + .array(UpdateFieldMetadataInputSchema) + .min(1) + .max(20) + .describe('Array of field metadata updates to apply (1-20 items).'), +}); + @Injectable() export class FieldMetadataToolsFactory { constructor(private readonly fieldMetadataService: FieldMetadataService) {} @@ -229,6 +245,86 @@ export class FieldMetadataToolsFactory { } }, }, + create_many_field_metadata: { + description: + 'Create multiple field metadata at once on one or more objects. More efficient than calling create_field_metadata multiple times. Each item follows the same schema as create_field_metadata.', + inputSchema: CreateManyFieldMetadataInputSchema, + execute: async (parameters: { + fields: Array<{ + objectMetadataId: string; + type: FieldMetadataType; + name: string; + label: string; + description?: string; + icon?: string; + isNullable?: boolean; + isUnique?: boolean; + defaultValue?: unknown; + options?: unknown; + settings?: unknown; + isLabelSyncedWithName?: boolean; + isRemoteCreation?: boolean; + relationCreationPayload?: unknown; + }>; + }) => { + try { + await this.fieldMetadataService.createManyFields({ + createFieldInputs: parameters.fields as Parameters< + typeof this.fieldMetadataService.createManyFields + >[0]['createFieldInputs'], + workspaceId, + }); + + return true; + } catch (error) { + if (error instanceof WorkspaceMigrationBuilderException) { + throw new Error(formatValidationErrors(error)); + } + throw error; + } + }, + }, + update_many_field_metadata: { + description: + 'Update multiple field metadata at once. More efficient than calling update_field_metadata multiple times. Each item must include the field ID and the properties to update.', + inputSchema: UpdateManyFieldMetadataInputSchema, + execute: async (parameters: { + fields: Array<{ + id: string; + name?: string; + label?: string; + description?: string; + icon?: string; + isActive?: boolean; + isNullable?: boolean; + isUnique?: boolean; + defaultValue?: unknown; + options?: unknown; + settings?: unknown; + isLabelSyncedWithName?: boolean; + }>; + }) => { + try { + await Promise.all( + parameters.fields.map(async ({ id, ...update }) => { + await this.fieldMetadataService.updateOneField({ + updateFieldInput: { id, ...update } as Parameters< + typeof this.fieldMetadataService.updateOneField + >[0]['updateFieldInput'], + workspaceId, + }); + }), + ); + + return true; + } catch (error) { + if (error instanceof WorkspaceMigrationBuilderException) { + throw new Error(formatValidationErrors(error)); + } + throw error; + } + }, + }, }; } } diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts index 7cac76fd19..41730e759c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts @@ -81,6 +81,22 @@ const DeleteObjectMetadataInputSchema = z.object({ id: z.string().uuid().describe('ID of the object to delete'), }); +const CreateManyObjectMetadataInputSchema = z.object({ + objects: z + .array(CreateObjectMetadataInputSchema) + .min(1) + .max(20) + .describe('Array of object metadata to create (1-20 items).'), +}); + +const UpdateManyObjectMetadataInputSchema = z.object({ + objects: z + .array(UpdateObjectMetadataInputSchema) + .min(1) + .max(20) + .describe('Array of object metadata updates to apply (1-20 items).'), +}); + @Injectable() export class ObjectMetadataToolsFactory { constructor(private readonly objectMetadataService: ObjectMetadataService) {} @@ -202,6 +218,83 @@ export class ObjectMetadataToolsFactory { } }, }, + create_many_object_metadata: { + description: + 'Create multiple object metadata at once in the workspace data model. More efficient than calling create_object_metadata multiple times. Each item follows the same schema as create_object_metadata.', + inputSchema: CreateManyObjectMetadataInputSchema, + execute: async (parameters: { + objects: Array<{ + nameSingular: string; + namePlural: string; + labelSingular: string; + labelPlural: string; + description?: string; + icon?: string; + shortcut?: string; + isRemote?: boolean; + isLabelSyncedWithName?: boolean; + }>; + }) => { + try { + await Promise.all( + parameters.objects.map(async (createObjectInput) => { + await this.objectMetadataService.createOneObject({ + createObjectInput: createObjectInput as Parameters< + typeof this.objectMetadataService.createOneObject + >[0]['createObjectInput'], + workspaceId, + }); + }), + ); + + return true; + } catch (error) { + if (error instanceof WorkspaceMigrationBuilderException) { + throw new Error(formatValidationErrors(error)); + } + throw error; + } + }, + }, + update_many_object_metadata: { + description: + 'Update multiple object metadata at once. More efficient than calling update_object_metadata multiple times. Each item must include the object ID and the properties to update.', + inputSchema: UpdateManyObjectMetadataInputSchema, + execute: async (parameters: { + objects: Array<{ + id: string; + labelSingular?: string; + labelPlural?: string; + nameSingular?: string; + namePlural?: string; + description?: string; + icon?: string; + shortcut?: string; + isActive?: boolean; + labelIdentifierFieldMetadataId?: string; + imageIdentifierFieldMetadataId?: string; + isLabelSyncedWithName?: boolean; + }>; + }) => { + try { + await Promise.all( + parameters.objects.map(async ({ id, ...update }) => { + await this.objectMetadataService.updateOneObject({ + updateObjectInput: { id, update }, + workspaceId, + }); + }), + ); + + return true; + } catch (error) { + if (error instanceof WorkspaceMigrationBuilderException) { + throw new Error(formatValidationErrors(error)); + } + throw error; + } + }, + }, }; } } diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field/tools/view-field-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/view-field/tools/view-field-tools.factory.ts new file mode 100644 index 0000000000..9ac5938585 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view-field/tools/view-field-tools.factory.ts @@ -0,0 +1,357 @@ +import { Injectable } from '@nestjs/common'; + +import { type ToolSet } from 'ai'; +import { AggregateOperations } from 'twenty-shared/types'; +import { z } from 'zod'; + +import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service'; +import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; + +const GetViewFieldsInputSchema = z.object({ + viewId: z + .string() + .uuid() + .describe( + 'The ID of the view to list fields for. Obtain this from get_views.', + ), +}); + +const CreateViewFieldInputSchema = z.object({ + viewId: z.string().uuid().describe('The ID of the view to add the field to.'), + fieldMetadataId: z + .string() + .uuid() + .describe( + 'The ID of the field metadata to add. Use get_field_metadata to find available fields.', + ), + isVisible: z + .boolean() + .optional() + .default(true) + .describe('Whether the field is visible in the view.'), + size: z + .number() + .int() + .optional() + .default(150) + .describe('Column width in pixels.'), + position: z + .number() + .optional() + .default(0) + .describe('Position of the field in the view (0-based).'), + aggregateOperation: z + .enum(Object.values(AggregateOperations) as [string, ...string[]]) + .optional() + .describe( + 'Aggregate operation for this field (e.g., "SUM", "AVG", "COUNT").', + ), +}); + +const UpdateViewFieldInputSchema = z.object({ + id: z + .string() + .uuid() + .describe( + 'The ID of the view field to update. Obtain this from get_view_fields.', + ), + isVisible: z.boolean().optional().describe('Whether the field is visible.'), + size: z.number().int().optional().describe('Column width in pixels.'), + position: z.number().optional().describe('Position of the field.'), + aggregateOperation: z + .enum(Object.values(AggregateOperations) as [string, ...string[]]) + .optional() + .describe('Aggregate operation for this field.'), +}); + +const DeleteViewFieldInputSchema = z.object({ + id: z + .string() + .uuid() + .describe( + 'The ID of the view field to delete. Obtain this from get_view_fields.', + ), +}); + +const CreateManyViewFieldsInputSchema = z.object({ + viewFields: z + .array(CreateViewFieldInputSchema) + .min(1) + .max(50) + .describe('Array of view fields to create (1-50 items).'), +}); + +const UpdateManyViewFieldsInputSchema = z.object({ + viewFields: z + .array(UpdateViewFieldInputSchema) + .min(1) + .max(50) + .describe('Array of view field updates to apply (1-50 items).'), +}); + +@Injectable() +export class ViewFieldToolsFactory { + constructor( + private readonly viewFieldService: ViewFieldService, + private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + ) {} + + private async resolveFieldName( + workspaceId: string, + fieldMetadataId: string, + ): Promise { + const { flatFieldMetadataMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatFieldMetadataMaps'], + }, + ); + + const universalIdentifier = + flatFieldMetadataMaps.universalIdentifierById[fieldMetadataId]; + + if (!universalIdentifier) { + return undefined; + } + + return flatFieldMetadataMaps.byUniversalIdentifier[universalIdentifier] + ?.name; + } + + generateReadTools(workspaceId: string): ToolSet { + return { + get_view_fields: { + description: + 'List the columns (fields) displayed in a specific view. A view field controls which columns appear in a table or kanban view, their visibility, width, position, and aggregate operation. Use get_views first to find the view ID, then call this to inspect its column configuration.', + + inputSchema: GetViewFieldsInputSchema, + execute: async (parameters: { viewId: string }) => { + const viewFields = await this.viewFieldService.findByViewId( + workspaceId, + parameters.viewId, + ); + + const viewFieldsWithNames = await Promise.all( + viewFields.map(async (viewField) => { + const fieldName = await this.resolveFieldName( + workspaceId, + viewField.fieldMetadataId, + ); + + return { + id: viewField.id, + fieldMetadataId: viewField.fieldMetadataId, + fieldName: fieldName ?? null, + viewId: viewField.viewId, + isVisible: viewField.isVisible, + size: viewField.size, + position: viewField.position, + aggregateOperation: viewField.aggregateOperation, + }; + }), + ); + + return viewFieldsWithNames; + }, + }, + }; + } + + generateWriteTools(workspaceId: string): ToolSet { + return { + create_view_field: { + description: + 'Add a new column to a view. View fields define which columns are shown in table or kanban views. First call get_field_metadata to find the fieldMetadataId of the column to add, and get_views to find the target viewId.', + + inputSchema: CreateViewFieldInputSchema, + execute: async (parameters: { + viewId: string; + fieldMetadataId: string; + isVisible?: boolean; + size?: number; + position?: number; + aggregateOperation?: string; + }) => { + try { + const viewField = await this.viewFieldService.createOne({ + createViewFieldInput: { + viewId: parameters.viewId, + fieldMetadataId: parameters.fieldMetadataId, + isVisible: parameters.isVisible ?? true, + size: parameters.size ?? 150, + position: parameters.position ?? 0, + aggregateOperation: + parameters.aggregateOperation as AggregateOperations, + }, + workspaceId, + }); + + return { + id: viewField.id, + fieldMetadataId: viewField.fieldMetadataId, + viewId: viewField.viewId, + isVisible: viewField.isVisible, + size: viewField.size, + position: viewField.position, + aggregateOperation: viewField.aggregateOperation, + }; + } catch (error) { + if (error instanceof WorkspaceMigrationBuilderException) { + throw new Error(formatValidationErrors(error)); + } + throw error; + } + }, + }, + update_view_field: { + description: + "Update properties of a column in a view. You can change its visibility, width (size in pixels), display position, or aggregate operation. Use get_view_fields to find the view field ID. Constraints: position must not be -1, must not precede the label identifier field, and must not conflict with another field's position.", + + inputSchema: UpdateViewFieldInputSchema, + execute: async (parameters: { + id: string; + isVisible?: boolean; + size?: number; + position?: number; + aggregateOperation?: string; + }) => { + try { + const viewField = await this.viewFieldService.updateOne({ + updateViewFieldInput: { + id: parameters.id, + update: { + isVisible: parameters.isVisible, + size: parameters.size, + position: parameters.position, + aggregateOperation: + parameters.aggregateOperation as AggregateOperations, + }, + }, + workspaceId, + }); + + return { + id: viewField.id, + fieldMetadataId: viewField.fieldMetadataId, + viewId: viewField.viewId, + isVisible: viewField.isVisible, + size: viewField.size, + position: viewField.position, + aggregateOperation: viewField.aggregateOperation, + }; + } catch (error) { + if (error instanceof WorkspaceMigrationBuilderException) { + throw new Error(formatValidationErrors(error)); + } + throw error; + } + }, + }, + delete_view_field: { + description: + "Remove a column from a view. This removes the field from the view's displayed columns. Use get_view_fields to find the view field ID to delete.", + + inputSchema: DeleteViewFieldInputSchema, + execute: async (parameters: { id: string }) => { + try { + const viewField = await this.viewFieldService.deleteOne({ + deleteViewFieldInput: { id: parameters.id }, + workspaceId, + }); + + return { + id: viewField.id, + deleted: true, + }; + } catch (error) { + if (error instanceof WorkspaceMigrationBuilderException) { + throw new Error(formatValidationErrors(error)); + } + throw error; + } + }, + }, + create_many_view_fields: { + description: + 'Add multiple columns to a view at once. More efficient than calling create_view_field multiple times. Each item follows the same schema as create_view_field. All view fields can target the same or different views.', + inputSchema: CreateManyViewFieldsInputSchema, + execute: async (parameters: { + viewFields: Array<{ + viewId: string; + fieldMetadataId: string; + isVisible?: boolean; + size?: number; + position?: number; + aggregateOperation?: string; + }>; + }) => { + try { + await this.viewFieldService.createMany({ + createViewFieldInputs: parameters.viewFields.map((viewField) => ({ + viewId: viewField.viewId, + fieldMetadataId: viewField.fieldMetadataId, + isVisible: viewField.isVisible ?? true, + size: viewField.size ?? 150, + position: viewField.position ?? 0, + aggregateOperation: + viewField.aggregateOperation as AggregateOperations, + })), + workspaceId, + }); + + return true; + } catch (error) { + if (error instanceof WorkspaceMigrationBuilderException) { + throw new Error(formatValidationErrors(error)); + } + throw error; + } + }, + }, + update_many_view_fields: { + description: + 'Update multiple columns in a view at once. More efficient than calling update_view_field multiple times. Each item must include the view field ID and properties to update. Same constraints as update_view_field apply to each item.', + inputSchema: UpdateManyViewFieldsInputSchema, + execute: async (parameters: { + viewFields: Array<{ + id: string; + isVisible?: boolean; + size?: number; + position?: number; + aggregateOperation?: string; + }>; + }) => { + try { + await Promise.all( + parameters.viewFields.map(async (viewField) => { + await this.viewFieldService.updateOne({ + updateViewFieldInput: { + id: viewField.id, + update: { + isVisible: viewField.isVisible, + size: viewField.size, + position: viewField.position, + aggregateOperation: + viewField.aggregateOperation as AggregateOperations, + }, + }, + workspaceId, + }); + }), + ); + + return true; + } catch (error) { + if (error instanceof WorkspaceMigrationBuilderException) { + throw new Error(formatValidationErrors(error)); + } + throw error; + } + }, + }, + }; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field/view-field.module.ts b/packages/twenty-server/src/engine/metadata-modules/view-field/view-field.module.ts index 636ebd9444..7c81cf5877 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-field/view-field.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-field/view-field.module.ts @@ -8,6 +8,7 @@ import { ViewFieldController } from 'src/engine/metadata-modules/view-field/cont import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity'; import { ViewFieldResolver } from 'src/engine/metadata-modules/view-field/resolvers/view-field.resolver'; import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service'; +import { ViewFieldToolsFactory } from 'src/engine/metadata-modules/view-field/tools/view-field-tools.factory'; import { ViewPermissionsModule } from 'src/engine/metadata-modules/view-permissions/view-permissions.module'; import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; @@ -24,7 +25,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace ViewPermissionsModule, ], controllers: [ViewFieldController], - providers: [ViewFieldResolver, ViewFieldService], - exports: [ViewFieldService], + providers: [ViewFieldResolver, ViewFieldService, ViewFieldToolsFactory], + exports: [ViewFieldService, ViewFieldToolsFactory], }) export class ViewFieldModule {} diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-agents.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-agents.util.ts index f90129613d..dd761e665b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-agents.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-agents.util.ts @@ -366,6 +366,10 @@ export const seedAgents = async ({ schemaName, workspaceId, }: SeedAgentsArgs) => { + if (workspaceId === SEED_APPLE_WORKSPACE_ID) { + return; + } + const threadId = await seedChatThreads({ queryRunner, schemaName, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-skill.constant.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-skill.constant.ts index f2bcf156ba..54299dc123 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-skill.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-skill.constant.ts @@ -29,6 +29,9 @@ export const STANDARD_SKILL = { pptx: { universalIdentifier: '20202020-13b5-4e60-9359-b8519ef1c07d', }, + 'workspace-demo-seeding': { + universalIdentifier: '20202020-c81b-4af8-9255-4c34bd0eac9c', + }, } as const satisfies Record< string, { diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts index 49036b2765..841898d0da 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts @@ -138,6 +138,58 @@ Prioritize data integrity and provide clear feedback on operations performed.`, }, }), + 'workspace-demo-seeding': (args: Omit) => + createStandardSkillFlatMetadata({ + ...args, + context: { + skillName: 'workspace-demo-seeding', + name: 'workspace-demo-seeding', + label: 'Workspace Demo Seeding', + description: + 'Seeding demo metadata and data for workspace setup and testing purposes', + icon: 'IconDatabase', + content: `# Workspace Demo Seeding Skill +You will create a demo workspace that fits a particular type of company given by the user. + +Do not ask the user for more information, just be creative with the objects and fields, but stay professional and coherent. + +Create relations fields between objects, for example a car repair shop workspace would have objects for cars, employees, repairs, customers, and the relevant relations between them. + +DO NOT USE code-interpreter tool at all. Prefer more steps. + +LIMIT TO 3 OBJECTS FOR DEMO, AND 3 FIELDS FOR EACH OBJECT, to avoid bugs. + +For the fields you will create, make sure to create a good variety of field types to showcase the different capabilities of the platform, for example: +- Create SELECT and SELECT_MULTIPLE field types for building demo board index views and table with groups views +- Create DATE_TIME fields to be able to create calendar views +- Create CURRENCY and NUMERIC fields for graphs + +Here are the steps for you to work properly : +- Proceed object by object, for each object. +- Create the object with the right tool, DO THIS FIRST +- Wait 3 seconds before navigating, for the view to be populated by the backend +- Navigate to its default view +- Then create each relevant field metadata one by one, and create a view field for each of them, reorder them to the start so we see them. +- Then seed mock data relevant : + - use the tool that is related to the object, look for tools, create_my_new_object, create_many_of_my_new_object, look again in tools, don't use http + - between 20 and 50 + - with a coherent combination of values + - proceed with the relevant tools for each object, do not use code-interpreter + - navigate to each default view before seeding an object, so the user can see what happens. + +After you've finished with this part, let's proceed to the dashboard creation. We will create a dashboard with 4 graphs. +- Navigate to the dashboard list default view +- Create a new dashboard +- Navigate to the dashboard page +- Create 4 graphs : + - For each graph, find a relevant amount for y axis, a relevant date or select field for x axis, and if necessary a relevant group by stack + - Change the name of each graph so it is relevant + - Turn on the labels on the graphs +`, + isCustom: false, + }, + }), + 'dashboard-building': (args: Omit) => createStandardSkillFlatMetadata({ ...args, diff --git a/packages/twenty-shared/src/ai/index.ts b/packages/twenty-shared/src/ai/index.ts index 55fe5fa9af..5ee77e9c3d 100644 --- a/packages/twenty-shared/src/ai/index.ts +++ b/packages/twenty-shared/src/ai/index.ts @@ -26,3 +26,4 @@ export type { } from './types/ExtendedUIMessage'; export type { ExtendedUIMessagePart } from './types/ExtendedUIMessagePart'; export type { ModelConfiguration } from './types/model-configuration.type'; +export type { NavigateAppToolOutput } from './types/NavigateAppToolOutput'; diff --git a/packages/twenty-shared/src/ai/types/ExtendedUIMessage.ts b/packages/twenty-shared/src/ai/types/ExtendedUIMessage.ts index abb7166cd8..edfe4b5cf1 100644 --- a/packages/twenty-shared/src/ai/types/ExtendedUIMessage.ts +++ b/packages/twenty-shared/src/ai/types/ExtendedUIMessage.ts @@ -1,4 +1,5 @@ import { type DataMessagePart } from '@/ai/types/DataMessagePart'; +import { type Nullable } from '@/types'; import { type UIMessage } from 'ai'; export type AIChatUsageMetadata = { @@ -20,4 +21,6 @@ type Metadata = { model?: AIChatModelMetadata; }; -export type ExtendedUIMessage = UIMessage; +export type ExtendedUIMessage = UIMessage & { + threadId?: Nullable; +}; diff --git a/packages/twenty-shared/src/ai/types/NavigateAppToolOutput.ts b/packages/twenty-shared/src/ai/types/NavigateAppToolOutput.ts new file mode 100644 index 0000000000..2aa329a4f3 --- /dev/null +++ b/packages/twenty-shared/src/ai/types/NavigateAppToolOutput.ts @@ -0,0 +1,18 @@ +export type NavigateAppToolOutput = + | { + action: 'navigateToObject'; + objectNameSingular: string; + } + | { + action: 'navigateToView'; + viewName: string; + } + | { + action: 'navigateToRecord'; + objectNameSingular: string; + recordId: string; + } + | { + action: 'wait'; + durationMs: number; + }; diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 74a0489827..caaedba522 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -225,6 +225,7 @@ export { IconInbox, IconInfoCircle, IconItalic, + IconJetpack, IconJson, IconKey, IconLanguage, diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index 4df76bb99a..1168da1383 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -300,6 +300,7 @@ export { IconInbox, IconInfoCircle, IconItalic, + IconJetpack, IconJson, IconKey, IconLanguage,