diff --git a/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx b/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx new file mode 100644 index 000000000..87f51f4aa --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx @@ -0,0 +1,51 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { t } from '@lingui/core/macro'; +import { IconSparkles } from 'twenty-ui/display'; +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}; + padding: ${({ theme }) => theme.spacing(2.5)}; + display: flex; + justify-content: center; + margin-bottom: ${({ theme }) => theme.spacing(2)}; +`; + +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}; +`; + +export const AIChatEmptyState = () => { + const theme = useTheme(); + + return ( + + + + + {t`Chat`} + + {t`Start a conversation with your AI agent to get workflow insights, task assistance, and process guidance`} + + + ); +}; diff --git a/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx new file mode 100644 index 000000000..05f894bb7 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx @@ -0,0 +1,204 @@ +import { keyframes, useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { Avatar, IconDotsVertical, IconSparkles } from 'twenty-ui/display'; + +import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton'; +import { AgentChatFilePreview } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFilePreview'; +import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role'; + +import { AgentChatMessage } from '~/generated/graphql'; +import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; + +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<{ isShowingToolCall?: boolean }>` + display: flex; + flex-direction: row; + align-items: ${({ isShowingToolCall }) => + isShowingToolCall ? 'center' : 'flex-start'}; + 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 StyledMessageContainer = styled.div` + width: 100%; +`; + +const StyledFilesContainer = styled.div` + display: flex; + flex-direction: row; + gap: ${({ theme }) => theme.spacing(2)}; + flex-wrap: wrap; + margin-top: ${({ theme }) => theme.spacing(2)}; +`; + +const dots = keyframes` + 0% { content: ''; } + 33% { content: '.'; } + 66% { content: '..'; } + 100% { content: '...'; } +`; + +const StyledToolCallContainer = styled.div` + &::after { + display: inline-block; + content: ''; + animation: ${dots} 750ms steps(3, end) infinite; + width: 2ch; + text-align: left; + } +`; + +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); +`; + +export const AIChatMessage = ({ + message, + agentStreamingMessage, +}: { + message: AgentChatMessage; + agentStreamingMessage: { streamingText: string; toolCall: string }; +}) => { + const theme = useTheme(); + + const getAssistantMessageContent = (message: AgentChatMessage) => { + if (message.content !== '') { + return message.content; + } + + if (agentStreamingMessage.streamingText !== '') { + return agentStreamingMessage.streamingText; + } + + if (agentStreamingMessage.toolCall !== '') { + return ( + + {agentStreamingMessage.toolCall} + + ); + } + + return ( + + + + ); + }; + + return ( + + + {message.role === AgentChatMessageRole.ASSISTANT && ( + + + + )} + {message.role === AgentChatMessageRole.USER && ( + + + + )} + + + {message.role === AgentChatMessageRole.ASSISTANT + ? getAssistantMessageContent(message) + : message.content} + + {message.files.length > 0 && ( + + {message.files.map((file) => ( + + ))} + + )} + {message.content && ( + + {beautifyPastDateRelativeToNow(message.createdAt)} + + + )} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/ai/hooks/useAIChatFileUpload.ts b/packages/twenty-front/src/modules/ai/hooks/useAIChatFileUpload.ts new file mode 100644 index 000000000..dd9de38f8 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/hooks/useAIChatFileUpload.ts @@ -0,0 +1,82 @@ +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; +import { agentChatSelectedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatSelectedFilesComponentState'; +import { agentChatUploadedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatUploadedFilesComponentState'; +import { useLingui } from '@lingui/react/macro'; +import { isDefined } from 'twenty-shared/utils'; +import { + File as FileDocument, + useCreateFileMutation, +} from '~/generated-metadata/graphql'; + +export const useAIChatFileUpload = ({ agentId }: { agentId: string }) => { + const coreClient = useApolloCoreClient(); + const [createFile] = useCreateFileMutation({ client: coreClient }); + const { t } = useLingui(); + const { enqueueErrorSnackBar } = useSnackBar(); + const [agentChatSelectedFiles, setAgentChatSelectedFiles] = + useRecoilComponentStateV2(agentChatSelectedFilesComponentState, agentId); + const [agentChatUploadedFiles, setAgentChatUploadedFiles] = + useRecoilComponentStateV2(agentChatUploadedFilesComponentState, agentId); + + const sendFile = async (file: File) => { + try { + const result = await createFile({ + variables: { + file, + }, + }); + + const uploadedFile = result?.data?.createFile; + + if (!isDefined(uploadedFile)) { + throw new Error(t`Couldn't upload the file.`); + } + setAgentChatSelectedFiles( + agentChatSelectedFiles.filter((f) => f.name !== file.name), + ); + return uploadedFile; + } catch (error) { + const fileName = file.name; + enqueueErrorSnackBar({ + message: t`Failed to upload file: ${fileName}`, + }); + return null; + } + }; + + const uploadFiles = async (files: File[]) => { + const uploadResults = await Promise.allSettled( + files.map((file) => sendFile(file)), + ); + + const successfulUploads = uploadResults.reduce( + (acc, result) => { + if (result.status === 'fulfilled' && isDefined(result.value)) { + acc.push(result.value); + } + return acc; + }, + [], + ); + + if (successfulUploads.length > 0) { + setAgentChatUploadedFiles([ + ...agentChatUploadedFiles, + ...successfulUploads, + ]); + } + + const failedCount = uploadResults.filter( + (result) => result.status === 'rejected', + ).length; + if (failedCount > 0) { + enqueueErrorSnackBar({ + message: t`${failedCount} file(s) failed to upload`, + }); + } + }; + + return { uploadFiles }; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/groupThreadsByDate.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/groupThreadsByDate.test.ts new file mode 100644 index 000000000..d50b95ef2 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/groupThreadsByDate.test.ts @@ -0,0 +1,39 @@ +import { AgentChatThread } from '~/generated-metadata/graphql'; +import { groupThreadsByDate } from '../groupThreadsByDate'; + +describe('groupThreadsByDate', () => { + const baseThread: Omit = { + agentId: 'agent-1', + title: 'Test Thread', + updatedAt: new Date().toISOString(), + }; + + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + const twoDaysAgo = new Date(today); + twoDaysAgo.setDate(today.getDate() - 2); + + const threads: AgentChatThread[] = [ + { ...baseThread, id: '1', createdAt: today.toISOString() }, + { ...baseThread, id: '2', createdAt: yesterday.toISOString() }, + { ...baseThread, id: '3', createdAt: twoDaysAgo.toISOString() }, + ]; + + it('groups threads into today, yesterday, and older', () => { + const result = groupThreadsByDate(threads); + expect(result.today).toHaveLength(1); + expect(result.today[0].id).toBe('1'); + expect(result.yesterday).toHaveLength(1); + expect(result.yesterday[0].id).toBe('2'); + expect(result.older).toHaveLength(1); + expect(result.older[0].id).toBe('3'); + }); + + it('returns empty arrays if no threads', () => { + const result = groupThreadsByDate([]); + expect(result.today).toEqual([]); + expect(result.yesterday).toEqual([]); + expect(result.older).toEqual([]); + }); +}); 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 index 12ef8beb1..ca3d5aac4 100644 --- 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 @@ -1,77 +1,39 @@ import { TextArea } from '@/ui/input/components/TextArea'; -import { keyframes, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { - Avatar, - IconDotsVertical, - IconHistory, - IconMessageCirclePlus, - IconSparkles, -} from 'twenty-ui/display'; +import { IconHistory, IconMessageCirclePlus } from 'twenty-ui/display'; +import { DropZone } from '@/activities/files/components/DropZone'; import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; -import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; -import { AgentChatFilePreview } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFilePreview'; import { AgentChatFileUpload } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFileUpload'; -import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role'; +import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState'; +import { AIChatMessage } from '@/ai/components/AIChatMessage'; +import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload'; import { t } from '@lingui/core/macro'; +import { useState } from 'react'; import { Button } from 'twenty-ui/input'; -import { AgentChatMessage } from '~/generated/graphql'; -import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; import { useAgentChat } from '../hooks/useAgentChat'; import { AIChatSkeletonLoader } from './AIChatSkeletonLoader'; import { AgentChatSelectedFilesPreview } from './AgentChatSelectedFilesPreview'; -const StyledContainer = styled.div` +const StyledContainer = styled.div<{ isDraggingFile: boolean }>` background: ${({ theme }) => theme.background.primary}; - height: calc(100% - 154px); -`; - -const StyledEmptyState = styled.div` + height: ${({ isDraggingFile }) => + isDraggingFile ? `calc(100% - 24px)` : '100%'}; + padding: ${({ isDraggingFile, theme }) => + isDraggingFile ? theme.spacing(3) : '0'}; 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}; `; @@ -86,118 +48,12 @@ const StyledScrollWrapper = styled(ScrollWrapper)` 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<{ isShowingToolCall?: boolean }>` - display: flex; - flex-direction: row; - align-items: ${({ isShowingToolCall }) => - isShowingToolCall ? 'center' : 'flex-start'}; - 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%; -`; - -const dots = keyframes` - 0% { content: ''; } - 33% { content: '.'; } - 66% { content: '..'; } - 100% { content: '...'; } -`; - -const StyledToolCallContainer = styled.div` - &::after { - display: inline-block; - content: ''; - animation: ${dots} 750ms steps(3, end) infinite; - width: 2ch; - text-align: left; - } -`; - const StyledButtonsContainer = styled.div` display: flex; flex-direction: row; gap: ${({ theme }) => theme.spacing(2)}; `; -const StyledFilesContainer = styled.div` - display: flex; - flex-direction: row; - gap: ${({ theme }) => theme.spacing(2)}; - flex-wrap: wrap; - margin-top: ${({ theme }) => theme.spacing(2)}; -`; - export const AIChatTab = ({ agentId, isWorkflowAgentNodeChat, @@ -205,7 +61,7 @@ export const AIChatTab = ({ agentId: string; isWorkflowAgentNodeChat?: boolean; }) => { - const theme = useTheme(); + const [isDraggingFile, setIsDraggingFile] = useState(false); const { messages, @@ -216,151 +72,83 @@ export const AIChatTab = ({ agentStreamingMessage, scrollWrapperId, } = useAgentChat(agentId); + const { uploadFiles } = useAIChatFileUpload({ agentId }); const { createAgentChatThread } = useCreateNewAIChatThread({ agentId }); const { navigateCommandMenu } = useCommandMenu(); - const getAssistantMessageContent = (message: AgentChatMessage) => { - if (message.content !== '') { - return message.content; - } - - if (agentStreamingMessage.streamingText !== '') { - return agentStreamingMessage.streamingText; - } - - if (agentStreamingMessage.toolCall !== '') { - return ( - - {agentStreamingMessage.toolCall} - - ); - } - - return ( - - - - ); - }; - return ( - - {messages.length !== 0 && ( - - {messages.map((msg) => ( - - - {msg.role === AgentChatMessageRole.ASSISTANT && ( - - - - )} - {msg.role === AgentChatMessageRole.USER && ( - - - - )} - - - {msg.role === AgentChatMessageRole.ASSISTANT - ? getAssistantMessageContent(msg) - : msg.content} - - {msg.files.length > 0 && ( - - {msg.files.map((file) => ( - - ))} - - )} - {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 && } - - - -