Restructure agent chat messages with parts-based architecture (#14749)

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Abdul Rahman 2025-09-29 17:01:55 +05:30 committed by GitHub
parent 84cbd8e092
commit 2685f4a5b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 1200 additions and 1539 deletions

View file

@ -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",

View file

@ -71,11 +71,40 @@ export type AgentChatMessage = {
createdAt: Scalars['DateTime'];
files: Array<File>;
id: Scalars['UUID'];
rawContent?: Maybe<Scalars['String']>;
parts: Array<AgentChatMessagePart>;
role: Scalars['String'];
threadId: Scalars['UUID'];
};
export type AgentChatMessagePart = {
__typename?: 'AgentChatMessagePart';
createdAt: Scalars['DateTime'];
errorDetails?: Maybe<Scalars['JSON']>;
errorMessage?: Maybe<Scalars['String']>;
fileFilename?: Maybe<Scalars['String']>;
fileMediaType?: Maybe<Scalars['String']>;
fileUrl?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
messageId: Scalars['UUID'];
orderIndex: Scalars['Int'];
providerMetadata?: Maybe<Scalars['JSON']>;
reasoningContent?: Maybe<Scalars['String']>;
sourceDocumentFilename?: Maybe<Scalars['String']>;
sourceDocumentMediaType?: Maybe<Scalars['String']>;
sourceDocumentSourceId?: Maybe<Scalars['String']>;
sourceDocumentTitle?: Maybe<Scalars['String']>;
sourceUrlSourceId?: Maybe<Scalars['String']>;
sourceUrlTitle?: Maybe<Scalars['String']>;
sourceUrlUrl?: Maybe<Scalars['String']>;
state?: Maybe<Scalars['String']>;
textContent?: Maybe<Scalars['String']>;
toolCallId?: Maybe<Scalars['String']>;
toolInput?: Maybe<Scalars['JSON']>;
toolName?: Maybe<Scalars['String']>;
toolOutput?: Maybe<Scalars['JSON']>;
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

View file

@ -71,11 +71,40 @@ export type AgentChatMessage = {
createdAt: Scalars['DateTime'];
files: Array<File>;
id: Scalars['UUID'];
rawContent?: Maybe<Scalars['String']>;
parts: Array<AgentChatMessagePart>;
role: Scalars['String'];
threadId: Scalars['UUID'];
};
export type AgentChatMessagePart = {
__typename?: 'AgentChatMessagePart';
createdAt: Scalars['DateTime'];
errorDetails?: Maybe<Scalars['JSON']>;
errorMessage?: Maybe<Scalars['String']>;
fileFilename?: Maybe<Scalars['String']>;
fileMediaType?: Maybe<Scalars['String']>;
fileUrl?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
messageId: Scalars['UUID'];
orderIndex: Scalars['Int'];
providerMetadata?: Maybe<Scalars['JSON']>;
reasoningContent?: Maybe<Scalars['String']>;
sourceDocumentFilename?: Maybe<Scalars['String']>;
sourceDocumentMediaType?: Maybe<Scalars['String']>;
sourceDocumentSourceId?: Maybe<Scalars['String']>;
sourceDocumentTitle?: Maybe<Scalars['String']>;
sourceUrlSourceId?: Maybe<Scalars['String']>;
sourceUrlTitle?: Maybe<Scalars['String']>;
sourceUrlUrl?: Maybe<Scalars['String']>;
state?: Maybe<Scalars['String']>;
textContent?: Maybe<Scalars['String']>;
toolCallId?: Maybe<Scalars['String']>;
toolInput?: Maybe<Scalars['JSON']>;
toolName?: Maybe<Scalars['String']>;
toolOutput?: Maybe<Scalars['JSON']>;
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',

View file

@ -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<UIDataTypes, UITools>[];
isLastMessageStreaming: boolean;
}) => {
const agentStreamingMessage = useRecoilValue(agentStreamingMessageState);
const isStreaming = streamData === agentStreamingMessage;
if (!streamData) {
return <LoadingDotsIcon />;
}
const hasStructuredData = hasStructuredStreamData(streamData);
if (!hasStructuredData) {
return <LazyMarkdownRenderer text={streamData} />;
}
const steps = parseStream(streamData);
if (!steps.length) {
return <LoadingDotsIcon />;
}
const renderStep = (step: ParsedStep, index: number) => {
const renderStep = (
step: UIMessagePart<UIDataTypes, UITools>,
index: number,
) => {
switch (step.type) {
case 'tool':
return <ToolStepRenderer key={index} events={step.events} />;
case 'reasoning':
return (
<ReasoningSummaryDisplay
key={index}
content={step.content}
isThinking={step.isThinking}
content={step.text}
isThinking={step.state === 'streaming'}
/>
);
case 'text':
return <LazyMarkdownRenderer key={index} text={step.content} />;
case 'error':
return (
<ErrorStepRenderer
key={index}
message={step.message}
error={step.error}
/>
);
return <LazyMarkdownRenderer key={index} text={step.text} />;
default:
{
if (step.type.includes('tool-')) {
const { output, input, type } = step as ToolUIPart;
return (
<ToolStepRenderer
key={index}
input={input as ToolInput}
output={output as ToolOutput}
toolName={type.split('-')[1]}
/>
);
}
}
return null;
}
};
if (!messageParts.length) {
return <LoadingDotsIcon />;
}
return (
<div>
<StyledStepsContainer>{steps.map(renderStep)}</StyledStepsContainer>
{isStreaming && <StyledToolCallContainer />}
<StyledStepsContainer>
{messageParts.map(renderStep)}
</StyledStepsContainer>
{isLastMessageStreaming && <StyledToolCallContainer />}
</div>
);
};

View file

@ -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 = ({
<StyledMessageText
isUser={message.role === AgentChatMessageRole.USER}
>
{message.role === AgentChatMessageRole.ASSISTANT ? (
<AIChatAssistantMessageRenderer
streamData={message.rawContent || agentStreamingMessage}
/>
) : (
message.rawContent
)}
<AIChatAssistantMessageRenderer
isLastMessageStreaming={isLastMessageStreaming}
messageParts={message.parts}
/>
</StyledMessageText>
{message.files.length > 0 && (
{message.parts.length > 0 && (
<StyledFilesContainer>
{message.files.map((file) => (
<AgentChatFilePreview key={file.id} file={file} />
))}
{message.parts
.filter((part) => part.type === 'file')
.map((file) => (
<AgentChatFilePreview key={file.filename} file={file} />
))}
</StyledFilesContainer>
)}
{message.rawContent && (
{message.parts.length > 0 && message.metadata?.createdAt && (
<StyledMessageFooter className="message-footer">
<span>
{beautifyPastDateRelativeToNow(
message.createdAt,
message.metadata?.createdAt,
localeCatalog,
)}
</span>
<LightCopyIconButton copyText={message.rawContent} />
<LightCopyIconButton
copyText={
message.parts.find((part) => part.type === 'text')?.text ?? ''
}
/>
</StyledMessageFooter>
)}
</StyledMessageContainer>

View file

@ -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 = ({
<StyledScrollWrapper componentInstanceId={scrollWrapperId}>
{messages.map((message) => (
<AIChatMessage
agentStreamingMessage={agentStreamingMessage}
isLastMessageStreaming={
isStreaming &&
message.id === messages[messages.length - 1].id
}
message={message}
key={message.id}
/>
))}
</StyledScrollWrapper>
)}
{messages.length === 0 && !isLoading && <AIChatEmptyState />}
{messages.length === 0 && <AIChatEmptyState />}
{isLoading && messages.length === 0 && <AIChatSkeletonLoader />}
<StyledInputArea>
@ -145,11 +145,12 @@ export const AIChatTab = ({
</>
)}
<AgentChatFileUploadButton agentId={agentId} />
{contextStoreCurrentObjectMetadataItemId ? (
<SendMessageWithRecordsContextButton agentId={agentId} />
) : (
<SendMessageButton agentId={agentId} />
)}
<SendMessageButton
agentId={agentId}
handleSendMessage={handleSendMessage}
input={input}
isLoading={isLoading}
/>
</StyledButtonsContainer>
</StyledInputArea>
</>

View file

@ -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 (
<StyledContainer>
<StyledLoadingContainer>
<ShimmeringText>
<StyledDisplayMessage>
{toolCallEvent.input.loadingMessage}
</StyledDisplayMessage>
<StyledDisplayMessage>{input?.loadingMessage}</StyledDisplayMessage>
</ShimmeringText>
</StyledLoadingContainer>
</StyledContainer>
@ -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 (
<StyledContainer>
@ -132,7 +126,7 @@ export const ToolStepRenderer = ({ events }: { events: ToolEvent[] }) => {
{isExpandable && (
<AnimatedExpandableContainer isExpanded={isExpanded}>
<StyledContentContainer>
<StyledPre>{JSON.stringify(toolOutput, null, 2)}</StyledPre>
<StyledPre>{JSON.stringify(result, null, 2)}</StyledPre>
</StyledContentContainer>
</AnimatedExpandableContainer>
)}

View file

@ -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) => (
<AgentChatFilePreview
file={file}
key={file.id}
onRemove={() => handleRemoveUploadedFile(file.id)}
key={index}
onRemove={() => handleRemoveUploadedFile(index)}
isUploading={false}
/>
))}

View file

@ -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 (
<Chip
label={file.name}
label={fileName}
variant={ChipVariant.Static}
leftComponent={
isUploading ? (
<Loader color="yellow" />
) : (
<AvatarChip
Icon={IconMapping[getFileType(file.name)]}
IconBackgroundColor={iconColors[getFileType(file.name)]}
Icon={IconMapping[getFileType(fileName)]}
IconBackgroundColor={iconColors[getFileType(fileName)]}
/>
)
}

View file

@ -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 (
<Button
variant="primary"

View file

@ -1,14 +0,0 @@
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
export const SendMessageWithRecordsContextButton = ({
agentId,
}: {
agentId: string;
}) => {
const { records } = useFindManyRecordsSelectedInContextStore({
limit: 10,
});
return <SendMessageButton records={records} agentId={agentId} />;
};

View file

@ -7,7 +7,33 @@ export const GET_AGENT_CHAT_MESSAGES = 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

View file

@ -4,15 +4,15 @@ import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import {
type File as FileDocument,
useCreateFileMutation,
} from '~/generated-metadata/graphql';
import { type FileUIPart } from 'ai';
import { buildSignedPath, isDefined } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useUploadFileMutation } from '~/generated-metadata/graphql';
import { FileFolder } from '~/generated/graphql';
export const useAIChatFileUpload = ({ agentId }: { agentId: string }) => {
const coreClient = useApolloCoreClient();
const [createFile] = useCreateFileMutation({ client: coreClient });
const [uploadFile] = useUploadFileMutation({ client: coreClient });
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const [agentChatSelectedFiles, setAgentChatSelectedFiles] =
@ -20,23 +20,35 @@ export const useAIChatFileUpload = ({ agentId }: { agentId: string }) => {
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
useRecoilComponentState(agentChatUploadedFilesComponentState, agentId);
const sendFile = async (file: File) => {
const sendFile = async (file: File): Promise<FileUIPart | null> => {
try {
const result = await createFile({
const result = await uploadFile({
variables: {
file,
fileFolder: FileFolder.AgentChat,
},
});
const uploadedFile = result?.data?.createFile;
const response = result?.data?.uploadFile;
if (!isDefined(uploadedFile)) {
if (!isDefined(response)) {
throw new Error(t`Couldn't upload the file.`);
}
const signedPath = buildSignedPath({
path: response.path,
token: response.token,
});
setAgentChatSelectedFiles(
agentChatSelectedFiles.filter((f) => f.name !== file.name),
);
return uploadedFile;
return {
filename: file.name,
mediaType: file.type,
url: `${REACT_APP_SERVER_BASE_URL}/files/${signedPath}`,
type: 'file',
};
} catch {
const fileName = file.name;
enqueueErrorSnackBar({
@ -51,7 +63,7 @@ export const useAIChatFileUpload = ({ agentId }: { agentId: string }) => {
files.map((file) => sendFile(file)),
);
const successfulUploads = uploadResults.reduce<FileDocument[]>(
const successfulUploads = uploadResults.reduce<FileUIPart[]>(
(acc, result) => {
if (result.status === 'fulfilled' && isDefined(result.value)) {
acc.push(result.value);

View file

@ -1,10 +1,7 @@
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { AgentChatMessageRole } from '@/ai/constants/AgentChatMessageRole';
import { STREAM_CHAT_QUERY } from '@/ai/rest-api/agent-chat-apollo.api';
import {
type AIChatObjectMetadataAndRecordContext,
agentChatObjectMetadataAndRecordContextState,
@ -13,36 +10,35 @@ import { agentChatSelectedFilesComponentState } from '@/ai/states/agentChatSelec
import { agentChatUploadedFilesComponentState } from '@/ai/states/agentChatUploadedFilesComponentState';
import { currentAIChatThreadComponentState } from '@/ai/states/currentAIChatThreadComponentState';
import { isAgentChatCurrentContextActiveState } from '@/ai/states/isAgentChatCurrentContextActiveState';
import { type UIMessageWithMetadata } from '@/ai/types/UIMessageWithMetadata';
import { getTokenPair } from '@/apollo/utils/getTokenPair';
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { useGetObjectMetadataItemById } from '@/object-metadata/hooks/useGetObjectMetadataItemById';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useApolloClient } from '@apollo/client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import {
useGetAgentChatMessagesQuery,
useGetAgentChatThreadsQuery,
} from '~/generated-metadata/graphql';
import { type AgentChatMessage } from '~/generated/graphql';
import { REST_API_BASE_URL } from '../../apollo/constant/rest-api-base-url';
import { agentChatInputState } from '../states/agentChatInputState';
import { agentChatMessagesComponentState } from '../states/agentChatMessagesComponentState';
import { agentStreamingMessageState } from '../states/agentStreamingMessageState';
type OptimisticMessage = AgentChatMessage & {
isPending: boolean;
};
export const useAgentChat = (agentId: string, records?: ObjectRecord[]) => {
const apolloClient = useApolloClient();
export const useAgentChat = (
agentId: string,
uiMessages: UIMessageWithMetadata[],
) => {
const { getObjectMetadataItemById } = useGetObjectMetadataItemById();
const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const { records } = useFindManyRecordsSelectedInContextStore({
limit: 10,
});
const isAgentChatCurrentContextActive = useRecoilComponentValue(
isAgentChatCurrentContextActiveState,
agentId,
@ -58,32 +54,40 @@ export const useAgentChat = (agentId: string, records?: ObjectRecord[]) => {
agentId,
);
const [currentThreadId, setCurrentThreadId] = useRecoilComponentState(
const currentThreadId = useRecoilComponentValue(
currentAIChatThreadComponentState,
agentId,
);
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
useRecoilComponentState(agentChatUploadedFilesComponentState, agentId);
const [agentChatMessages, setAgentChatMessages] = useRecoilComponentState(
agentChatMessagesComponentState,
agentId,
);
const [agentChatInput, setAgentChatInput] =
useRecoilState(agentChatInputState);
const [agentStreamingMessage, setAgentStreamingMessage] = useRecoilState(
agentStreamingMessageState,
);
const [isStreaming, setIsStreaming] = useState(false);
const scrollWrapperId = `scroll-wrapper-ai-chat-${agentId}`;
const { scrollWrapperHTMLElement } =
useScrollWrapperHTMLElement(scrollWrapperId);
const { enqueueErrorSnackBar } = useSnackBar();
const { sendMessage, messages, status, error } = useChat({
transport: new DefaultChatTransport({
api: `${REST_API_BASE_URL}/agent-chat/stream`,
headers: () => ({
Authorization: `Bearer ${getTokenPair()?.accessOrWorkspaceAgnosticToken.token}`,
}),
}),
messages: uiMessages,
id: currentThreadId as string,
onError: (error) => {
enqueueErrorSnackBar({ message: error.message });
},
});
const isStreaming = status === 'streaming';
const scrollToBottom = () => {
scrollWrapperHTMLElement?.scroll({
top: scrollWrapperHTMLElement.scrollHeight,
@ -91,64 +95,10 @@ export const useAgentChat = (agentId: string, records?: ObjectRecord[]) => {
});
};
const { loading: threadsLoading } = useGetAgentChatThreadsQuery({
variables: { agentId },
skip: isDefined(currentThreadId),
onCompleted: (data) => {
if (data.agentChatThreads.length > 0) {
setCurrentThreadId(data.agentChatThreads[0].id);
}
},
});
const { loading: messagesLoading, refetch: refetchMessages } =
useGetAgentChatMessagesQuery({
variables: { threadId: currentThreadId as string },
skip: !isDefined(currentThreadId),
onCompleted: ({ agentChatMessages }) => {
setAgentChatMessages(agentChatMessages);
scrollToBottom();
},
});
const isLoading =
messagesLoading ||
threadsLoading ||
!currentThreadId ||
isStreaming ||
agentChatSelectedFiles.length > 0;
const createOptimisticMessages = (content: string): AgentChatMessage[] => {
const optimisticUserMessage: OptimisticMessage = {
id: v4(),
threadId: currentThreadId as string,
role: AgentChatMessageRole.USER,
rawContent: content,
createdAt: new Date().toISOString(),
isPending: true,
files: agentChatUploadedFiles,
};
const optimisticAiMessage: OptimisticMessage = {
id: v4(),
threadId: currentThreadId as string,
role: AgentChatMessageRole.ASSISTANT,
rawContent: '',
createdAt: new Date().toISOString(),
isPending: true,
files: [],
};
return [optimisticUserMessage, optimisticAiMessage];
};
const streamAgentResponse = async (content: string) => {
if (!currentThreadId) {
return '';
}
setIsStreaming(true);
!currentThreadId || isStreaming || agentChatSelectedFiles.length > 0;
const sendChatMessage = async (content: string) => {
const recordIdsByObjectMetadataNameSingular = [];
if (
@ -164,47 +114,21 @@ export const useAgentChat = (agentId: string, records?: ObjectRecord[]) => {
});
}
await apolloClient.query({
query: STREAM_CHAT_QUERY,
variables: {
requestBody: {
sendMessage(
{
text: content,
files: agentChatUploadedFiles,
},
{
body: {
threadId: currentThreadId,
userMessage: content,
fileIds: agentChatUploadedFiles.map((file) => file.id),
recordIdsByObjectMetadataNameSingular:
recordIdsByObjectMetadataNameSingular,
recordIdsByObjectMetadataNameSingular,
},
},
context: {
onChunk: (chunk: string) => {
setAgentStreamingMessage((prev) => prev + chunk);
scrollToBottom();
},
},
});
setIsStreaming(false);
};
const sendChatMessage = async (content: string) => {
const optimisticMessages = createOptimisticMessages(content);
setAgentChatMessages((prevMessages) => [
...prevMessages,
...optimisticMessages,
]);
);
setAgentChatUploadedFiles([]);
setTimeout(scrollToBottom, 100);
await streamAgentResponse(content);
const { data } = await refetchMessages();
setAgentChatMessages(data?.agentChatMessages);
setAgentStreamingMessage('');
scrollToBottom();
};
const handleSendMessage = async () => {
@ -239,13 +163,14 @@ export const useAgentChat = (agentId: string, records?: ObjectRecord[]) => {
return {
handleInputChange: (value: string) => setAgentChatInput(value),
messages: agentChatMessages,
messages,
input: agentChatInput,
context: agentChatContext,
handleSetContext,
handleSendMessage,
isLoading,
agentStreamingMessage,
scrollWrapperId,
isStreaming,
error,
};
};

View file

@ -1,46 +0,0 @@
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
title
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
files
}
}
`;
export const STREAM_CHAT_QUERY = gql`
query StreamChatResponse($requestBody: JSON!) {
streamChatResponse(requestBody: $requestBody)
@stream(
type: "StreamChatResponse"
path: "/agent-chat/stream"
method: "POST"
bodyKey: "requestBody"
)
}
`;

View file

@ -1,9 +1,9 @@
import { AgentChatMessagesComponentInstanceContext } from '@/ai/states/agentChatMessagesComponentState';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { type File } from '~/generated-metadata/graphql';
import { type FileUIPart } from 'ai';
export const agentChatUploadedFilesComponentState = createComponentState<
File[]
FileUIPart[]
>({
key: 'agentChatUploadedFilesComponentState',
defaultValue: [],

View file

@ -1,6 +0,0 @@
import { atom } from 'recoil';
export const agentStreamingMessageState = atom<string>({
key: 'agentStreamingMessageState',
default: '',
});

View file

@ -1,7 +0,0 @@
import type { ToolEvent } from 'twenty-shared/ai';
export type ParsedStep =
| { type: 'tool'; events: ToolEvent[] }
| { type: 'reasoning'; content: string; isThinking: boolean }
| { type: 'text'; content: string }
| { type: 'error'; message: string; error?: unknown };

View file

@ -0,0 +1,4 @@
export type ToolInput = {
loadingMessage: string;
input: Record<string, unknown>;
};

View file

@ -0,0 +1 @@
export type ToolOutput = Record<string, unknown>;

View file

@ -0,0 +1,7 @@
import { type UIMessage } from 'ai';
export type UIMessageWithMetadata = UIMessage & {
metadata: {
createdAt: string;
};
};

View file

@ -1,440 +0,0 @@
import { parseStream } from '../parseStream';
describe('parseStream', () => {
describe('tool-call events', () => {
it('should parse tool-call event correctly', () => {
const streamText = JSON.stringify({
type: 'tool-call',
toolCallId: 'call-123',
toolName: 'send_email',
args: {
loadingMessage: 'Sending email...',
input: { to: 'test@example.com' },
},
});
const result = parseStream(streamText);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'tool',
events: [
{
type: 'tool-call',
toolCallId: 'call-123',
toolName: 'send_email',
args: {
loadingMessage: 'Sending email...',
input: { to: 'test@example.com' },
},
},
],
});
});
});
describe('tool-result events', () => {
it('should parse tool-result event and append to existing tool entry', () => {
const streamText = [
JSON.stringify({
type: 'tool-call',
toolCallId: 'call-123',
toolName: 'send_email',
args: { loadingMessage: 'Sending email...', input: {} },
}),
JSON.stringify({
type: 'tool-result',
toolCallId: 'call-123',
toolName: 'send_email',
result: { success: true, result: 'Email sent', message: 'Success' },
message: 'Email sent successfully',
}),
].join('\n');
const result = parseStream(streamText);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'tool',
events: [
{
type: 'tool-call',
toolCallId: 'call-123',
toolName: 'send_email',
args: { loadingMessage: 'Sending email...', input: {} },
},
{
type: 'tool-result',
toolCallId: 'call-123',
toolName: 'send_email',
result: { success: true, result: 'Email sent', message: 'Success' },
message: 'Email sent successfully',
},
],
});
});
it('should create new tool entry for orphaned tool-result', () => {
const streamText = JSON.stringify({
type: 'tool-result',
toolCallId: 'call-456',
toolName: 'http_request',
result: {
success: true,
result: 'Response received',
message: 'Success',
},
message: 'Request completed',
});
const result = parseStream(streamText);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'tool',
events: [
{
type: 'tool-result',
toolCallId: 'call-456',
toolName: 'http_request',
result: {
success: true,
result: 'Response received',
message: 'Success',
},
message: 'Request completed',
},
],
});
});
});
describe('reasoning events', () => {
it('should parse reasoning events correctly', () => {
const streamText = [
JSON.stringify({ type: 'reasoning-start' }),
JSON.stringify({
type: 'reasoning-delta',
text: 'Let me think about this...',
}),
JSON.stringify({
type: 'reasoning-delta',
text: ' I need to consider the options.',
}),
JSON.stringify({ type: 'reasoning-end' }),
].join('\n');
const result = parseStream(streamText);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'reasoning',
content: 'Let me think about this... I need to consider the options.',
isThinking: false,
});
});
it('should handle reasoning without end as thinking', () => {
const streamText = [
JSON.stringify({ type: 'reasoning-start' }),
JSON.stringify({
type: 'reasoning-delta',
text: 'Still thinking...',
}),
].join('\n');
const result = parseStream(streamText);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'reasoning',
content: 'Still thinking...',
isThinking: true,
});
});
it('should concatenate multiple reasoning deltas', () => {
const streamText = [
JSON.stringify({ type: 'reasoning-start' }),
JSON.stringify({ type: 'reasoning-delta', text: 'First part' }),
JSON.stringify({ type: 'reasoning-delta', text: ' second part' }),
JSON.stringify({ type: 'reasoning-delta', text: ' third part' }),
].join('\n');
const result = parseStream(streamText);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'reasoning',
content: 'First part second part third part',
isThinking: true,
});
});
});
describe('text-delta events', () => {
it('should parse text-delta events correctly', () => {
const streamText = [
JSON.stringify({ type: 'text-delta', text: 'Hello, ' }),
JSON.stringify({ type: 'text-delta', text: 'world!' }),
].join('\n');
const result = parseStream(streamText);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'text',
content: 'Hello, world!',
});
});
it('should handle empty text', () => {
const streamText = JSON.stringify({ type: 'text-delta', text: '' });
const result = parseStream(streamText);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'text',
content: '',
});
});
});
describe('error events', () => {
it('should parse error events correctly', () => {
const streamText = JSON.stringify({
type: 'error',
message: 'Something went wrong',
error: { code: 'TIMEOUT', details: 'Request timed out' },
});
const result = parseStream(streamText);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'error',
message: 'Something went wrong',
error: { code: 'TIMEOUT', details: 'Request timed out' },
});
});
it('should handle error without error details', () => {
const streamText = JSON.stringify({
type: 'error',
message: 'Generic error',
});
const result = parseStream(streamText);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'error',
message: 'Generic error',
error: undefined,
});
});
it('should use default message when none provided', () => {
const streamText = JSON.stringify({
type: 'error',
});
const result = parseStream(streamText);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'error',
message: 'An error occurred',
error: undefined,
});
});
});
describe('step-finish events', () => {
it('should flush current text block on step-finish', () => {
const streamText = [
JSON.stringify({ type: 'text-delta', text: 'Some text' }),
JSON.stringify({ type: 'step-finish' }),
].join('\n');
const result = parseStream(streamText);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'text',
content: 'Some text',
});
});
it('should mark reasoning as not thinking on step-finish', () => {
const streamText = [
JSON.stringify({ type: 'reasoning-start' }),
JSON.stringify({ type: 'reasoning-delta', text: 'Thinking...' }),
JSON.stringify({ type: 'step-finish' }),
].join('\n');
const result = parseStream(streamText);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'reasoning',
content: 'Thinking...',
isThinking: false,
});
});
});
describe('mixed events', () => {
it('should handle mixed event types correctly', () => {
const streamText = [
JSON.stringify({ type: 'text-delta', text: 'Starting...' }),
JSON.stringify({
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'send_email',
args: { loadingMessage: 'Sending...', input: {} },
}),
JSON.stringify({ type: 'reasoning-start' }),
JSON.stringify({ type: 'reasoning-delta', text: 'Let me think...' }),
JSON.stringify({
type: 'tool-result',
toolCallId: 'call-1',
toolName: 'send_email',
result: { success: true, message: 'Done' },
message: 'Email sent',
}),
JSON.stringify({ type: 'text-delta', text: 'Finished!' }),
].join('\n');
const result = parseStream(streamText);
expect(result).toHaveLength(4);
expect(result[0]).toEqual({ type: 'text', content: 'Starting...' });
expect(result[1]).toEqual({
type: 'tool',
events: [
{
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'send_email',
args: { loadingMessage: 'Sending...', input: {} },
},
{
type: 'tool-result',
toolCallId: 'call-1',
toolName: 'send_email',
result: { success: true, message: 'Done' },
message: 'Email sent',
},
],
});
expect(result[2]).toEqual({
type: 'reasoning',
content: 'Let me think...',
isThinking: true,
});
expect(result[3]).toEqual({
type: 'text',
content: 'Finished!',
});
});
});
describe('edge cases', () => {
it('should handle empty stream', () => {
const result = parseStream('');
expect(result).toEqual([]);
});
it('should handle whitespace-only stream', () => {
const result = parseStream(' \n \t ');
expect(result).toEqual([]);
});
it('should skip invalid JSON lines', () => {
const streamText = [
'invalid json line',
JSON.stringify({ type: 'text-delta', text: 'Valid content' }),
'another invalid line',
].join('\n');
const result = parseStream(streamText);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'text',
content: 'Valid content',
});
});
it('should handle unknown event types', () => {
const streamText = JSON.stringify({
type: 'unknown-event',
data: 'some data',
});
const result = parseStream(streamText);
expect(result).toEqual([]);
});
it('should flush remaining text block at end', () => {
const streamText = JSON.stringify({
type: 'text-delta',
text: 'Unflushed content',
});
const result = parseStream(streamText);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'text',
content: 'Unflushed content',
});
});
});
describe('text block transitions', () => {
it('should create new text block when switching from reasoning to text', () => {
const streamText = [
JSON.stringify({ type: 'reasoning-start' }),
JSON.stringify({ type: 'reasoning-delta', text: 'Thinking...' }),
JSON.stringify({ type: 'text-delta', text: 'Speaking...' }),
].join('\n');
const result = parseStream(streamText);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
type: 'reasoning',
content: 'Thinking...',
isThinking: true,
});
expect(result[1]).toEqual({
type: 'text',
content: 'Speaking...',
});
});
it('should create new reasoning block when switching from text to reasoning', () => {
const streamText = [
JSON.stringify({ type: 'text-delta', text: 'Speaking...' }),
JSON.stringify({ type: 'reasoning-start' }),
JSON.stringify({ type: 'reasoning-delta', text: 'Thinking...' }),
].join('\n');
const result = parseStream(streamText);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
type: 'text',
content: 'Speaking...',
});
expect(result[1]).toEqual({
type: 'reasoning',
content: 'Thinking...',
isThinking: true,
});
});
});
});

View file

@ -0,0 +1,16 @@
import { type UIMessageWithMetadata } from '@/ai/types/UIMessageWithMetadata';
import { mapDBPartToUIMessagePart } from '@/ai/utils/mapDBPartToUIMessagePart';
import { type AgentChatMessage } from '~/generated/graphql';
export const mapDBMessagesToUIMessages = (
dbMessages: AgentChatMessage[],
): UIMessageWithMetadata[] => {
return dbMessages.map((dbMessage) => ({
id: dbMessage.id,
role: dbMessage.role as UIMessageWithMetadata['role'],
parts: dbMessage.parts.map(mapDBPartToUIMessagePart),
metadata: {
createdAt: dbMessage.createdAt,
},
}));
};

View file

@ -0,0 +1,67 @@
import {
type ReasoningUIPart,
type ToolUIPart,
type UIMessagePart,
type UITool,
} from 'ai';
import { type AgentChatMessagePart } from '~/generated/graphql';
export const mapDBPartToUIMessagePart = (
part: AgentChatMessagePart,
): UIMessagePart<never, Record<string, UITool>> => {
switch (part.type) {
case 'text':
return {
type: 'text',
text: part.textContent!,
};
case 'reasoning':
return {
type: 'reasoning',
text: part.reasoningContent!,
state: part.state as ReasoningUIPart['state'],
};
case 'file':
return {
type: 'file',
mediaType: part.fileMediaType!,
filename: part.fileFilename!,
url: part.fileUrl!,
};
case 'source-url':
return {
type: 'source-url',
sourceId: part.sourceUrlSourceId!,
url: part.sourceUrlUrl!,
title: part.sourceUrlTitle!,
providerMetadata: part.providerMetadata ?? undefined,
};
case 'source-document':
return {
type: 'source-document',
sourceId: part.sourceDocumentSourceId!,
mediaType: part.sourceDocumentMediaType!,
title: part.sourceDocumentTitle!,
filename: part.sourceDocumentFilename!,
providerMetadata: part.providerMetadata ?? undefined,
};
case 'step-start':
return {
type: 'step-start',
};
default:
{
if (part.type.includes('tool-') === true) {
return {
type: part.type as `tool-${string}`,
toolCallId: part.toolCallId!,
input: part.toolInput,
output: part.toolOutput,
errorText: part.errorMessage!,
state: part.state,
} as ToolUIPart;
}
}
throw new Error(`Unsupported part type: ${part.type}`);
}
};

View file

@ -1,189 +0,0 @@
import {
parseStreamLine,
splitStreamIntoLines,
type ErrorEvent,
type ReasoningDeltaEvent,
type TextBlock,
type TextDeltaEvent,
type ToolCallEvent,
type ToolEvent,
type ToolResultEvent,
} from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import type { ParsedStep } from '@/ai/types/ParsedStep';
const handleToolCall = (
event: ToolCallEvent,
output: ParsedStep[],
flushTextBlock: () => void,
) => {
flushTextBlock();
output.push({
type: 'tool',
events: [event],
});
};
const handleToolResult = (
event: ToolResultEvent,
output: ParsedStep[],
flushTextBlock: () => void,
) => {
flushTextBlock();
const toolEntry = output.find(
(item): item is { type: 'tool'; events: ToolEvent[] } =>
item.type === 'tool' &&
item.events.some(
(e) => e.type === 'tool-call' && e.toolCallId === event.toolCallId,
),
);
if (isDefined(toolEntry)) {
toolEntry.events.push(event);
} else {
output.push({
type: 'tool',
events: [event],
});
}
};
const handleReasoningStart = (flushTextBlock: () => void): TextBlock => {
flushTextBlock();
return {
type: 'reasoning',
content: '',
isThinking: true,
};
};
const handleReasoningDelta = (
event: ReasoningDeltaEvent,
currentTextBlock: TextBlock,
flushTextBlock: () => void,
): TextBlock => {
if (!currentTextBlock || currentTextBlock.type !== 'reasoning') {
flushTextBlock();
return {
type: 'reasoning',
content: event.text || '',
isThinking: true,
};
}
currentTextBlock.content += event.text || '';
return currentTextBlock;
};
const handleReasoningEnd = (currentTextBlock: TextBlock): TextBlock => {
if (currentTextBlock?.type === 'reasoning') {
return {
...currentTextBlock,
isThinking: false,
};
}
return currentTextBlock;
};
const handleTextDelta = (
event: TextDeltaEvent,
currentTextBlock: TextBlock,
flushTextBlock: () => void,
): TextBlock => {
if (!currentTextBlock || currentTextBlock.type !== 'text') {
flushTextBlock();
return { type: 'text', content: event.text || '' };
}
currentTextBlock.content += event.text || '';
return currentTextBlock;
};
const handleStepFinish = (
currentTextBlock: TextBlock,
flushTextBlock: () => void,
): TextBlock => {
if (currentTextBlock?.type === 'reasoning') {
currentTextBlock.isThinking = false;
}
flushTextBlock();
return null;
};
const handleError = (
event: ErrorEvent,
output: ParsedStep[],
flushTextBlock: () => void,
) => {
flushTextBlock();
output.push({
type: 'error',
message: event.message || 'An error occurred',
error: event.error,
});
};
export const parseStream = (streamText: string): ParsedStep[] => {
const lines = splitStreamIntoLines(streamText);
const output: ParsedStep[] = [];
let currentTextBlock: TextBlock = null;
const flushTextBlock = () => {
if (isDefined(currentTextBlock)) {
output.push(currentTextBlock);
currentTextBlock = null;
}
};
for (const line of lines) {
const event = parseStreamLine(line);
if (!event) {
continue;
}
switch (event.type) {
case 'tool-call':
handleToolCall(event, output, flushTextBlock);
break;
case 'tool-result':
handleToolResult(event, output, flushTextBlock);
break;
case 'reasoning-start':
currentTextBlock = handleReasoningStart(flushTextBlock);
break;
case 'reasoning-delta':
currentTextBlock = handleReasoningDelta(
event,
currentTextBlock,
flushTextBlock,
);
break;
case 'reasoning-end':
currentTextBlock = handleReasoningEnd(currentTextBlock);
break;
case 'text-delta':
currentTextBlock = handleTextDelta(
event,
currentTextBlock,
flushTextBlock,
);
break;
case 'step-finish':
currentTextBlock = handleStepFinish(currentTextBlock, flushTextBlock);
break;
case 'error':
handleError(event, output, flushTextBlock);
break;
}
}
flushTextBlock();
return output;
};

View file

@ -1,7 +1,16 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { AIChatTab } from '@/ai/components/AIChatTab';
import { currentAIChatThreadComponentState } from '@/ai/states/currentAIChatThreadComponentState';
import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import {
useGetAgentChatMessagesQuery,
useGetAgentChatThreadsQuery,
} from '~/generated-metadata/graphql';
const StyledContainer = styled.div`
height: 100%;
@ -21,17 +30,45 @@ export const CommandMenuAskAIPage = () => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const agentId = currentWorkspace?.defaultAgent?.id;
if (!agentId) {
const [currentThreadId, setCurrentThreadId] = useRecoilComponentState(
currentAIChatThreadComponentState,
agentId,
);
const { loading: threadsLoading } = useGetAgentChatThreadsQuery({
variables: { agentId: agentId! },
skip: isDefined(currentThreadId) || !isDefined(agentId),
onCompleted: (data) => {
if (data.agentChatThreads.length > 0) {
setCurrentThreadId(data.agentChatThreads[0].id);
}
},
});
const { loading, data } = useGetAgentChatMessagesQuery({
variables: { threadId: currentThreadId as string },
skip: !isDefined(currentThreadId),
});
const isLoading = loading || !currentThreadId || threadsLoading;
if (!agentId || isLoading) {
return (
<StyledContainer>
<StyledEmptyState>No AI Agent found.</StyledEmptyState>
<StyledEmptyState>
{isLoading ? t`Loading...` : t`No AI Agent found.`}
</StyledEmptyState>
</StyledContainer>
);
}
return (
<StyledContainer>
<AIChatTab agentId={agentId} />
<AIChatTab
agentId={agentId}
key={currentThreadId}
uiMessages={mapDBMessagesToUIMessages(data?.agentChatMessages || [])}
/>
</StyledContainer>
);
};

View file

@ -1,13 +0,0 @@
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends Array<infer R>
? Array<DeepPartial<R>>
: DeepPartial<T[K]>;
};
type ExcludeFunctions<T> = T extends Function ? never : T;
/**
* Wrapper type used to circumvent ESM modules circular dependency issue
* caused by reflection metadata saving the type of the property.
*/
type CircularDep<T> = T;

View file

@ -3,6 +3,7 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { type ViewFilterOperand as SharedViewFilterOperand } from 'twenty-shared/types';
import { DataSource, In, Repository, type QueryRunner } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
@ -477,7 +478,7 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat
viewName = 'All {objectLabelPlural}';
}
const coreView: Partial<ViewEntity> = {
const coreView: QueryDeepPartialEntity<ViewEntity> = {
id: workspaceView.id,
name: viewName,
objectMetadataId: workspaceView.objectMetadataId,
@ -517,7 +518,7 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat
queryRunner: QueryRunner,
): Promise<void> {
for (const field of workspaceViewFields) {
const coreViewField: Partial<ViewFieldEntity> = {
const coreViewField: QueryDeepPartialEntity<ViewFieldEntity> = {
id: field.id,
fieldMetadataId: field.fieldMetadataId,
viewId: field.viewId,
@ -549,7 +550,7 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat
continue;
}
const coreViewFilter: Partial<ViewFilterEntity> = {
const coreViewFilter: QueryDeepPartialEntity<ViewFilterEntity> = {
id: filter.id,
fieldMetadataId: filter.fieldMetadataId,
viewId: filter.viewId,
@ -586,7 +587,7 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat
const direction = sort.direction.toUpperCase() as ViewSortDirection;
const coreViewSort: Partial<ViewSortEntity> = {
const coreViewSort: QueryDeepPartialEntity<ViewSortEntity> = {
id: sort.id,
fieldMetadataId: sort.fieldMetadataId,
viewId: sort.viewId,
@ -616,7 +617,7 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat
continue;
}
const coreViewGroup: Partial<ViewGroupEntity> = {
const coreViewGroup: QueryDeepPartialEntity<ViewGroupEntity> = {
id: group.id,
fieldMetadataId: group.fieldMetadataId,
viewId: group.viewId,
@ -644,20 +645,21 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat
(a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
)) {
const coreViewFilterGroup: Partial<ViewFilterGroupEntity> = {
id: filterGroup.id,
viewId: filterGroup.viewId,
logicalOperator:
filterGroup.logicalOperator as ViewFilterGroupLogicalOperator,
parentViewFilterGroupId: filterGroup.parentViewFilterGroupId,
positionInViewFilterGroup: filterGroup.positionInViewFilterGroup,
workspaceId,
createdAt: new Date(filterGroup.createdAt),
updatedAt: new Date(filterGroup.updatedAt),
deletedAt: filterGroup.deletedAt
? new Date(filterGroup.deletedAt)
: null,
};
const coreViewFilterGroup: QueryDeepPartialEntity<ViewFilterGroupEntity> =
{
id: filterGroup.id,
viewId: filterGroup.viewId,
logicalOperator:
filterGroup.logicalOperator as ViewFilterGroupLogicalOperator,
parentViewFilterGroupId: filterGroup.parentViewFilterGroupId,
positionInViewFilterGroup: filterGroup.positionInViewFilterGroup,
workspaceId,
createdAt: new Date(filterGroup.createdAt),
updatedAt: new Date(filterGroup.updatedAt),
deletedAt: filterGroup.deletedAt
? new Date(filterGroup.deletedAt)
: null,
};
const repository = queryRunner.manager.getRepository(
ViewFilterGroupEntity,

View file

@ -0,0 +1,33 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class NameOfYourMigration1758767315179 implements MigrationInterface {
name = 'CreateAgentChatMessagePartTableAndRemoveRawContent1758767315179';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."agentChatMessagePart" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "messageId" uuid NOT NULL, "orderIndex" integer NOT NULL, "type" character varying NOT NULL, "textContent" text, "reasoningContent" text, "toolName" character varying, "toolCallId" character varying, "toolInput" jsonb, "toolOutput" jsonb, "state" character varying, "errorMessage" text, "errorDetails" jsonb, "sourceUrlSourceId" character varying, "sourceUrlUrl" character varying, "sourceUrlTitle" character varying, "sourceDocumentSourceId" character varying, "sourceDocumentMediaType" character varying, "sourceDocumentTitle" character varying, "sourceDocumentFilename" character varying, "fileMediaType" character varying, "fileFilename" character varying, "fileUrl" character varying, "providerMetadata" jsonb, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_c28499bb0699730d41e57e1fe23" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_5d4b48eeebfa7b23cd2226a874" ON "core"."agentChatMessagePart" ("messageId") `,
);
await queryRunner.query(
`ALTER TABLE "core"."agentChatMessage" DROP COLUMN "rawContent"`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentChatMessagePart" ADD CONSTRAINT "FK_5d4b48eeebfa7b23cd2226a874f" FOREIGN KEY ("messageId") REFERENCES "core"."agentChatMessage"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agentChatMessagePart" DROP CONSTRAINT "FK_5d4b48eeebfa7b23cd2226a874f"`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentChatMessage" ADD "rawContent" text`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_5d4b48eeebfa7b23cd2226a874"`,
);
await queryRunner.query(`DROP TABLE "core"."agentChatMessagePart"`);
}
}

View file

@ -8,6 +8,8 @@ import {
Resolver,
} from '@nestjs/graphql';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import {
ApiKeyException,
@ -83,7 +85,7 @@ export class ApiKeyResolver {
@AuthWorkspace() workspace: Workspace,
@Args('input') input: UpdateApiKeyDTO,
): Promise<ApiKey | null> {
const updateData: Partial<ApiKey> = {};
const updateData: QueryDeepPartialEntity<ApiKey> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.expiresAt !== undefined)

View file

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, IsNull, Repository } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/api-key-role.service';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
@ -78,7 +79,7 @@ export class ApiKeyService {
async update(
id: string,
workspaceId: string,
updateData: Partial<ApiKey>,
updateData: QueryDeepPartialEntity<ApiKey>,
): Promise<ApiKey | null> {
const apiKey = await this.findById(id, workspaceId);

View file

@ -10,6 +10,8 @@ import {
UseGuards,
} from '@nestjs/common';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
import { type ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApiKeyService } from 'src/engine/core-modules/api-key/api-key.service';
@ -65,7 +67,7 @@ export class ApiKeyController {
@Body() updateApiKeyDto: UpdateApiKeyDTO,
@AuthWorkspace() workspace: Workspace,
): Promise<ApiKey | null> {
const updateData: Partial<ApiKey> = {};
const updateData: QueryDeepPartialEntity<ApiKey> = {};
if (updateApiKeyDto.name !== undefined)
updateData.name = updateApiKeyDto.name;

View file

@ -10,6 +10,7 @@ export enum FileFolder {
ServerlessFunction = 'serverless-function',
ServerlessFunctionToDelete = 'serverless-function-to-delete',
File = 'file',
AgentChat = 'agent-chat',
}
registerEnumType(FileFolder, {
@ -42,6 +43,9 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
[FileFolder.File]: {
ignoreExpirationToken: false,
},
[FileFolder.AgentChat]: {
ignoreExpirationToken: false,
},
};
export type AllowedFolders = KebabCase<keyof typeof FileFolder>;

View file

@ -3,15 +3,16 @@ import { InjectRepository } from '@nestjs/typeorm';
import { t } from '@lingui/core/macro';
import { Repository } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { PublicDomainDTO } from 'src/engine/core-modules/public-domain/dtos/public-domain.dto';
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
import {
PublicDomainException,
PublicDomainExceptionCode,
} from 'src/engine/core-modules/public-domain/public-domain.exception';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class PublicDomainService {
@ -90,7 +91,9 @@ export class PublicDomainService {
});
try {
await this.publicDomainRepository.insert(publicDomain);
await this.publicDomainRepository.insert(
publicDomain as QueryDeepPartialEntity<PublicDomain>,
);
} catch (error) {
await this.dnsManagerService.deleteHostnameSilently(formattedDomain, {
isPublicDomain: true,

View file

@ -1,6 +1,8 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { CreateWebhookDTO } from 'src/engine/core-modules/webhook/dtos/create-webhook.dto';
import { DeleteWebhookDTO } from 'src/engine/core-modules/webhook/dtos/delete-webhook.dto';
import { GetWebhookDTO } from 'src/engine/core-modules/webhook/dtos/get-webhook.dto';
@ -56,7 +58,7 @@ export class WebhookResolver {
@Args('input') input: UpdateWebhookDTO,
): Promise<Webhook | null> {
try {
const updateData: Partial<Webhook> = {};
const updateData: QueryDeepPartialEntity<Webhook> = {};
if (input.targetUrl !== undefined) updateData.targetUrl = input.targetUrl;
if (input.operations !== undefined)

View file

@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { ArrayContains, IsNull, Repository } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { Webhook } from './webhook.entity';
import { WebhookException, WebhookExceptionCode } from './webhook.exception';
@ -93,7 +94,7 @@ export class WebhookService {
async update(
id: string,
workspaceId: string,
updateData: Partial<Webhook>,
updateData: QueryDeepPartialEntity<Webhook>,
): Promise<Webhook | null> {
const webhook = await this.findById(id, workspaceId);
@ -102,7 +103,9 @@ export class WebhookService {
}
if (isDefined(updateData.targetUrl)) {
const normalizedTargetUrl = this.normalizeTargetUrl(updateData.targetUrl);
const normalizedTargetUrl = this.normalizeTargetUrl(
updateData.targetUrl as string,
);
if (!this.validateTargetUrl(normalizedTargetUrl)) {
throw new WebhookException(

View file

@ -0,0 +1,98 @@
import { JSONValue } from 'ai';
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
} from 'typeorm';
import { AgentChatMessageEntity } from './agent-chat-message.entity';
@Entity('agentChatMessagePart')
export class AgentChatMessagePartEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
@Index()
messageId: string;
@ManyToOne(() => AgentChatMessageEntity, (message) => message.parts, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'messageId' })
message: Relation<AgentChatMessageEntity>;
@Column({ type: 'int' })
orderIndex: number;
@Column({ type: 'varchar' })
type: string;
@Column({ type: 'text', nullable: true })
textContent: string | null;
@Column({ type: 'text', nullable: true })
reasoningContent: string | null;
@Column({ type: 'varchar', nullable: true })
toolName: string | null;
@Column({ type: 'varchar', nullable: true })
toolCallId: string | null;
@Column({ type: 'jsonb', nullable: true })
toolInput: unknown | null;
@Column({ type: 'jsonb', nullable: true })
toolOutput: unknown | null;
@Column({ type: 'varchar', nullable: true })
state: string | null;
@Column({ type: 'text', nullable: true })
errorMessage: string | null;
@Column({ type: 'jsonb', nullable: true })
errorDetails: Record<string, unknown> | null;
@Column({ type: 'varchar', nullable: true })
sourceUrlSourceId: string | null;
@Column({ type: 'varchar', nullable: true })
sourceUrlUrl: string | null;
@Column({ type: 'varchar', nullable: true })
sourceUrlTitle: string | null;
@Column({ type: 'varchar', nullable: true })
sourceDocumentSourceId: string | null;
@Column({ type: 'varchar', nullable: true })
sourceDocumentMediaType: string | null;
@Column({ type: 'varchar', nullable: true })
sourceDocumentTitle: string | null;
@Column({ type: 'varchar', nullable: true })
sourceDocumentFilename: string | null;
@Column({ type: 'varchar', nullable: true })
fileMediaType: string | null;
@Column({ type: 'varchar', nullable: true })
fileFilename: string | null;
@Column({ type: 'varchar', nullable: true })
fileUrl: string | null;
@Column({ type: 'jsonb', nullable: true })
providerMetadata: Record<string, Record<string, JSONValue>> | null;
@CreateDateColumn()
createdAt: Date;
}

View file

@ -13,6 +13,8 @@ import {
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/agent/agent-chat-thread.entity';
import { AgentChatMessagePartEntity } from './agent-chat-message-part.entity';
export enum AgentChatMessageRole {
USER = 'user',
ASSISTANT = 'assistant',
@ -36,8 +38,8 @@ export class AgentChatMessageEntity {
@Column({ type: 'enum', enum: AgentChatMessageRole })
role: AgentChatMessageRole;
@Column({ type: 'text', nullable: true })
rawContent: string | null;
@OneToMany(() => AgentChatMessagePartEntity, (part) => part.message)
parts: Relation<AgentChatMessagePartEntity[]>;
@OneToMany(() => FileEntity, (file) => file.message)
files: Relation<FileEntity[]>;

View file

@ -9,14 +9,15 @@ import {
UseGuards,
} from '@nestjs/common';
import { UIDataTypes, UIMessage, UITools } from 'ai';
import { Response } from 'express';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
import { AgentChatService } from './agent-chat.service';
@ -63,45 +64,21 @@ export class AgentChatController {
@Body()
body: {
threadId: string;
userMessage: string;
fileIds?: string[];
messages: UIMessage<unknown, UIDataTypes, UITools>[];
recordIdsByObjectMetadataNameSingular?: RecordIdsByObjectMetadataNameSingularType;
},
@AuthUserWorkspaceId() userWorkspaceId: string,
@AuthWorkspace() workspace: Workspace,
@Res() res: Response,
@Res() response: Response,
) {
try {
await this.agentStreamingService.streamAgentChat({
threadId: body.threadId,
userMessage: body.userMessage,
userWorkspaceId,
workspace,
fileIds: body.fileIds || [],
recordIdsByObjectMetadataNameSingular:
body.recordIdsByObjectMetadataNameSingular || [],
res,
});
} catch (error) {
// Handle errors at controller level for streaming responses
// since the RestApiExceptionFilter interferes with our streaming error handling
const errorMessage =
error instanceof Error ? error.message : 'Unknown error occurred';
if (!res.headersSent) {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Transfer-Encoding', 'chunked');
res.setHeader('Cache-Control', 'no-cache');
}
res.write(
JSON.stringify({
type: 'error',
message: errorMessage,
}) + '\n',
);
res.end();
}
this.agentStreamingService.streamAgentChat({
threadId: body.threadId,
messages: body.messages,
userWorkspaceId,
workspace,
recordIdsByObjectMetadataNameSingular:
body.recordIdsByObjectMetadataNameSingular || [],
response,
});
}
}

View file

@ -3,16 +3,19 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import type { UIDataTypes, UIMessage, UIMessagePart, UITools } from 'ai';
import { AgentChatMessagePartEntity } from 'src/engine/metadata-modules/agent/agent-chat-message-part.entity';
import {
AgentChatMessageEntity,
type AgentChatMessageRole,
AgentChatMessageRole,
} from 'src/engine/metadata-modules/agent/agent-chat-message.entity';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/agent/agent-chat-thread.entity';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
import { mapUIMessagePartsToDBParts } from 'src/engine/metadata-modules/agent/utils/mapUIMessagePartsToDBParts';
import { AgentTitleGenerationService } from './agent-title-generation.service';
@ -23,8 +26,8 @@ export class AgentChatService {
private readonly threadRepository: Repository<AgentChatThreadEntity>,
@InjectRepository(AgentChatMessageEntity)
private readonly messageRepository: Repository<AgentChatMessageEntity>,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
@InjectRepository(AgentChatMessagePartEntity)
private readonly messagePartRepository: Repository<AgentChatMessagePartEntity>,
private readonly titleGenerationService: AgentTitleGenerationService,
) {}
@ -67,32 +70,32 @@ export class AgentChatService {
async addMessage({
threadId,
role,
rawContent,
fileIds,
uiMessage,
}: {
threadId: string;
role: AgentChatMessageRole;
rawContent: string | null;
fileIds?: string[];
uiMessage: Omit<UIMessage<unknown, UIDataTypes, UITools>, 'id'>;
uiMessageParts?: UIMessagePart<UIDataTypes, UITools>[];
}) {
const message = this.messageRepository.create({
threadId,
role,
rawContent,
role: uiMessage.role as AgentChatMessageRole,
});
const savedMessage = await this.messageRepository.save(message);
if (fileIds && fileIds.length > 0) {
for (const fileId of fileIds) {
await this.fileRepository.update(fileId, {
messageId: savedMessage.id,
});
}
if (uiMessage.parts && uiMessage.parts.length > 0) {
const dbParts = mapUIMessagePartsToDBParts(
uiMessage.parts,
savedMessage.id,
);
await this.messagePartRepository.save(dbParts);
}
this.generateTitleIfNeeded(threadId, rawContent);
this.generateTitleIfNeeded(
threadId,
uiMessage.parts.find((part) => part.type === 'text')?.text,
);
return savedMessage;
}
@ -115,13 +118,13 @@ export class AgentChatService {
return this.messageRepository.find({
where: { threadId },
order: { createdAt: 'ASC' },
relations: ['files'],
relations: ['parts', 'files'],
});
}
private async generateTitleIfNeeded(
threadId: string,
messageContent: string | null,
messageContent?: string | null,
) {
const thread = await this.threadRepository.findOne({
where: { id: threadId },

View file

@ -2,14 +2,14 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
type FilePart,
type ImagePart,
convertToModelMessages,
LanguageModelUsage,
type ModelMessage,
stepCountIs,
streamText,
ToolSet,
type UserContent,
UserModelMessage,
UIDataTypes,
UIMessage,
UITools,
} from 'ai';
import { AppPath } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
@ -20,20 +20,13 @@ import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-m
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { extractFolderPathAndFilename } from 'src/engine/core-modules/file/utils/extract-folderpath-and-filename.utils';
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import {
type AgentChatMessageEntity,
AgentChatMessageRole,
} from 'src/engine/metadata-modules/agent/agent-chat-message.entity';
import { AgentHandoffToolService } from 'src/engine/metadata-modules/agent/agent-handoff-tool.service';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-config.const';
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constants/agent-system-prompts.const';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
import { constructAssistantMessageContentFromStream } from 'src/engine/metadata-modules/agent/utils/constructAssistantMessageContentFromStream';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { AgentToolGeneratorService } from './agent-tool-generator.service';
import { AgentEntity } from './agent.entity';
@ -70,7 +63,7 @@ export class AgentExecutionService {
}: {
system: string;
agent: AgentEntity | null;
messages: ModelMessage[];
messages: UIMessage<unknown, UIDataTypes, UITools>[];
}) {
try {
if (agent) {
@ -106,8 +99,8 @@ export class AgentExecutionService {
system,
tools,
model: registeredModel.model,
messages,
maxSteps: AGENT_CONFIG.MAX_STEPS,
messages: convertToModelMessages(messages),
stopWhen: stepCountIs(AGENT_CONFIG.MAX_STEPS),
...(registeredModel.doesSupportThinking && {
providerOptions: {
anthropic: {
@ -128,39 +121,6 @@ export class AgentExecutionService {
}
}
private async buildUserMessageWithFiles(
fileIds: string[],
): Promise<(ImagePart | FilePart)[]> {
const files = await this.fileRepository.find({
where: {
id: In(fileIds),
},
});
return await Promise.all(files.map((file) => this.createFilePart(file)));
}
private async buildUserMessage(
userMessage: string,
fileIds: string[],
): Promise<UserModelMessage> {
const content: Exclude<UserContent, string> = [
{
type: 'text',
text: userMessage,
},
];
if (fileIds.length !== 0) {
content.push(...(await this.buildUserMessageWithFiles(fileIds)));
}
return {
role: AgentChatMessageRole.USER,
content,
};
}
private async getContextForSystemPrompt(
workspace: Workspace,
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType,
@ -225,119 +185,72 @@ export class AgentExecutionService {
return JSON.stringify(contextObject);
}
private async createFilePart(
file: FileEntity,
): Promise<ImagePart | FilePart> {
const { folderPath, filename } = extractFolderPathAndFilename(
file.fullPath,
);
const fileStream = await this.fileService.getFileStream(
folderPath,
filename,
file.workspaceId,
);
const fileBuffer = await streamToBuffer(fileStream);
if (file.type.startsWith('image')) {
return {
type: 'image',
image: fileBuffer,
mediaType: file.type,
};
}
return {
type: 'file',
data: fileBuffer,
mediaType: file.type,
};
}
private mapMessagesToCoreMessages(
messages: AgentChatMessageEntity[],
): ModelMessage[] {
return messages
.map(({ role, rawContent }): ModelMessage => {
if (role === AgentChatMessageRole.USER) {
return {
role: 'user',
content: rawContent ?? '',
};
}
return {
role: 'assistant',
content: constructAssistantMessageContentFromStream(rawContent ?? ''),
};
})
.filter((message) => message.content.length > 0);
}
async streamChatResponse({
workspace,
userWorkspaceId,
agentId,
userMessage,
messages,
fileIds,
recordIdsByObjectMetadataNameSingular,
}: {
workspace: Workspace;
userWorkspaceId: string;
agentId: string;
userMessage: string;
messages: AgentChatMessageEntity[];
fileIds: string[];
messages: UIMessage<unknown, UIDataTypes, UITools>[];
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType;
}) {
const agent = await this.agentRepository.findOneOrFail({
where: { id: agentId },
});
try {
const agent = await this.agentRepository.findOneOrFail({
where: { id: agentId },
});
const llmMessages: ModelMessage[] =
this.mapMessagesToCoreMessages(messages);
let contextString = '';
let contextString = '';
if (recordIdsByObjectMetadataNameSingular.length > 0) {
const contextPart = await this.getContextForSystemPrompt(
workspace,
recordIdsByObjectMetadataNameSingular,
userWorkspaceId,
);
if (recordIdsByObjectMetadataNameSingular.length > 0) {
const contextPart = await this.getContextForSystemPrompt(
workspace,
recordIdsByObjectMetadataNameSingular,
userWorkspaceId,
contextString = `\n\nCONTEXT:\n${contextPart}`;
}
const aiRequestConfig = await this.prepareAIRequestConfig({
system: `${AGENT_SYSTEM_PROMPTS.AGENT_CHAT}\n\n${agent.prompt}${contextString}`,
agent,
messages,
});
this.logger.log(
`Sending request to AI model with ${messages.length} messages`,
);
contextString = `\n\nCONTEXT:\n${contextPart}`;
const model =
await this.aiModelRegistryService.resolveModelForAgent(agent);
const stream = streamText(aiRequestConfig);
stream.usage
.then((usage) => {
this.aiBillingService.calculateAndBillUsage(
model.modelId,
usage,
workspace.id,
);
})
.catch((usageError) => {
this.logger.error('Failed to get usage information:', usageError);
});
return stream;
} catch (error) {
this.logger.error('Error in streamChatResponse:', error);
throw new AgentException(
error instanceof Error
? error.message
: 'Failed to stream chat response',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
const userMessageWithFiles = await this.buildUserMessage(
userMessage,
fileIds,
);
llmMessages.push(userMessageWithFiles);
const aiRequestConfig = await this.prepareAIRequestConfig({
system: `${AGENT_SYSTEM_PROMPTS.AGENT_CHAT}\n\n${agent.prompt}${contextString}`,
agent,
messages: llmMessages,
});
this.logger.log(
`Sending request to AI model with ${llmMessages.length} messages`,
);
const model = await this.aiModelRegistryService.resolveModelForAgent(agent);
const stream = streamText(aiRequestConfig);
stream.usage.then((usage) => {
this.aiBillingService.calculateAndBillUsage(
model.modelId,
usage,
workspace.id,
);
});
return stream;
}
}

View file

@ -1,6 +1,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
createUIMessageStream,
pipeUIMessageStreamToResponse,
UIDataTypes,
UIMessage,
UITools,
} from 'ai';
import { type Response } from 'express';
import { Repository } from 'typeorm';
@ -17,24 +24,13 @@ import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metad
export type StreamAgentChatOptions = {
threadId: string;
userMessage: string;
userWorkspaceId: string;
workspace: Workspace;
fileIds: string[];
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType;
res: Response;
response: Response;
messages: UIMessage<unknown, UIDataTypes, UITools>[];
};
const CLIENT_FORWARDED_EVENT_TYPES = [
'text-delta',
'reasoning',
'reasoning-delta',
'tool-call',
'tool-input-delta',
'tool-result',
'error',
];
@Injectable()
export class AgentStreamingService {
private readonly logger = new Logger(AgentStreamingService.name);
@ -48,15 +44,12 @@ export class AgentStreamingService {
async streamAgentChat({
threadId,
userMessage,
userWorkspaceId,
workspace,
fileIds,
messages,
recordIdsByObjectMetadataNameSingular,
res,
response,
}: StreamAgentChatOptions) {
let rawStreamString = '';
try {
const thread = await this.threadRepository.findOne({
where: {
@ -73,74 +66,56 @@ export class AgentStreamingService {
);
}
this.setupStreamingHeaders(res);
const stream = createUIMessageStream({
execute: async ({ writer }) => {
const result = await this.agentExecutionService.streamChatResponse({
workspace,
agentId: thread.agent.id,
userWorkspaceId,
messages,
recordIdsByObjectMetadataNameSingular,
});
const { fullStream } =
await this.agentExecutionService.streamChatResponse({
workspace,
agentId: thread.agent.id,
userWorkspaceId,
userMessage,
messages: thread.messages,
fileIds,
recordIdsByObjectMetadataNameSingular,
});
writer.merge(
result.toUIMessageStream({
onError: (error) => {
return error instanceof Error ? error.message : String(error);
},
onFinish: async ({ responseMessage }) => {
if (responseMessage.parts.length === 0) {
return;
}
for await (const chunk of fullStream) {
rawStreamString += JSON.stringify(chunk) + '\n';
await this.agentChatService.addMessage({
threadId,
uiMessage: {
role: AgentChatMessageRole.USER,
parts: [
{
type: 'text',
text:
messages[messages.length - 1].parts.find(
(part) => part.type === 'text',
)?.text ?? '',
},
],
},
});
await this.agentChatService.addMessage({
threadId,
uiMessage: responseMessage,
});
},
sendReasoning: true,
}),
);
},
});
this.sendStreamEvent(
res,
CLIENT_FORWARDED_EVENT_TYPES.includes(chunk.type)
? chunk
: { type: chunk.type },
);
}
pipeUIMessageStreamToResponse({ stream, response });
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error occurred';
if (error instanceof AgentException) {
this.logger.error(`Agent Exception Code: ${error.code}`);
}
if (!res.headersSent) {
this.setupStreamingHeaders(res);
}
const errorChunk = {
type: 'error',
message: errorMessage,
};
rawStreamString += JSON.stringify(errorChunk) + '\n';
this.sendStreamEvent(res, errorChunk);
this.logger.error(error.message);
response.end();
}
await this.agentChatService.addMessage({
threadId,
role: AgentChatMessageRole.USER,
rawContent: userMessage,
fileIds,
});
await this.agentChatService.addMessage({
threadId,
role: AgentChatMessageRole.ASSISTANT,
rawContent: rawStreamString.trim() || null,
});
res.end();
}
private sendStreamEvent(res: Response, event: object): void {
res.write(JSON.stringify(event) + '\n');
}
private setupStreamingHeaders(res: Response): void {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Transfer-Encoding', 'chunked');
res.setHeader('Cache-Control', 'no-cache');
}
}

View file

@ -21,6 +21,7 @@ import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/wor
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkflowToolsModule } from 'src/modules/workflow/workflow-tools/workflow-tools.module';
import { AgentChatMessagePartEntity } from './agent-chat-message-part.entity';
import { AgentChatMessageEntity } from './agent-chat-message.entity';
import { AgentChatThreadEntity } from './agent-chat-thread.entity';
import { AgentChatResolver } from './agent-chat.resolver';
@ -45,6 +46,7 @@ import { AgentService } from './agent.service';
RoleEntity,
RoleTargetsEntity,
AgentChatMessageEntity,
AgentChatMessagePartEntity,
AgentChatThreadEntity,
FileEntity,
UserWorkspace,
@ -89,6 +91,7 @@ import { AgentService } from './agent.service';
TypeOrmModule.forFeature([
AgentEntity,
AgentChatMessageEntity,
AgentChatMessagePartEntity,
AgentChatThreadEntity,
]),
AgentHandoffExecutorService,

View file

@ -76,7 +76,18 @@ export const AGENT_HANDOFF_SCHEMA = z.object({
}),
z.object({
role: z.literal('tool'),
content: z.string(),
content: z.union([
z.string(),
z.array(
z.object({
type: z.literal('tool-result'),
toolCallId: z.string(),
toolName: z.string(),
result: z.unknown(),
isError: z.boolean().optional(),
}),
),
]),
toolCallId: z.string(),
}),
]),

View file

@ -0,0 +1,84 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { JSONValue } from 'ai';
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('AgentChatMessagePart')
export class AgentChatMessagePartDTO {
@Field(() => UUIDScalarType)
id: string;
@Field(() => UUIDScalarType)
messageId: string;
@Field(() => Int)
orderIndex: number;
@Field()
type: string;
@Field({ nullable: true })
textContent?: string;
@Field({ nullable: true })
reasoningContent?: string;
@Field({ nullable: true })
toolName?: string;
@Field({ nullable: true })
toolCallId?: string;
@Field(() => GraphQLJSON, { nullable: true })
toolInput?: Record<string, unknown>;
@Field(() => GraphQLJSON, { nullable: true })
toolOutput?: Record<string, unknown>;
@Field({ nullable: true })
state?: string;
@Field({ nullable: true })
errorMessage?: string;
@Field(() => GraphQLJSON, { nullable: true })
errorDetails?: Record<string, unknown>;
@Field({ nullable: true })
sourceUrlSourceId?: string;
@Field({ nullable: true })
sourceUrlUrl?: string;
@Field({ nullable: true })
sourceUrlTitle?: string;
@Field({ nullable: true })
sourceDocumentSourceId?: string;
@Field({ nullable: true })
sourceDocumentMediaType?: string;
@Field({ nullable: true })
sourceDocumentTitle?: string;
@Field({ nullable: true })
sourceDocumentFilename?: string;
@Field({ nullable: true })
fileMediaType?: string;
@Field({ nullable: true })
fileFilename?: string;
@Field({ nullable: true })
fileUrl?: string;
@Field(() => GraphQLJSON, { nullable: true })
providerMetadata?: Record<string, Record<string, JSONValue>>;
@Field()
createdAt: Date;
}

View file

@ -3,6 +3,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { AgentChatMessagePartDTO } from './agent-chat-message-part.dto';
@ObjectType('AgentChatMessage')
export class AgentChatMessageDTO {
@Field(() => UUIDScalarType)
@ -14,8 +16,8 @@ export class AgentChatMessageDTO {
@Field()
role: 'user' | 'assistant';
@Field({ nullable: true })
rawContent: string;
@Field(() => [AgentChatMessagePartDTO])
parts: AgentChatMessagePartDTO[];
@Field(() => [FileDTO])
files: FileDTO[];

View file

@ -1,96 +0,0 @@
import { type ReasoningPart } from '@ai-sdk/provider-utils';
import { type TextPart } from 'ai';
import {
parseStreamLine,
splitStreamIntoLines,
type TextBlock,
} from 'twenty-shared/ai';
export const constructAssistantMessageContentFromStream = (
rawContent: string,
) => {
const lines = splitStreamIntoLines(rawContent);
const output: Array<TextPart | ReasoningPart> = [];
let currentTextBlock: TextBlock = null;
const flushTextBlock = () => {
if (currentTextBlock) {
if (currentTextBlock.type === 'reasoning') {
output.push({
type: 'reasoning',
text: currentTextBlock.content,
});
} else {
output.push({
type: 'text',
text: currentTextBlock.content,
});
}
currentTextBlock = null;
}
};
for (const line of lines) {
const event = parseStreamLine(line);
if (!event) {
continue;
}
switch (event.type) {
case 'reasoning-start':
flushTextBlock();
currentTextBlock = {
type: 'reasoning',
content: '',
isThinking: true,
};
break;
case 'reasoning-delta':
if (!currentTextBlock || currentTextBlock.type !== 'reasoning') {
flushTextBlock();
currentTextBlock = {
type: 'reasoning',
content: '',
isThinking: true,
};
}
currentTextBlock.content += event.text || '';
break;
case 'reasoning-end':
if (currentTextBlock?.type === 'reasoning') {
currentTextBlock.isThinking = false;
}
break;
case 'text-delta':
if (!currentTextBlock || currentTextBlock.type !== 'text') {
flushTextBlock();
currentTextBlock = { type: 'text', content: '' };
}
currentTextBlock.content += event.text || '';
break;
case 'step-finish':
if (currentTextBlock?.type === 'reasoning') {
currentTextBlock.isThinking = false;
}
flushTextBlock();
break;
case 'error':
flushTextBlock();
break;
default:
break;
}
}
flushTextBlock();
return output;
};

View file

@ -0,0 +1,81 @@
import {
type ToolUIPart,
type UIDataTypes,
type UIMessagePart,
type UITools,
} from 'ai';
import { type AgentChatMessagePartEntity } from 'src/engine/metadata-modules/agent/agent-chat-message-part.entity';
const isToolPart = (
part: UIMessagePart<UIDataTypes, UITools>,
): part is ToolUIPart => {
return part.type.includes('tool-') && 'toolCallId' in part;
};
export const mapUIMessagePartsToDBParts = (
uiMessageParts: UIMessagePart<UIDataTypes, UITools>[],
messageId: string,
): Partial<AgentChatMessagePartEntity>[] => {
return uiMessageParts.map((part, index) => {
const basePart: Partial<AgentChatMessagePartEntity> = {
messageId,
orderIndex: index,
type: part.type,
};
switch (part.type) {
case 'text':
return {
...basePart,
textContent: part.text,
};
case 'reasoning':
return {
...basePart,
reasoningContent: part.text,
};
case 'file':
return {
...basePart,
fileMediaType: part.mediaType,
fileFilename: part.filename,
fileUrl: part.url,
};
case 'source-url':
return {
...basePart,
sourceUrlSourceId: part.sourceId,
sourceUrlUrl: part.url,
sourceUrlTitle: part.title,
providerMetadata: part.providerMetadata ?? null,
};
case 'source-document':
return {
...basePart,
sourceDocumentSourceId: part.sourceId,
sourceDocumentMediaType: part.mediaType,
sourceDocumentTitle: part.title,
sourceDocumentFilename: part.filename,
providerMetadata: part.providerMetadata ?? null,
};
case 'step-start':
return basePart;
default:
{
if (isToolPart(part)) {
const { toolCallId, input, output, errorText } = part;
return {
...basePart,
toolCallId: toolCallId,
toolInput: input,
toolOutput: output,
errorMessage: errorText,
};
}
}
throw new Error(`Unsupported part type: ${part.type}`);
}
});
};

View file

@ -3,6 +3,7 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import isEmpty from 'lodash.isempty';
import { DataSource, type EntityManager, Repository } from 'typeorm';
import { type DeepPartial } from 'typeorm/common/DeepPartial';
import { v4 } from 'uuid';
import { ForeignDataWrapperServerQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/foreign-data-wrapper-server-query.factory';

View file

@ -1,3 +1,5 @@
import { type DeepPartial } from 'typeorm';
import {
type ForeignDataWrapperOptions,
type RemoteServerEntity,
@ -9,10 +11,6 @@ import {
} from 'src/engine/metadata-modules/remote-server/remote-server.exception';
import { type UserMappingOptions } from 'src/engine/metadata-modules/remote-server/types/user-mapping-options';
export type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
export const buildUpdateRemoteServerRawQuery = (
remoteServerToUpdate: DeepPartial<RemoteServerEntity<RemoteServerType>> &
Pick<RemoteServerEntity<RemoteServerType>, 'workspaceId' | 'id'>,

View file

@ -11,6 +11,7 @@ const agentTableName = 'agent';
const workspaceTableName = 'workspace';
const agentChatThreadTableName = 'agentChatThread';
const agentChatMessageTableName = 'agentChatMessage';
const agentChatMessagePartTableName = 'agentChatMessagePart';
export const AGENT_DATA_SEED_IDS = {
APPLE_DEFAULT_AGENT: '20202020-0000-4000-8000-000000000001',
@ -33,6 +34,17 @@ export const AGENT_CHAT_MESSAGE_DATA_SEED_IDS = {
YCOMBINATOR_MESSAGE_4: '20202020-0000-4000-8000-000000000034',
};
export const AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS = {
APPLE_MESSAGE_1_PART_1: '20202020-0000-4000-8000-000000000041',
APPLE_MESSAGE_2_PART_1: '20202020-0000-4000-8000-000000000042',
APPLE_MESSAGE_3_PART_1: '20202020-0000-4000-8000-000000000043',
APPLE_MESSAGE_4_PART_1: '20202020-0000-4000-8000-000000000044',
YCOMBINATOR_MESSAGE_1_PART_1: '20202020-0000-4000-8000-000000000051',
YCOMBINATOR_MESSAGE_2_PART_1: '20202020-0000-4000-8000-000000000052',
YCOMBINATOR_MESSAGE_3_PART_1: '20202020-0000-4000-8000-000000000053',
YCOMBINATOR_MESSAGE_4_PART_1: '20202020-0000-4000-8000-000000000054',
};
const seedAgentChatThreads = async (
dataSource: DataSource,
schemaName: string,
@ -88,16 +100,24 @@ const seedAgentChatMessages = async (
threadId: string,
) => {
let messageIds: string[];
let partIds: string[];
let messages: Array<{
id: string;
threadId: string;
role: AgentChatMessageRole;
content: string;
createdAt: Date;
}>;
let messageParts: Array<{
id: string;
messageId: string;
orderIndex: number;
type: string;
textContent: string;
createdAt: Date;
}>;
const now = new Date();
const baseTime = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24 hours ago
const baseTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
if (workspaceId === SEED_APPLE_WORKSPACE_ID) {
messageIds = [
@ -106,38 +126,74 @@ const seedAgentChatMessages = async (
AGENT_CHAT_MESSAGE_DATA_SEED_IDS.APPLE_MESSAGE_3,
AGENT_CHAT_MESSAGE_DATA_SEED_IDS.APPLE_MESSAGE_4,
];
partIds = [
AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS.APPLE_MESSAGE_1_PART_1,
AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS.APPLE_MESSAGE_2_PART_1,
AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS.APPLE_MESSAGE_3_PART_1,
AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS.APPLE_MESSAGE_4_PART_1,
];
messages = [
{
id: messageIds[0],
threadId,
role: AgentChatMessageRole.USER,
content:
'Hello! Can you help me understand our current product roadmap and key metrics?',
createdAt: new Date(baseTime.getTime()),
},
{
id: messageIds[1],
threadId,
role: AgentChatMessageRole.ASSISTANT,
content:
"Hello! I'd be happy to help you understand Apple's product roadmap and metrics. Based on your workspace data, I can see you have various projects and initiatives tracked. What specific aspect would you like to explore - product development timelines, user engagement metrics, or revenue targets?",
createdAt: new Date(baseTime.getTime() + 5 * 60 * 1000), // 5 minutes later
createdAt: new Date(baseTime.getTime() + 5 * 60 * 1000),
},
{
id: messageIds[2],
threadId,
role: AgentChatMessageRole.USER,
content:
"I'd like to focus on our user engagement metrics and how they're trending over the last quarter.",
createdAt: new Date(baseTime.getTime() + 10 * 60 * 1000), // 10 minutes later
createdAt: new Date(baseTime.getTime() + 10 * 60 * 1000),
},
{
id: messageIds[3],
threadId,
role: AgentChatMessageRole.ASSISTANT,
content:
createdAt: new Date(baseTime.getTime() + 15 * 60 * 1000),
},
];
messageParts = [
{
id: partIds[0],
messageId: messageIds[0],
orderIndex: 0,
type: 'text',
textContent:
'Hello! Can you help me understand our current product roadmap and key metrics?',
createdAt: new Date(baseTime.getTime()),
},
{
id: partIds[1],
messageId: messageIds[1],
orderIndex: 0,
type: 'text',
textContent:
"Hello! I'd be happy to help you understand Apple's product roadmap and metrics. Based on your workspace data, I can see you have various projects and initiatives tracked. What specific aspect would you like to explore - product development timelines, user engagement metrics, or revenue targets?",
createdAt: new Date(baseTime.getTime() + 5 * 60 * 1000),
},
{
id: partIds[2],
messageId: messageIds[2],
orderIndex: 0,
type: 'text',
textContent:
"I'd like to focus on our user engagement metrics and how they're trending over the last quarter.",
createdAt: new Date(baseTime.getTime() + 10 * 60 * 1000),
},
{
id: partIds[3],
messageId: messageIds[3],
orderIndex: 0,
type: 'text',
textContent:
'Great! Looking at your user engagement data, I can see several key trends from the last quarter. Your active user base has grown by 15%, with particularly strong engagement in the mobile app. Daily active users are averaging 2.3 million, and session duration has increased by 8%. Would you like me to dive deeper into any specific engagement metrics or create a detailed report?',
createdAt: new Date(baseTime.getTime() + 15 * 60 * 1000), // 15 minutes later
createdAt: new Date(baseTime.getTime() + 15 * 60 * 1000),
},
];
} else if (workspaceId === SEED_YCOMBINATOR_WORKSPACE_ID) {
@ -147,38 +203,74 @@ const seedAgentChatMessages = async (
AGENT_CHAT_MESSAGE_DATA_SEED_IDS.YCOMBINATOR_MESSAGE_3,
AGENT_CHAT_MESSAGE_DATA_SEED_IDS.YCOMBINATOR_MESSAGE_4,
];
partIds = [
AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS.YCOMBINATOR_MESSAGE_1_PART_1,
AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS.YCOMBINATOR_MESSAGE_2_PART_1,
AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS.YCOMBINATOR_MESSAGE_3_PART_1,
AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS.YCOMBINATOR_MESSAGE_4_PART_1,
];
messages = [
{
id: messageIds[0],
threadId,
role: AgentChatMessageRole.USER,
content:
'What are the current startup trends and which companies in our portfolio are performing best?',
createdAt: new Date(baseTime.getTime()),
},
{
id: messageIds[1],
threadId,
role: AgentChatMessageRole.ASSISTANT,
content:
'Hello! I can help you analyze startup trends and portfolio performance. From your YCombinator workspace data, I can see strong performance in AI/ML startups, particularly in the B2B SaaS space. Several companies are showing 40%+ month-over-month growth. Would you like me to provide specific company performance metrics or focus on broader industry trends?',
createdAt: new Date(baseTime.getTime() + 3 * 60 * 1000), // 3 minutes later
createdAt: new Date(baseTime.getTime() + 3 * 60 * 1000),
},
{
id: messageIds[2],
threadId,
role: AgentChatMessageRole.USER,
content:
'Please focus on our top 5 performing companies and their key metrics.',
createdAt: new Date(baseTime.getTime() + 8 * 60 * 1000), // 8 minutes later
createdAt: new Date(baseTime.getTime() + 8 * 60 * 1000),
},
{
id: messageIds[3],
threadId,
role: AgentChatMessageRole.ASSISTANT,
content:
createdAt: new Date(baseTime.getTime() + 12 * 60 * 1000),
},
];
messageParts = [
{
id: partIds[0],
messageId: messageIds[0],
orderIndex: 0,
type: 'text',
textContent:
'What are the current startup trends and which companies in our portfolio are performing best?',
createdAt: new Date(baseTime.getTime()),
},
{
id: partIds[1],
messageId: messageIds[1],
orderIndex: 0,
type: 'text',
textContent:
'Hello! I can help you analyze startup trends and portfolio performance. From your YCombinator workspace data, I can see strong performance in AI/ML startups, particularly in the B2B SaaS space. Several companies are showing 40%+ month-over-month growth. Would you like me to provide specific company performance metrics or focus on broader industry trends?',
createdAt: new Date(baseTime.getTime() + 3 * 60 * 1000),
},
{
id: partIds[2],
messageId: messageIds[2],
orderIndex: 0,
type: 'text',
textContent:
'Please focus on our top 5 performing companies and their key metrics.',
createdAt: new Date(baseTime.getTime() + 8 * 60 * 1000),
},
{
id: partIds[3],
messageId: messageIds[3],
orderIndex: 0,
type: 'text',
textContent:
'Here are your top 5 performing portfolio companies: 1) TechFlow AI - 45% MoM growth, $2M ARR, 2) DataSync Pro - 38% MoM growth, $1.5M ARR, 3) CloudOps Solutions - 35% MoM growth, $3.2M ARR, 4) SecureNet - 32% MoM growth, $1.8M ARR, 5) HealthTech Plus - 28% MoM growth, $2.5M ARR. All are showing strong customer retention (>95%) and expanding market share. Would you like detailed breakdowns for any specific company?',
createdAt: new Date(baseTime.getTime() + 12 * 60 * 1000), // 12 minutes later
createdAt: new Date(baseTime.getTime() + 12 * 60 * 1000),
},
];
} else {
@ -194,12 +286,26 @@ const seedAgentChatMessages = async (
'id',
'threadId',
'role',
'content',
'createdAt',
])
.orIgnore()
.values(messages)
.execute();
await dataSource
.createQueryBuilder()
.insert()
.into(`${schemaName}.${agentChatMessagePartTableName}`, [
'id',
'messageId',
'orderIndex',
'type',
'textContent',
'createdAt',
])
.orIgnore()
.values(messageParts)
.execute();
};
export const seedAgents = async (

View file

@ -1,4 +1,7 @@
import { type FieldMetadataType } from 'twenty-shared/types';
import {
type ExcludeFunctions,
type FieldMetadataType,
} from 'twenty-shared/types';
import { type WorkspaceDynamicRelationMetadataArgsFactory } from 'src/engine/twenty-orm/interfaces/workspace-dynamic-relation-metadata-args.interface';

View file

@ -1,4 +1,5 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { type DeepPartial } from 'typeorm/common/DeepPartial';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';

View file

@ -6,14 +6,3 @@
* |_| \_/\_/ \___|_| |_|\__|\__, |
* |___/
*/
export type { ErrorEvent } from './types/ErrorEvent';
export type { ReasoningDeltaEvent } from './types/ReasoningDeltaEvent';
export type { StreamEvent } from './types/StreamEvent';
export type { TextBlock } from './types/TextBlock';
export type { TextDeltaEvent } from './types/TextDeltaEvent';
export type { ToolCallEvent } from './types/ToolCallEvent';
export type { ToolEvent } from './types/ToolEvent';
export type { ToolResultEvent } from './types/ToolResultEvent';
export { parseStreamLine } from './utils/parseStreamLine';
export { splitStreamIntoLines } from './utils/splitStreamIntoLines';

View file

@ -1,5 +0,0 @@
export type ErrorEvent = {
type: 'error';
message: string;
error?: unknown;
};

View file

@ -1,4 +0,0 @@
export type ReasoningDeltaEvent = {
type: 'reasoning-delta';
text: string;
};

View file

@ -1,21 +0,0 @@
import type { ErrorEvent } from './ErrorEvent';
import type { ReasoningDeltaEvent } from './ReasoningDeltaEvent';
import type { TextDeltaEvent } from './TextDeltaEvent';
import type { ToolCallEvent } from './ToolCallEvent';
import type { ToolResultEvent } from './ToolResultEvent';
export type StreamEvent =
| ToolCallEvent
| ToolResultEvent
| {
type: 'reasoning-start';
}
| ReasoningDeltaEvent
| {
type: 'reasoning-end';
}
| TextDeltaEvent
| {
type: 'step-finish';
}
| ErrorEvent;

View file

@ -1,4 +0,0 @@
export type TextBlock =
| { type: 'reasoning'; content: string; isThinking: boolean }
| { type: 'text'; content: string }
| null;

View file

@ -1,4 +0,0 @@
export type TextDeltaEvent = {
type: 'text-delta';
text: string;
};

View file

@ -1,9 +0,0 @@
export type ToolCallEvent = {
type: 'tool-call';
toolCallId: string;
toolName: string;
input: {
loadingMessage: string;
input: unknown;
};
};

View file

@ -1,4 +0,0 @@
import type { ToolCallEvent } from './ToolCallEvent';
import type { ToolResultEvent } from './ToolResultEvent';
export type ToolEvent = ToolCallEvent | ToolResultEvent;

View file

@ -1,11 +0,0 @@
export type ToolResultEvent = {
type: 'tool-result';
toolCallId: string;
toolName: string;
output: {
success: boolean;
result?: unknown;
error?: string;
message: string;
};
};

View file

@ -1,9 +0,0 @@
import type { StreamEvent } from '../types/StreamEvent';
export const parseStreamLine = (line: string): StreamEvent | null => {
try {
return JSON.parse(line) as StreamEvent;
} catch {
return null;
}
};

View file

@ -1,3 +0,0 @@
export const splitStreamIntoLines = (streamText: string): string[] => {
return streamText.trim().split('\n');
};

View file

@ -0,0 +1 @@
export type ExcludeFunctions<T> = T extends Function ? never : T;

View file

@ -14,6 +14,7 @@ export { AppPath } from './AppPath';
export type { ConfigVariableValue } from './ConfigVariableValue';
export { ConnectedAccountProvider } from './ConnectedAccountProvider';
export type { EnumFieldMetadataType } from './EnumFieldMetadataType';
export type { ExcludeFunctions } from './ExcludeFunctions';
export { FieldMetadataType } from './FieldMetadataType';
export type { FromTo } from './FromToType';
export type { IsExactly } from './IsExactly';

View file

@ -8,7 +8,7 @@
*/
export { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from './constants/CaptureAllVariableTagInnerRegex';
export { CONTENT_TYPE_VALUES_HTTP_REQUEST } from './constants/contentTypeValuesHttpRequest';
export { CONTENT_TYPE_VALUES_HTTP_REQUEST } from './constants/ContentTypeValuesHttpRequest';
export { TRIGGER_STEP_ID } from './constants/TriggerStepId';
export { workflowAiAgentActionSchema } from './schemas/ai-agent-action-schema';
export { workflowAiAgentActionSettingsSchema } from './schemas/ai-agent-action-settings-schema';

View file

@ -48,6 +48,30 @@ __metadata:
languageName: node
linkType: hard
"@ai-sdk/gateway@npm:1.0.26":
version: 1.0.26
resolution: "@ai-sdk/gateway@npm:1.0.26"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/bb61342d1827838139b81c8c2cb21e455f422b438cd0791d3d3cf30f58726c853644d9e5b85bc9faa7104bce4816b878c09ea0af65c69e13871ff08ae40a56ec
languageName: node
linkType: hard
"@ai-sdk/gateway@npm:1.0.28":
version: 1.0.28
resolution: "@ai-sdk/gateway@npm:1.0.28"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/3c5d0c1e0a957353fc848ae498e136302bddce4b99ef66b68f29566fdc31e0f7f98bb182cfd546998a3b08651e97a28c9ec1cfc4da31fffca35618ae2c13b323
languageName: node
linkType: hard
"@ai-sdk/openai-compatible@npm:1.0.18":
version: 1.0.18
resolution: "@ai-sdk/openai-compatible@npm:1.0.18"
@ -94,6 +118,24 @@ __metadata:
languageName: node
linkType: hard
"@ai-sdk/react@npm:^2.0.51":
version: 2.0.51
resolution: "@ai-sdk/react@npm:2.0.51"
dependencies:
"@ai-sdk/provider-utils": "npm:3.0.9"
ai: "npm:5.0.51"
swr: "npm:^2.2.5"
throttleit: "npm:2.1.0"
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
zod: ^3.25.76 || ^4
peerDependenciesMeta:
zod:
optional: true
checksum: 10c0/2ae48e67ecd9efcbd725f4d84850601b58ab93f1fda67ca282cccf7e734b465d3aad702a047cedf88ae0a697f99a5e593f2a6efac372123ae832ef8bcc1980a1
languageName: node
linkType: hard
"@ai-sdk/xai@npm:^2.0.19":
version: 2.0.19
resolution: "@ai-sdk/xai@npm:2.0.19"
@ -23889,6 +23931,20 @@ __metadata:
languageName: node
linkType: hard
"ai@npm:5.0.51":
version: 5.0.51
resolution: "ai@npm:5.0.51"
dependencies:
"@ai-sdk/gateway": "npm:1.0.28"
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
"@opentelemetry/api": "npm:1.9.0"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/47bf8b5195d5175810e7311e670f40da233d3fba8117949c51dcb8b5214a926bccb98d1ed0e0da98d572f9818349026427adcc4259ecbfcd4d4d1d19992d1cb5
languageName: node
linkType: hard
"ai@npm:^5.0.44":
version: 5.0.44
resolution: "ai@npm:5.0.44"
@ -23903,6 +23959,20 @@ __metadata:
languageName: node
linkType: hard
"ai@npm:^5.0.49":
version: 5.0.49
resolution: "ai@npm:5.0.49"
dependencies:
"@ai-sdk/gateway": "npm:1.0.26"
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
"@opentelemetry/api": "npm:1.9.0"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/781393542d263f3f9ecd26798253746c4d46743440fb8ebe129239134fbf5e789d599762b8b8a0a05c25194b5be686b912a947db8c013c2840f0d1e3a27888b8
languageName: node
linkType: hard
"ajv-draft-04@npm:^1.0.0":
version: 1.0.0
resolution: "ajv-draft-04@npm:1.0.0"
@ -51291,6 +51361,18 @@ __metadata:
languageName: node
linkType: hard
"swr@npm:^2.2.5":
version: 2.3.6
resolution: "swr@npm:2.3.6"
dependencies:
dequal: "npm:^2.0.3"
use-sync-external-store: "npm:^1.4.0"
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/9534f350982e36a3ae0a13da8c0f7da7011fc979e77f306e60c4e5db0f9b84f17172c44f973441ba56bb684b69b0d9838ab40011a6b6b3e32d0cd7f3d5405f99
languageName: node
linkType: hard
"symbol-observable@npm:4.0.0, symbol-observable@npm:^4.0.0":
version: 4.0.0
resolution: "symbol-observable@npm:4.0.0"
@ -51541,6 +51623,13 @@ __metadata:
languageName: node
linkType: hard
"throttleit@npm:2.1.0":
version: 2.1.0
resolution: "throttleit@npm:2.1.0"
checksum: 10c0/1696ae849522cea6ba4f4f3beac1f6655d335e51b42d99215e196a718adced0069e48deaaf77f7e89f526ab31de5b5c91016027da182438e6f9280be2f3d5265
languageName: node
linkType: hard
"through2@npm:4.0.2, through2@npm:^4.0.2":
version: 4.0.2
resolution: "through2@npm:4.0.2"
@ -52400,6 +52489,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "twenty-front@workspace:packages/twenty-front"
dependencies:
"@ai-sdk/react": "npm:^2.0.51"
"@apollo/client": "npm:^3.7.17"
"@blocknote/mantine": "npm:^0.31.1"
"@blocknote/react": "npm:^0.31.1"
@ -52448,6 +52538,7 @@ __metadata:
"@typescript-eslint/parser": "npm:^8.39.0"
"@typescript-eslint/utils": "npm:^8.39.0"
"@xyflow/react": "npm:^12.4.2"
ai: "npm:^5.0.49"
apollo-link-rest: "npm:^0.9.0"
apollo-upload-client: "npm:^17.0.0"
buffer: "npm:^6.0.3"