From 51d02c13bf14e5c96ad3bf3fc423fa6d51e57917 Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Tue, 8 Jul 2025 02:17:41 +0530 Subject: [PATCH] Feat - Agent chat tab (#13061) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Félix Malfait Co-authored-by: Marie <51697796+ijreilly@users.noreply.github.com> Co-authored-by: Antoine Moreaux Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com> --- package.json | 1 + .../modules/apollo/services/apollo.factory.ts | 9 +- .../useFetchMoreRecordsWithPagination.ts | 3 +- .../useLazyFetchMoreRecordsWithPagination.ts | 3 +- .../api/agent-chat-apollo.api.ts | 32 ++ .../ai-agent-action/api/streamChatResponse.ts | 105 ++++++ .../components/AIChatSkeletonLoader.tsx | 47 +++ .../ai-agent-action/components/AIChatTab.tsx | 254 ++++++++++++++ .../components/WorkflowEditActionAiAgent.tsx | 139 ++++---- .../constants/agent-chat-message-role.ts | 4 + .../constants/workflow-ai-agent-tabs.ts | 1 + .../ai-agent-action/hooks/useAgentChat.ts | 140 ++++++++ .../hooks/useAgentChatMessages.ts | 19 ++ .../hooks/useAgentChatThreads.ts | 17 + .../states/agentChatInputState.ts | 6 + .../states/agentChatMessagesComponentState.ts | 14 + .../states/agentStreamingMessageState.ts | 6 + .../utils/__tests__/getFieldIcon.test.ts | 238 +++++++++++++ .../getDefaultFormFieldSettings.test.ts | 217 ++++++++++++ .../__tests__/useFilteredOtherActions.test.ts | 111 ++++++ ...ttpJsonBodyWithoutVariablesOrThrow.test.ts | 57 ++++ .../shouldDisplayRawJsonByDefault.test.ts | 111 ++++++ .../getActionIconColorOrThrow.test.ts | 316 ++++++++++++++++++ .../__tests__/getTriggerDefaultLabel.test.ts | 183 ++++++++++ ...67020-AddAgentChatMessageAndThreadTable.ts | 61 ++++ .../agent/agent-chat-message.entity.ts | 42 +++ .../agent/agent-chat-thread.entity.ts | 53 +++ .../agent/agent-chat.controller.ts | 72 ++++ .../agent/agent-chat.service.ts | 85 +++++ .../agent/agent-execution.service.ts | 114 +++++-- .../agent/agent-streaming.service.ts | 102 ++++++ .../metadata-modules/agent/agent.entity.ts | 6 + .../metadata-modules/agent/agent.module.ts | 29 +- .../constants/agent-system-prompts.const.ts | 42 ++- .../agent/dtos/agent-chat-message.dto.ts | 19 ++ .../agent/dtos/agent-chat-thread.dto.ts | 16 + .../workflow-version-step.module.ts | 6 +- ...workflow-version-step.workspace-service.ts | 16 + .../agent/utils/agent-tool-test-utils.ts | 1 + yarn.lock | 207 +++++++++++- 40 files changed, 2777 insertions(+), 127 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/api/agent-chat-apollo.api.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/api/streamChatResponse.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatSkeletonLoader.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatTab.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentChat.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentChatMessages.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentChatThreads.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatInputState.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatMessagesComponentState.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentStreamingMessageState.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/utils/__tests__/getFieldIcon.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/utils/__tests__/getDefaultFormFieldSettings.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/__tests__/useFilteredOtherActions.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/__tests__/parseHttpJsonBodyWithoutVariablesOrThrow.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/__tests__/shouldDisplayRawJsonByDefault.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/__tests__/getActionIconColorOrThrow.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getTriggerDefaultLabel.test.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1751467467020-AddAgentChatMessageAndThreadTable.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/agent-chat-message.entity.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/agent-chat-thread.entity.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/agent-chat.controller.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/agent-chat.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/agent-streaming.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/dtos/agent-chat-message.dto.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/dtos/agent-chat-thread.dto.ts diff --git a/package.json b/package.json index 951900f76..128a6c056 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "add": "^2.0.6", "addressparser": "^1.0.1", "afterframe": "^1.0.2", + "apollo-link-rest": "^0.9.0", "apollo-server-express": "^3.12.0", "apollo-upload-client": "^17.0.0", "archiver": "^7.0.1", diff --git a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts index c2f7a9cf8..3bca7c797 100644 --- a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts +++ b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts @@ -9,6 +9,7 @@ import { import { setContext } from '@apollo/client/link/context'; import { onError } from '@apollo/client/link/error'; import { RetryLink } from '@apollo/client/link/retry'; +import { RestLink } from 'apollo-link-rest'; import { createUploadLink } from 'apollo-upload-client'; import { renewToken } from '@/auth/services/AuthService'; @@ -21,6 +22,7 @@ import { i18n } from '@lingui/core'; import { GraphQLFormattedError } from 'graphql'; import isEmpty from 'lodash.isempty'; import { getGenericOperationName, isDefined } from 'twenty-shared/utils'; +import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { cookieStorage } from '~/utils/cookie-storage'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { ApolloManager } from '../types/apolloManager.interface'; @@ -67,6 +69,10 @@ export class ApolloFactory implements ApolloManager { uri, }); + const restLink = new RestLink({ + uri: `${REACT_APP_SERVER_BASE_URL}/rest`, + }); + const authLink = setContext(async (_, { headers }) => { const tokenPair = getTokenPair(); @@ -222,8 +228,9 @@ export class ApolloFactory implements ApolloManager { ...(extraLinks || []), isDebugMode ? logger : null, retryLink, + restLink, httpLink, - ].filter(isDefined), + ].filter(isDefined) as ApolloLink[], ); }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFetchMoreRecordsWithPagination.ts b/packages/twenty-front/src/modules/object-record/hooks/useFetchMoreRecordsWithPagination.ts index 9cf3d8207..d87b79818 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFetchMoreRecordsWithPagination.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useFetchMoreRecordsWithPagination.ts @@ -5,6 +5,7 @@ import { OperationVariables, WatchQueryFetchPolicy, } from '@apollo/client'; +import { Unmasked } from '@apollo/client/masking'; import { isNonEmptyArray } from '@apollo/client/utilities'; import { isNonEmptyString } from '@sniptt/guards'; import { useMemo } from 'react'; @@ -54,7 +55,7 @@ type UseFindManyRecordsStateParams< updateQuery?: ( previousQueryResult: TData, options: { - fetchMoreResult: TFetchData; + fetchMoreResult: Unmasked; variables: TFetchVars; }, ) => TData; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useLazyFetchMoreRecordsWithPagination.ts b/packages/twenty-front/src/modules/object-record/hooks/useLazyFetchMoreRecordsWithPagination.ts index cbe606b77..95e3b9e0e 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useLazyFetchMoreRecordsWithPagination.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useLazyFetchMoreRecordsWithPagination.ts @@ -5,6 +5,7 @@ import { OperationVariables, WatchQueryFetchPolicy, } from '@apollo/client'; +import { Unmasked } from '@apollo/client/masking'; import { isNonEmptyArray } from '@apollo/client/utilities'; import { isNonEmptyString } from '@sniptt/guards'; import { useRecoilCallback } from 'recoil'; @@ -52,7 +53,7 @@ type UseFindManyRecordsStateParams< updateQuery?: ( previousQueryResult: TData, options: { - fetchMoreResult: TFetchData; + fetchMoreResult: Unmasked; variables: TFetchVars; }, ) => TData; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/api/agent-chat-apollo.api.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/api/agent-chat-apollo.api.ts new file mode 100644 index 000000000..a322c8bb1 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/api/agent-chat-apollo.api.ts @@ -0,0 +1,32 @@ +import { gql } from '@apollo/client'; + +export const GET_AGENT_CHAT_THREADS = gql` + query GetAgentChatThreads($agentId: String!) { + threads(agentId: $agentId) + @rest( + type: "AgentChatThread" + path: "/agent-chat/threads/{args.agentId}" + ) { + id + agentId + createdAt + updatedAt + } + } +`; + +export const GET_AGENT_CHAT_MESSAGES = gql` + query GetAgentChatMessages($threadId: String!) { + messages(threadId: $threadId) + @rest( + type: "AgentChatMessage" + path: "/agent-chat/threads/{args.threadId}/messages" + ) { + id + threadId + role + content + createdAt + } + } +`; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/api/streamChatResponse.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/api/streamChatResponse.ts new file mode 100644 index 000000000..cda2afae3 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/api/streamChatResponse.ts @@ -0,0 +1,105 @@ +import { getTokenPair } from '@/apollo/utils/getTokenPair'; +import { renewToken } from '@/auth/services/AuthService'; +import { AppPath } from '@/types/AppPath'; +import { isDefined } from 'twenty-shared/utils'; +import { REACT_APP_SERVER_BASE_URL } from '~/config'; +import { cookieStorage } from '~/utils/cookie-storage'; + +const handleTokenRenewal = async () => { + const tokenPair = getTokenPair(); + if (!isDefined(tokenPair?.refreshToken?.token)) { + throw new Error('No refresh token available'); + } + + const newTokens = await renewToken( + `${REACT_APP_SERVER_BASE_URL}/graphql`, + tokenPair, + ); + + if (!isDefined(newTokens)) { + throw new Error('Token renewal failed'); + } + + cookieStorage.setItem('tokenPair', JSON.stringify(newTokens)); + return newTokens; +}; + +const createStreamRequest = ( + threadId: string, + userMessage: string, + accessToken: string, +) => ({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + threadId, + userMessage, + }), +}); + +const handleStreamResponse = async ( + response: Response, + onChunk: (chunk: string) => void, +): Promise => { + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + let accumulated = ''; + + if (isDefined(reader)) { + let done = false; + while (!done) { + const { value, done: isDone } = await reader.read(); + done = isDone; + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + accumulated += chunk; + onChunk(accumulated); + } + } + + return accumulated; +}; + +export const streamChatResponse = async ( + threadId: string, + userMessage: string, + onChunk: (chunk: string) => void, +) => { + const tokenPair = getTokenPair(); + + if (!isDefined(tokenPair?.accessToken?.token)) { + throw new Error('No access token available'); + } + + const accessToken = tokenPair.accessToken.token; + + const response = await fetch( + `${REACT_APP_SERVER_BASE_URL}/rest/agent-chat/stream`, + createStreamRequest(threadId, userMessage, accessToken), + ); + + if (response.ok) { + return handleStreamResponse(response, onChunk); + } + + if (response.status === 401) { + try { + const newTokens = await handleTokenRenewal(); + const retryResponse = await fetch( + `${REACT_APP_SERVER_BASE_URL}/rest/agent-chat/stream`, + createStreamRequest(threadId, userMessage, newTokens.accessToken.token), + ); + + if (retryResponse.ok) { + return handleStreamResponse(retryResponse, onChunk); + } + } catch (renewalError) { + window.location.href = AppPath.SignInUp; + } + throw new Error('Authentication failed'); + } +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatSkeletonLoader.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatSkeletonLoader.tsx new file mode 100644 index 000000000..bd856da1f --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatSkeletonLoader.tsx @@ -0,0 +1,47 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; + +const StyledSkeletonContainer = styled.div` + display: flex; + flex: 1; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(6)}; + padding: ${({ theme }) => theme.spacing(3)}; +`; + +const StyledMessageBubble = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(3)}; +`; + +const StyledMessageSkeleton = styled.div` + width: 100%; +`; + +const NUMBER_OF_SKELETONS = 6; + +export const AIChatSkeletonLoader = () => { + const theme = useTheme(); + + return ( + + + {Array.from({ length: NUMBER_OF_SKELETONS }).map((_, index) => ( + + + + + + + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatTab.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatTab.tsx new file mode 100644 index 000000000..ee2b7e5ca --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatTab.tsx @@ -0,0 +1,254 @@ +import { TextArea } from '@/ui/input/components/TextArea'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import React from 'react'; +import { Avatar, IconDotsVertical, IconSparkles } from 'twenty-ui/display'; + +import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton'; +import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; +import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role'; +import { t } from '@lingui/core/macro'; +import { Button } from 'twenty-ui/input'; +import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; +import { useAgentChat } from '../hooks/useAgentChat'; +import { AIChatSkeletonLoader } from './AIChatSkeletonLoader'; + +const StyledContainer = styled.div` + background: ${({ theme }) => theme.background.primary}; + height: calc(100% - 154px); +`; + +const StyledEmptyState = styled.div` + display: flex; + flex-direction: column; + flex: 1; + gap: ${({ theme }) => theme.spacing(2)}; + align-items: center; + justify-content: center; + height: 100%; +`; + +const StyledSparkleIcon = styled.div` + align-items: center; + background: ${({ theme }) => theme.background.transparent.blue}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + display: flex; + height: 40px; + justify-content: center; + margin-bottom: ${({ theme }) => theme.spacing(2)}; + width: 40px; +`; + +const StyledTitle = styled.div` + font-size: ${({ theme }) => theme.font.size.lg}; + font-weight: 600; +`; + +const StyledDescription = styled.div` + color: ${({ theme }) => theme.font.color.secondary}; + text-align: center; + max-width: 85%; + font-size: ${({ theme }) => theme.font.size.md}; +`; + +const StyledInputArea = styled.div` + align-items: flex-end; + bottom: 0; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; + position: absolute; + width: calc(100% - 24px); + padding: ${({ theme }) => theme.spacing(3)}; + background: ${({ theme }) => theme.background.primary}; +`; + +const StyledScrollWrapper = styled(ScrollWrapper)` + display: flex; + flex: 1; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(5)}; + overflow-y: auto; + padding: ${({ theme }) => theme.spacing(3)}; + width: calc(100% - 24px); +`; + +const StyledMessageBubble = styled.div<{ isUser?: boolean }>` + display: flex; + flex-direction: column; + align-items: flex-start; + position: relative; + width: 100%; + + &:hover .message-footer { + opacity: 1; + pointer-events: auto; + } +`; + +const StyledMessageRow = styled.div` + display: flex; + flex-direction: row; + gap: ${({ theme }) => theme.spacing(3)}; + width: 100%; +`; + +const StyledMessageText = styled.div<{ isUser?: boolean }>` + background: ${({ theme, isUser }) => + isUser ? theme.background.secondary : theme.background.transparent}; + border-radius: ${({ theme }) => theme.border.radius.md}; + padding: ${({ theme, isUser }) => (isUser ? theme.spacing(1, 2) : 0)}; + border: ${({ isUser, theme }) => + !isUser ? 'none' : `1px solid ${theme.border.color.light}`}; + color: ${({ theme, isUser }) => + isUser ? theme.font.color.light : theme.font.color.primary}; + font-weight: ${({ isUser }) => (isUser ? 500 : 400)}; + width: fit-content; + white-space: pre-line; +`; + +const StyledMessageFooter = styled.div` + align-items: center; + color: ${({ theme }) => theme.font.color.secondary}; + display: flex; + font-size: ${({ theme }) => theme.font.size.sm}; + justify-content: space-between; + margin-top: ${({ theme }) => theme.spacing(1)}; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease-in-out; + width: 100%; +`; + +const StyledAvatarContainer = styled.div<{ isUser?: boolean }>` + align-items: center; + background: ${({ theme, isUser }) => + isUser + ? theme.background.transparent.light + : theme.background.transparent.blue}; + display: flex; + justify-content: center; + height: 24px; + min-width: 24px; + border-radius: ${({ theme }) => theme.border.radius.sm}; + padding: 1px; +`; + +const StyledDotsIconContainer = styled.div` + align-items: center; + border: ${({ theme }) => `1px solid ${theme.border.color.light}`}; + border-radius: ${({ theme }) => theme.border.radius.md}; + display: flex; + justify-content: center; + padding-inline: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledDotsIcon = styled(IconDotsVertical)` + color: ${({ theme }) => theme.font.color.light}; + transform: rotate(90deg); +`; + +const StyledMessageContainer = styled.div` + width: 100%; +`; + +type AIChatTabProps = { + agentId: string; +}; + +export const AIChatTab: React.FC = ({ agentId }) => { + const theme = useTheme(); + + const { + messages, + isLoading, + handleSendMessage, + input, + handleInputChange, + agentStreamingMessage, + } = useAgentChat(agentId); + + return ( + + {messages.length !== 0 && ( + + {messages.map((msg) => ( + + + {msg.role === AgentChatMessageRole.ASSISTANT && ( + + + + )} + {msg.role === AgentChatMessageRole.USER && ( + + + + )} + + + {msg.role === AgentChatMessageRole.ASSISTANT && !msg.content + ? agentStreamingMessage || ( + + + + ) + : msg.content} + + {msg.content && ( + + + {beautifyPastDateRelativeToNow(msg.createdAt)} + + + + )} + + + + ))} + + )} + {messages.length === 0 && !isLoading && ( + + + + + {t`Chat`} + + {t`Start a conversation with your AI agent to get workflow insights, task assistance, and process guidance`} + + + )} + {isLoading && messages.length === 0 && } + + +