diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json index de3ce74683..e72b887c8d 100644 --- a/packages/twenty-front/package.json +++ b/packages/twenty-front/package.json @@ -29,6 +29,7 @@ "workerDirectory": "public" }, "dependencies": { + "@ai-sdk/react": "^2.0.51", "@apollo/client": "^3.7.17", "@blocknote/mantine": "^0.31.1", "@blocknote/react": "^0.31.1", @@ -67,6 +68,7 @@ "@tiptap/extensions": "^3.4.2", "@tiptap/react": "^3.4.2", "@xyflow/react": "^12.4.2", + "ai": "^5.0.49", "apollo-link-rest": "^0.9.0", "apollo-upload-client": "^17.0.0", "buffer": "^6.0.3", diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index ad3b4e8d39..86065a4b19 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -71,11 +71,40 @@ export type AgentChatMessage = { createdAt: Scalars['DateTime']; files: Array; id: Scalars['UUID']; - rawContent?: Maybe; + parts: Array; role: Scalars['String']; threadId: Scalars['UUID']; }; +export type AgentChatMessagePart = { + __typename?: 'AgentChatMessagePart'; + createdAt: Scalars['DateTime']; + errorDetails?: Maybe; + errorMessage?: Maybe; + fileFilename?: Maybe; + fileMediaType?: Maybe; + fileUrl?: Maybe; + id: Scalars['UUID']; + messageId: Scalars['UUID']; + orderIndex: Scalars['Int']; + providerMetadata?: Maybe; + reasoningContent?: Maybe; + sourceDocumentFilename?: Maybe; + sourceDocumentMediaType?: Maybe; + sourceDocumentSourceId?: Maybe; + sourceDocumentTitle?: Maybe; + sourceUrlSourceId?: Maybe; + sourceUrlTitle?: Maybe; + sourceUrlUrl?: Maybe; + state?: Maybe; + textContent?: Maybe; + toolCallId?: Maybe; + toolInput?: Maybe; + toolName?: Maybe; + toolOutput?: Maybe; + type: Scalars['String']; +}; + export type AgentChatThread = { __typename?: 'AgentChatThread'; agentId: Scalars['UUID']; @@ -1203,6 +1232,7 @@ export type File = { }; export enum FileFolder { + AgentChat = 'AgentChat', Attachment = 'Attachment', File = 'File', PersonPicture = 'PersonPicture', @@ -4308,7 +4338,7 @@ export type GetAgentChatMessagesQueryVariables = Exact<{ }>; -export type GetAgentChatMessagesQuery = { __typename?: 'Query', agentChatMessages: Array<{ __typename?: 'AgentChatMessage', id: string, threadId: string, role: string, createdAt: string, rawContent?: string | null, files: Array<{ __typename?: 'File', id: string, name: string, fullPath: string, size: number, type: string, createdAt: string }> }> }; +export type GetAgentChatMessagesQuery = { __typename?: 'Query', agentChatMessages: Array<{ __typename?: 'AgentChatMessage', id: string, threadId: string, role: string, createdAt: string, parts: Array<{ __typename?: 'AgentChatMessagePart', id: string, messageId: string, orderIndex: number, type: string, textContent?: string | null, reasoningContent?: string | null, toolName?: string | null, toolCallId?: string | null, toolInput?: any | null, toolOutput?: any | null, state?: string | null, errorMessage?: string | null, errorDetails?: any | null, sourceUrlSourceId?: string | null, sourceUrlUrl?: string | null, sourceUrlTitle?: string | null, sourceDocumentSourceId?: string | null, sourceDocumentMediaType?: string | null, sourceDocumentTitle?: string | null, sourceDocumentFilename?: string | null, fileMediaType?: string | null, fileFilename?: string | null, fileUrl?: string | null, providerMetadata?: any | null, createdAt: string }>, files: Array<{ __typename?: 'File', id: string, name: string, fullPath: string, size: number, type: string, createdAt: string }> }> }; export type GetAgentChatThreadsQueryVariables = Exact<{ agentId: Scalars['UUID']; @@ -6568,7 +6598,33 @@ export const GetAgentChatMessagesDocument = gql` threadId role createdAt - rawContent + parts { + id + messageId + orderIndex + type + textContent + reasoningContent + toolName + toolCallId + toolInput + toolOutput + state + errorMessage + errorDetails + sourceUrlSourceId + sourceUrlUrl + sourceUrlTitle + sourceDocumentSourceId + sourceDocumentMediaType + sourceDocumentTitle + sourceDocumentFilename + fileMediaType + fileFilename + fileUrl + providerMetadata + createdAt + } files { id name diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 88df56b83e..8175b1f006 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -71,11 +71,40 @@ export type AgentChatMessage = { createdAt: Scalars['DateTime']; files: Array; id: Scalars['UUID']; - rawContent?: Maybe; + parts: Array; role: Scalars['String']; threadId: Scalars['UUID']; }; +export type AgentChatMessagePart = { + __typename?: 'AgentChatMessagePart'; + createdAt: Scalars['DateTime']; + errorDetails?: Maybe; + errorMessage?: Maybe; + fileFilename?: Maybe; + fileMediaType?: Maybe; + fileUrl?: Maybe; + id: Scalars['UUID']; + messageId: Scalars['UUID']; + orderIndex: Scalars['Int']; + providerMetadata?: Maybe; + reasoningContent?: Maybe; + sourceDocumentFilename?: Maybe; + sourceDocumentMediaType?: Maybe; + sourceDocumentSourceId?: Maybe; + sourceDocumentTitle?: Maybe; + sourceUrlSourceId?: Maybe; + sourceUrlTitle?: Maybe; + sourceUrlUrl?: Maybe; + state?: Maybe; + textContent?: Maybe; + toolCallId?: Maybe; + toolInput?: Maybe; + toolName?: Maybe; + toolOutput?: Maybe; + type: Scalars['String']; +}; + export type AgentChatThread = { __typename?: 'AgentChatThread'; agentId: Scalars['UUID']; @@ -1167,6 +1196,7 @@ export type File = { }; export enum FileFolder { + AgentChat = 'AgentChat', Attachment = 'Attachment', File = 'File', PersonPicture = 'PersonPicture', diff --git a/packages/twenty-front/src/modules/ai/components/AIChatAssistantMessageRenderer.tsx b/packages/twenty-front/src/modules/ai/components/AIChatAssistantMessageRenderer.tsx index 27c47f753f..711a8d6bd8 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatAssistantMessageRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatAssistantMessageRenderer.tsx @@ -1,16 +1,13 @@ -import { ErrorStepRenderer } from '@/ai/components/ErrorStepRenderer'; import { ReasoningSummaryDisplay } from '@/ai/components/ReasoningSummaryDisplay'; -import { ToolStepRenderer } from '@/ai/components/ToolStepRenderer'; -import type { ParsedStep } from '@/ai/types/ParsedStep'; -import { hasStructuredStreamData } from '@/ai/utils/hasStructuredStreamData'; -import { parseStream } from '@/ai/utils/parseStream'; import { IconDotsVertical } from 'twenty-ui/display'; import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer'; -import { agentStreamingMessageState } from '@/ai/states/agentStreamingMessageState'; +import { ToolStepRenderer } from '@/ai/components/ToolStepRenderer'; +import { type ToolInput } from '@/ai/types/ToolInput'; +import { type ToolOutput } from '@/ai/types/ToolOutput'; import { keyframes, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { useRecoilValue } from 'recoil'; +import type { ToolUIPart, UIDataTypes, UIMessagePart, UITools } from 'ai'; const StyledStepsContainer = styled.div` display: flex; @@ -60,60 +57,55 @@ const LoadingDotsIcon = () => { }; export const AIChatAssistantMessageRenderer = ({ - streamData, + messageParts, + isLastMessageStreaming, }: { - streamData: string; + messageParts: UIMessagePart[]; + isLastMessageStreaming: boolean; }) => { - const agentStreamingMessage = useRecoilValue(agentStreamingMessageState); - const isStreaming = streamData === agentStreamingMessage; - - if (!streamData) { - return ; - } - - const hasStructuredData = hasStructuredStreamData(streamData); - - if (!hasStructuredData) { - return ; - } - - const steps = parseStream(streamData); - - if (!steps.length) { - return ; - } - - const renderStep = (step: ParsedStep, index: number) => { + const renderStep = ( + step: UIMessagePart, + index: number, + ) => { switch (step.type) { - case 'tool': - return ; case 'reasoning': return ( ); case 'text': - return ; - case 'error': - return ( - - ); + return ; default: + { + if (step.type.includes('tool-')) { + const { output, input, type } = step as ToolUIPart; + return ( + + ); + } + } return null; } }; + if (!messageParts.length) { + return ; + } + return (
- {steps.map(renderStep)} - {isStreaming && } + + {messageParts.map(renderStep)} + + {isLastMessageStreaming && }
); }; diff --git a/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx index 9696a017f9..54236df66a 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx @@ -5,10 +5,10 @@ import { Avatar, IconSparkles } from 'twenty-ui/display'; import { AgentChatFilePreview } from '@/ai/components/internal/AgentChatFilePreview'; import { AgentChatMessageRole } from '@/ai/constants/AgentChatMessageRole'; -import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton'; import { AIChatAssistantMessageRenderer } from '@/ai/components/AIChatAssistantMessageRenderer'; -import { type AgentChatMessage } from '~/generated/graphql'; +import { type UIMessageWithMetadata } from '@/ai/types/UIMessageWithMetadata'; +import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton'; import { dateLocaleState } from '~/localization/states/dateLocaleState'; import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; const StyledMessageBubble = styled.div<{ isUser?: boolean }>` @@ -74,7 +74,8 @@ const StyledMessageText = styled.div<{ isUser?: boolean }>` } p { - margin: ${({ theme }) => theme.spacing(1)} 0; + margin-block: ${({ isUser, theme }) => + isUser ? '0' : `${theme.spacing(1)}`}; line-height: 1.5; } @@ -137,10 +138,10 @@ const StyledFilesContainer = styled.div` export const AIChatMessage = ({ message, - agentStreamingMessage, + isLastMessageStreaming, }: { - message: AgentChatMessage; - agentStreamingMessage: string; + message: UIMessageWithMetadata; + isLastMessageStreaming: boolean; }) => { const theme = useTheme(); const { localeCatalog } = useRecoilValue(dateLocaleState); @@ -170,30 +171,33 @@ export const AIChatMessage = ({ - {message.role === AgentChatMessageRole.ASSISTANT ? ( - - ) : ( - message.rawContent - )} + - {message.files.length > 0 && ( + {message.parts.length > 0 && ( - {message.files.map((file) => ( - - ))} + {message.parts + .filter((part) => part.type === 'file') + .map((file) => ( + + ))} )} - {message.rawContent && ( + {message.parts.length > 0 && message.metadata?.createdAt && ( {beautifyPastDateRelativeToNow( - message.createdAt, + message.metadata?.createdAt, localeCatalog, )} - + part.type === 'text')?.text ?? '' + } + /> )} diff --git a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx index c452890817..bc8200f769 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx @@ -14,10 +14,8 @@ import { AIChatMessage } from '@/ai/components/AIChatMessage'; import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader'; import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview'; import { SendMessageButton } from '@/ai/components/internal/SendMessageButton'; -import { SendMessageWithRecordsContextButton } from '@/ai/components/internal/SendMessageWithRecordsContextButton'; import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload'; -import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { type UIMessageWithMetadata } from '@/ai/types/UIMessageWithMetadata'; import { t } from '@lingui/core/macro'; import { useState } from 'react'; import { Button } from 'twenty-ui/input'; @@ -61,24 +59,23 @@ const StyledButtonsContainer = styled.div` export const AIChatTab = ({ agentId, isWorkflowAgentNodeChat, + uiMessages, }: { agentId: string; isWorkflowAgentNodeChat?: boolean; + uiMessages: UIMessageWithMetadata[]; }) => { const [isDraggingFile, setIsDraggingFile] = useState(false); - const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValue( - contextStoreCurrentObjectMetadataItemIdComponentState, - ); - const { - messages, isLoading, input, handleInputChange, - agentStreamingMessage, scrollWrapperId, - } = useAgentChat(agentId); + messages, + handleSendMessage, + isStreaming, + } = useAgentChat(agentId, uiMessages); const { uploadFiles } = useAIChatFileUpload({ agentId }); const { createAgentChatThread } = useCreateNewAIChatThread({ agentId }); @@ -101,14 +98,17 @@ export const AIChatTab = ({ {messages.map((message) => ( ))} )} - {messages.length === 0 && !isLoading && } + {messages.length === 0 && } {isLoading && messages.length === 0 && } @@ -145,11 +145,12 @@ export const AIChatTab = ({ )} - {contextStoreCurrentObjectMetadataItemId ? ( - - ) : ( - - )} + diff --git a/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx b/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx index 036f7c66a8..d91ca8cede 100644 --- a/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx @@ -6,12 +6,9 @@ import { IconChevronDown, IconChevronUp } from 'twenty-ui/display'; import { AnimatedExpandableContainer } from 'twenty-ui/layout'; import { ShimmeringText } from '@/ai/components/ShimmeringText'; +import { type ToolInput } from '@/ai/types/ToolInput'; +import { type ToolOutput } from '@/ai/types/ToolOutput'; import { getToolIcon } from '@/ai/utils/getToolIcon'; -import type { - ToolCallEvent, - ToolEvent, - ToolResultEvent, -} from 'twenty-shared/ai'; import { isDefined } from 'twenty-shared/utils'; const StyledContainer = styled.div` @@ -70,32 +67,26 @@ const StyledIconTextContainer = styled.div` } `; -export const ToolStepRenderer = ({ events }: { events: ToolEvent[] }) => { +export const ToolStepRenderer = ({ + input, + output, + toolName, +}: { + input: ToolInput; + output: ToolOutput; + toolName: string; +}) => { const theme = useTheme(); const [isExpanded, setIsExpanded] = useState(false); - const toolCallEvent = events[0] as ToolCallEvent | undefined; - const toolResultEvent = events.find( - (event): event is ToolResultEvent => event.type === 'tool-result', - ); + const isExpandable = isDefined(output); - if (!toolCallEvent) { - return null; - } - - const toolOutput = - toolResultEvent?.output?.error ?? toolResultEvent?.output?.result; - - const isExpandable = isDefined(toolOutput); - - if (!toolResultEvent) { + if (!output) { return ( - - {toolCallEvent.input.loadingMessage} - + {input?.loadingMessage} @@ -103,13 +94,16 @@ export const ToolStepRenderer = ({ events }: { events: ToolEvent[] }) => { } const displayMessage = - toolResultEvent?.output && - typeof toolResultEvent.output === 'object' && - 'message' in toolResultEvent.output - ? (toolResultEvent.output as { message: string }).message + output && typeof output === 'object' && 'message' in output + ? (output as { message: string }).message : undefined; - const ToolIcon = getToolIcon(toolCallEvent.toolName); + const result = + output && typeof output === 'object' && 'result' in output + ? (output as { result: string }).result + : output; + + const ToolIcon = getToolIcon(toolName); return ( @@ -132,7 +126,7 @@ export const ToolStepRenderer = ({ events }: { events: ToolEvent[] }) => { {isExpandable && ( - {JSON.stringify(toolOutput, null, 2)} + {JSON.stringify(result, null, 2)} )} diff --git a/packages/twenty-front/src/modules/ai/components/internal/AgentChatContextPreview.tsx b/packages/twenty-front/src/modules/ai/components/internal/AgentChatContextPreview.tsx index 1a47acb82e..954c90e970 100644 --- a/packages/twenty-front/src/modules/ai/components/internal/AgentChatContextPreview.tsx +++ b/packages/twenty-front/src/modules/ai/components/internal/AgentChatContextPreview.tsx @@ -2,12 +2,9 @@ import { AgentChatContextRecordPreview } from '@/ai/components/internal/AgentCha import { agentChatSelectedFilesComponentState } from '@/ai/states/agentChatSelectedFilesComponentState'; import { agentChatUploadedFilesComponentState } from '@/ai/states/agentChatUploadedFilesComponentState'; import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import styled from '@emotion/styled'; -import { useLingui } from '@lingui/react/macro'; -import { useDeleteFileMutation } from '~/generated-metadata/graphql'; import { AgentChatFilePreview } from './AgentChatFilePreview'; const StyledContainer = styled.div` @@ -25,31 +22,15 @@ const StyledPreviewsContainer = styled.div` `; export const AgentChatContextPreview = ({ agentId }: { agentId: string }) => { - const { t } = useLingui(); const [agentChatSelectedFiles, setAgentChatSelectedFiles] = useRecoilComponentState(agentChatSelectedFilesComponentState, agentId); const [agentChatUploadedFiles, setAgentChatUploadedFiles] = useRecoilComponentState(agentChatUploadedFilesComponentState, agentId); - const { enqueueErrorSnackBar } = useSnackBar(); - - const [deleteFile] = useDeleteFileMutation(); - - const handleRemoveUploadedFile = async (fileId: string) => { - const originalFiles = agentChatUploadedFiles; - + const handleRemoveUploadedFile = async (fileIndex: number) => { setAgentChatUploadedFiles( - agentChatUploadedFiles.filter((f) => f.id !== fileId), + agentChatUploadedFiles.filter((f, index) => fileIndex !== index), ); - - try { - await deleteFile({ variables: { fileId } }); - } catch { - setAgentChatUploadedFiles(originalFiles); - enqueueErrorSnackBar({ - message: t`Failed to remove file`, - }); - } }; const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValue( @@ -71,11 +52,11 @@ export const AgentChatContextPreview = ({ agentId }: { agentId: string }) => { isUploading /> ))} - {agentChatUploadedFiles.map((file) => ( + {agentChatUploadedFiles.map((file, index) => ( handleRemoveUploadedFile(file.id)} + key={index} + onRemove={() => handleRemoveUploadedFile(index)} isUploading={false} /> ))} diff --git a/packages/twenty-front/src/modules/ai/components/internal/AgentChatFilePreview.tsx b/packages/twenty-front/src/modules/ai/components/internal/AgentChatFilePreview.tsx index e01e76e2e4..4a3ac7daa2 100644 --- a/packages/twenty-front/src/modules/ai/components/internal/AgentChatFilePreview.tsx +++ b/packages/twenty-front/src/modules/ai/components/internal/AgentChatFilePreview.tsx @@ -1,34 +1,37 @@ import { getFileType } from '@/activities/files/utils/getFileType'; import { IconMapping, useFileTypeColors } from '@/file/utils/fileIconMappings'; import { useTheme } from '@emotion/react'; +import { type FileUIPart } from 'ai'; import { AvatarChip, Chip, ChipVariant } from 'twenty-ui/components'; import { IconX } from 'twenty-ui/display'; import { Loader } from 'twenty-ui/feedback'; -import { type File as FileDocument } from '~/generated-metadata/graphql'; export const AgentChatFilePreview = ({ file, onRemove, isUploading, }: { - file: File | FileDocument; + file: FileUIPart | File; onRemove?: () => void; isUploading?: boolean; }) => { const theme = useTheme(); const iconColors = useFileTypeColors(); + const fileName = + file instanceof File ? file.name : (file.filename ?? 'Unknown file'); + return ( ) : ( ) } diff --git a/packages/twenty-front/src/modules/ai/components/internal/SendMessageButton.tsx b/packages/twenty-front/src/modules/ai/components/internal/SendMessageButton.tsx index 85ab541b88..4c54c81a83 100644 --- a/packages/twenty-front/src/modules/ai/components/internal/SendMessageButton.tsx +++ b/packages/twenty-front/src/modules/ai/components/internal/SendMessageButton.tsx @@ -1,20 +1,16 @@ -import { Button } from 'twenty-ui/input'; import { t } from '@lingui/core/macro'; -import { useAgentChat } from '@/ai/hooks/useAgentChat'; -import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { Button } from 'twenty-ui/input'; export const SendMessageButton = ({ - records, - agentId, + handleSendMessage, + input, + isLoading, }: { agentId: string; - records?: ObjectRecord[]; + handleSendMessage: () => void; + input: string; + isLoading: boolean; }) => { - const { isLoading, handleSendMessage, input } = useAgentChat( - agentId, - records, - ); - return (