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 && } + + +