Feat - Agent chat tab (#13061)

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Marie <51697796+ijreilly@users.noreply.github.com>
Co-authored-by: Antoine Moreaux <moreaux.antoine@gmail.com>
Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
This commit is contained in:
Abdul Rahman
2025-07-08 02:17:41 +05:30
committed by GitHub
parent 29f7b74756
commit 51d02c13bf
40 changed files with 2777 additions and 127 deletions

View File

@ -63,6 +63,7 @@
"add": "^2.0.6",
"addressparser": "^1.0.1",
"afterframe": "^1.0.2",
"apollo-link-rest": "^0.9.0",
"apollo-server-express": "^3.12.0",
"apollo-upload-client": "^17.0.0",
"archiver": "^7.0.1",

View File

@ -9,6 +9,7 @@ import {
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
import { RestLink } from 'apollo-link-rest';
import { createUploadLink } from 'apollo-upload-client';
import { renewToken } from '@/auth/services/AuthService';
@ -21,6 +22,7 @@ import { i18n } from '@lingui/core';
import { GraphQLFormattedError } from 'graphql';
import isEmpty from 'lodash.isempty';
import { getGenericOperationName, isDefined } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { cookieStorage } from '~/utils/cookie-storage';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { ApolloManager } from '../types/apolloManager.interface';
@ -67,6 +69,10 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
uri,
});
const restLink = new RestLink({
uri: `${REACT_APP_SERVER_BASE_URL}/rest`,
});
const authLink = setContext(async (_, { headers }) => {
const tokenPair = getTokenPair();
@ -222,8 +228,9 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
...(extraLinks || []),
isDebugMode ? logger : null,
retryLink,
restLink,
httpLink,
].filter(isDefined),
].filter(isDefined) as ApolloLink[],
);
};

View File

@ -5,6 +5,7 @@ import {
OperationVariables,
WatchQueryFetchPolicy,
} from '@apollo/client';
import { Unmasked } from '@apollo/client/masking';
import { isNonEmptyArray } from '@apollo/client/utilities';
import { isNonEmptyString } from '@sniptt/guards';
import { useMemo } from 'react';
@ -54,7 +55,7 @@ type UseFindManyRecordsStateParams<
updateQuery?: (
previousQueryResult: TData,
options: {
fetchMoreResult: TFetchData;
fetchMoreResult: Unmasked<TFetchData>;
variables: TFetchVars;
},
) => TData;

View File

@ -5,6 +5,7 @@ import {
OperationVariables,
WatchQueryFetchPolicy,
} from '@apollo/client';
import { Unmasked } from '@apollo/client/masking';
import { isNonEmptyArray } from '@apollo/client/utilities';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil';
@ -52,7 +53,7 @@ type UseFindManyRecordsStateParams<
updateQuery?: (
previousQueryResult: TData,
options: {
fetchMoreResult: TFetchData;
fetchMoreResult: Unmasked<TFetchData>;
variables: TFetchVars;
},
) => TData;

View File

@ -0,0 +1,32 @@
import { gql } from '@apollo/client';
export const GET_AGENT_CHAT_THREADS = gql`
query GetAgentChatThreads($agentId: String!) {
threads(agentId: $agentId)
@rest(
type: "AgentChatThread"
path: "/agent-chat/threads/{args.agentId}"
) {
id
agentId
createdAt
updatedAt
}
}
`;
export const GET_AGENT_CHAT_MESSAGES = gql`
query GetAgentChatMessages($threadId: String!) {
messages(threadId: $threadId)
@rest(
type: "AgentChatMessage"
path: "/agent-chat/threads/{args.threadId}/messages"
) {
id
threadId
role
content
createdAt
}
}
`;

View File

@ -0,0 +1,105 @@
import { getTokenPair } from '@/apollo/utils/getTokenPair';
import { renewToken } from '@/auth/services/AuthService';
import { AppPath } from '@/types/AppPath';
import { isDefined } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { cookieStorage } from '~/utils/cookie-storage';
const handleTokenRenewal = async () => {
const tokenPair = getTokenPair();
if (!isDefined(tokenPair?.refreshToken?.token)) {
throw new Error('No refresh token available');
}
const newTokens = await renewToken(
`${REACT_APP_SERVER_BASE_URL}/graphql`,
tokenPair,
);
if (!isDefined(newTokens)) {
throw new Error('Token renewal failed');
}
cookieStorage.setItem('tokenPair', JSON.stringify(newTokens));
return newTokens;
};
const createStreamRequest = (
threadId: string,
userMessage: string,
accessToken: string,
) => ({
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
threadId,
userMessage,
}),
});
const handleStreamResponse = async (
response: Response,
onChunk: (chunk: string) => void,
): Promise<string> => {
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let accumulated = '';
if (isDefined(reader)) {
let done = false;
while (!done) {
const { value, done: isDone } = await reader.read();
done = isDone;
if (done) break;
const chunk = decoder.decode(value, { stream: true });
accumulated += chunk;
onChunk(accumulated);
}
}
return accumulated;
};
export const streamChatResponse = async (
threadId: string,
userMessage: string,
onChunk: (chunk: string) => void,
) => {
const tokenPair = getTokenPair();
if (!isDefined(tokenPair?.accessToken?.token)) {
throw new Error('No access token available');
}
const accessToken = tokenPair.accessToken.token;
const response = await fetch(
`${REACT_APP_SERVER_BASE_URL}/rest/agent-chat/stream`,
createStreamRequest(threadId, userMessage, accessToken),
);
if (response.ok) {
return handleStreamResponse(response, onChunk);
}
if (response.status === 401) {
try {
const newTokens = await handleTokenRenewal();
const retryResponse = await fetch(
`${REACT_APP_SERVER_BASE_URL}/rest/agent-chat/stream`,
createStreamRequest(threadId, userMessage, newTokens.accessToken.token),
);
if (retryResponse.ok) {
return handleStreamResponse(retryResponse, onChunk);
}
} catch (renewalError) {
window.location.href = AppPath.SignInUp;
}
throw new Error('Authentication failed');
}
};

View File

@ -0,0 +1,47 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
const StyledSkeletonContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(6)};
padding: ${({ theme }) => theme.spacing(3)};
`;
const StyledMessageBubble = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(3)};
`;
const StyledMessageSkeleton = styled.div`
width: 100%;
`;
const NUMBER_OF_SKELETONS = 6;
export const AIChatSkeletonLoader = () => {
const theme = useTheme();
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<StyledSkeletonContainer>
{Array.from({ length: NUMBER_OF_SKELETONS }).map((_, index) => (
<StyledMessageBubble key={index}>
<Skeleton width={24} height={24} borderRadius={4} />
<StyledMessageSkeleton>
<Skeleton height={20} borderRadius={8} />
</StyledMessageSkeleton>
</StyledMessageBubble>
))}
</StyledSkeletonContainer>
</SkeletonTheme>
);
};

View File

@ -0,0 +1,254 @@
import { TextArea } from '@/ui/input/components/TextArea';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import React from 'react';
import { Avatar, IconDotsVertical, IconSparkles } from 'twenty-ui/display';
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { t } from '@lingui/core/macro';
import { Button } from 'twenty-ui/input';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { useAgentChat } from '../hooks/useAgentChat';
import { AIChatSkeletonLoader } from './AIChatSkeletonLoader';
const StyledContainer = styled.div`
background: ${({ theme }) => theme.background.primary};
height: calc(100% - 154px);
`;
const StyledEmptyState = styled.div`
display: flex;
flex-direction: column;
flex: 1;
gap: ${({ theme }) => theme.spacing(2)};
align-items: center;
justify-content: center;
height: 100%;
`;
const StyledSparkleIcon = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.blue};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
height: 40px;
justify-content: center;
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 40px;
`;
const StyledTitle = styled.div`
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: 600;
`;
const StyledDescription = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
text-align: center;
max-width: 85%;
font-size: ${({ theme }) => theme.font.size.md};
`;
const StyledInputArea = styled.div`
align-items: flex-end;
bottom: 0;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
position: absolute;
width: calc(100% - 24px);
padding: ${({ theme }) => theme.spacing(3)};
background: ${({ theme }) => theme.background.primary};
`;
const StyledScrollWrapper = styled(ScrollWrapper)`
display: flex;
flex: 1;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(5)};
overflow-y: auto;
padding: ${({ theme }) => theme.spacing(3)};
width: calc(100% - 24px);
`;
const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
display: flex;
flex-direction: column;
align-items: flex-start;
position: relative;
width: 100%;
&:hover .message-footer {
opacity: 1;
pointer-events: auto;
}
`;
const StyledMessageRow = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(3)};
width: 100%;
`;
const StyledMessageText = styled.div<{ isUser?: boolean }>`
background: ${({ theme, isUser }) =>
isUser ? theme.background.secondary : theme.background.transparent};
border-radius: ${({ theme }) => theme.border.radius.md};
padding: ${({ theme, isUser }) => (isUser ? theme.spacing(1, 2) : 0)};
border: ${({ isUser, theme }) =>
!isUser ? 'none' : `1px solid ${theme.border.color.light}`};
color: ${({ theme, isUser }) =>
isUser ? theme.font.color.light : theme.font.color.primary};
font-weight: ${({ isUser }) => (isUser ? 500 : 400)};
width: fit-content;
white-space: pre-line;
`;
const StyledMessageFooter = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
justify-content: space-between;
margin-top: ${({ theme }) => theme.spacing(1)};
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-in-out;
width: 100%;
`;
const StyledAvatarContainer = styled.div<{ isUser?: boolean }>`
align-items: center;
background: ${({ theme, isUser }) =>
isUser
? theme.background.transparent.light
: theme.background.transparent.blue};
display: flex;
justify-content: center;
height: 24px;
min-width: 24px;
border-radius: ${({ theme }) => theme.border.radius.sm};
padding: 1px;
`;
const StyledDotsIconContainer = styled.div`
align-items: center;
border: ${({ theme }) => `1px solid ${theme.border.color.light}`};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
justify-content: center;
padding-inline: ${({ theme }) => theme.spacing(1)};
`;
const StyledDotsIcon = styled(IconDotsVertical)`
color: ${({ theme }) => theme.font.color.light};
transform: rotate(90deg);
`;
const StyledMessageContainer = styled.div`
width: 100%;
`;
type AIChatTabProps = {
agentId: string;
};
export const AIChatTab: React.FC<AIChatTabProps> = ({ agentId }) => {
const theme = useTheme();
const {
messages,
isLoading,
handleSendMessage,
input,
handleInputChange,
agentStreamingMessage,
} = useAgentChat(agentId);
return (
<StyledContainer>
{messages.length !== 0 && (
<StyledScrollWrapper componentInstanceId={agentId}>
{messages.map((msg) => (
<StyledMessageBubble
key={msg.id}
isUser={msg.role === AgentChatMessageRole.USER}
>
<StyledMessageRow>
{msg.role === AgentChatMessageRole.ASSISTANT && (
<StyledAvatarContainer>
<Avatar
size="sm"
placeholder="AI"
Icon={IconSparkles}
iconColor={theme.color.blue}
/>
</StyledAvatarContainer>
)}
{msg.role === AgentChatMessageRole.USER && (
<StyledAvatarContainer isUser>
<Avatar size="sm" placeholder="U" type="rounded" />
</StyledAvatarContainer>
)}
<StyledMessageContainer>
<StyledMessageText
isUser={msg.role === AgentChatMessageRole.USER}
>
{msg.role === AgentChatMessageRole.ASSISTANT && !msg.content
? agentStreamingMessage || (
<StyledDotsIconContainer>
<StyledDotsIcon size={theme.icon.size.xl} />
</StyledDotsIconContainer>
)
: msg.content}
</StyledMessageText>
{msg.content && (
<StyledMessageFooter className="message-footer">
<span>
{beautifyPastDateRelativeToNow(msg.createdAt)}
</span>
<LightCopyIconButton copyText={msg.content} />
</StyledMessageFooter>
)}
</StyledMessageContainer>
</StyledMessageRow>
</StyledMessageBubble>
))}
</StyledScrollWrapper>
)}
{messages.length === 0 && !isLoading && (
<StyledEmptyState>
<StyledSparkleIcon>
<IconSparkles size={theme.icon.size.lg} color={theme.color.blue} />
</StyledSparkleIcon>
<StyledTitle>{t`Chat`}</StyledTitle>
<StyledDescription>
{t`Start a conversation with your AI agent to get workflow insights, task assistance, and process guidance`}
</StyledDescription>
</StyledEmptyState>
)}
{isLoading && messages.length === 0 && <AIChatSkeletonLoader />}
<StyledInputArea>
<TextArea
textAreaId={`${agentId}-chat-input`}
placeholder={t`Enter a question...`}
value={input}
onChange={handleInputChange}
/>
<Button
variant="primary"
accent="blue"
size="small"
hotkeys={input && !isLoading ? ['⏎'] : undefined}
disabled={!input || isLoading}
title={t`Send`}
onClick={handleSendMessage}
/>
</StyledInputArea>
</StyledContainer>
);
};

View File

@ -6,12 +6,18 @@ import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/
import { WorkflowAiAgentAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { AIChatTab } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatTab';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { BaseOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconSettings, IconTool, useIcons } from 'twenty-ui/display';
import {
IconMessage,
IconSettings,
IconTool,
useIcons,
} from 'twenty-ui/display';
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';
import {
WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID,
@ -89,6 +95,7 @@ export const WorkflowEditActionAiAgent = ({
Icon: IconSettings,
},
{ id: WorkflowAiAgentTabId.TOOLS, title: t`Tools`, Icon: IconTool },
{ id: WorkflowAiAgentTabId.CHAT, title: t`Chat`, Icon: IconMessage },
];
return loading ? (
@ -100,70 +107,76 @@ export const WorkflowEditActionAiAgent = ({
behaveAsLinks={false}
componentInstanceId={WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID}
/>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate?.({ ...action, name: newName });
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={actionOptions.readonly}
/>
<WorkflowStepBody>
{activeTabId === WorkflowAiAgentTabId.SETTINGS && (
<>
<div>
<Select
dropdownId="select-model"
label={t`AI Model`}
options={modelOptions}
value={formValues.modelId}
onChange={(value) => handleFieldChange('modelId', value)}
disabled={actionOptions.readonly || noModelsAvailable}
emptyOption={{
label: t`No AI models available`,
value: '',
}}
/>
{noModelsAvailable && (
<StyledErrorMessage>
{t`You haven't configured any model provider. Please set up an API Key in your instance's admin panel or as an environment variable.`}
</StyledErrorMessage>
)}
</div>
<FormTextFieldInput
key={`prompt-${formValues.modelId ? action.id : 'empty'}`}
label={t`Instructions for AI`}
placeholder={t`Describe what you want the AI to do...`}
readonly={actionOptions.readonly}
defaultValue={formValues.prompt}
onChange={(value) => handleFieldChange('prompt', value)}
VariablePicker={WorkflowVariablePicker}
multiline
/>
<WorkflowOutputSchemaBuilder
fields={outputFields}
onChange={handleOutputSchemaChange}
readonly={actionOptions.readonly}
/>
</>
)}
{activeTabId === WorkflowAiAgentTabId.TOOLS && (
<Select
dropdownId="select-agent-role"
label={t`Assign Role`}
options={[{ label: t`No role`, value: '' }, ...rolesOptions]}
value={selectedRole || ''}
onChange={handleRoleChange}
{activeTabId === WorkflowAiAgentTabId.CHAT ? (
<AIChatTab agentId={agentId} />
) : (
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate?.({ ...action, name: newName });
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={actionOptions.readonly}
/>
)}
</WorkflowStepBody>
<WorkflowStepBody>
{activeTabId === WorkflowAiAgentTabId.SETTINGS && (
<>
<div>
<Select
dropdownId="select-model"
label={t`AI Model`}
options={modelOptions}
value={formValues.modelId}
onChange={(value) => handleFieldChange('modelId', value)}
disabled={actionOptions.readonly || noModelsAvailable}
emptyOption={{
label: t`No AI models available`,
value: '',
}}
/>
{noModelsAvailable && (
<StyledErrorMessage>
{t`You haven't configured any model provider. Please set up an API Key in your instance's admin panel or as an environment variable.`}
</StyledErrorMessage>
)}
</div>
<FormTextFieldInput
key={`prompt-${formValues.modelId ? action.id : 'empty'}`}
label={t`Instructions for AI`}
placeholder={t`Describe what you want the AI to do...`}
readonly={actionOptions.readonly}
defaultValue={formValues.prompt}
onChange={(value) => handleFieldChange('prompt', value)}
VariablePicker={WorkflowVariablePicker}
multiline
/>
<WorkflowOutputSchemaBuilder
fields={outputFields}
onChange={handleOutputSchemaChange}
readonly={actionOptions.readonly}
/>
</>
)}
{activeTabId === WorkflowAiAgentTabId.TOOLS && (
<Select
dropdownId="select-agent-role"
label={t`Assign Role`}
options={[{ label: t`No role`, value: '' }, ...rolesOptions]}
value={selectedRole || ''}
onChange={handleRoleChange}
disabled={actionOptions.readonly}
/>
)}
</WorkflowStepBody>
</>
)}
</>
);
};

View File

@ -0,0 +1,4 @@
export enum AgentChatMessageRole {
USER = 'user',
ASSISTANT = 'assistant',
}

View File

@ -1,6 +1,7 @@
export enum WorkflowAiAgentTabId {
SETTINGS = 'settings',
TOOLS = 'tools',
CHAT = 'chat',
}
export const WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID =

View File

@ -0,0 +1,140 @@
import { InputHotkeyScope } from '@/ui/input/types/InputHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useCallback, useState } from 'react';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperElement';
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { v4 } from 'uuid';
import { streamChatResponse } from '../api/streamChatResponse';
import { agentChatInputState } from '../states/agentChatInputState';
import { agentChatMessagesComponentState } from '../states/agentChatMessagesComponentState';
import { agentStreamingMessageState } from '../states/agentStreamingMessageState';
import { AgentChatMessage, useAgentChatMessages } from './useAgentChatMessages';
import { useAgentChatThreads } from './useAgentChatThreads';
interface OptimisticMessage extends AgentChatMessage {
isPending: boolean;
}
export const useAgentChat = (agentId: string) => {
const [agentChatMessages, setAgentChatMessages] = useRecoilComponentStateV2(
agentChatMessagesComponentState,
agentId,
);
const [agentChatInput, setAgentChatInput] =
useRecoilState(agentChatInputState);
const [agentStreamingMessage, setAgentStreamingMessage] = useRecoilState(
agentStreamingMessageState,
);
const [isStreaming, setIsStreaming] = useState(false);
const { scrollWrapperHTMLElement } = useScrollWrapperElement(agentId);
const scrollToBottom = useCallback(() => {
scrollWrapperHTMLElement?.scroll({
top: scrollWrapperHTMLElement.scrollHeight,
behavior: 'smooth',
});
}, [scrollWrapperHTMLElement]);
const { data: { threads = [] } = {}, loading: threadsLoading } =
useAgentChatThreads(agentId);
const currentThreadId = threads[0]?.id;
const { loading: messagesLoading, refetch: refetchMessages } =
useAgentChatMessages(currentThreadId);
const isLoading = messagesLoading || threadsLoading || isStreaming;
const createOptimisticMessages = (content: string): AgentChatMessage[] => {
const optimisticUserMessage: OptimisticMessage = {
id: v4(),
threadId: currentThreadId,
role: AgentChatMessageRole.USER,
content,
createdAt: new Date().toISOString(),
isPending: true,
};
const optimisticAiMessage: OptimisticMessage = {
id: v4(),
threadId: currentThreadId,
role: AgentChatMessageRole.ASSISTANT,
content: '',
createdAt: new Date().toISOString(),
isPending: true,
};
return [optimisticUserMessage, optimisticAiMessage];
};
const streamAgentResponse = async (content: string) => {
if (!currentThreadId) {
return '';
}
setIsStreaming(true);
await streamChatResponse(currentThreadId, content, (chunk) => {
setAgentStreamingMessage(chunk);
scrollToBottom();
});
setIsStreaming(false);
};
const sendChatMessage = async (content: string) => {
const optimisticMessages = createOptimisticMessages(content);
setAgentChatMessages((prevMessages) => [
...prevMessages,
...optimisticMessages,
]);
setTimeout(scrollToBottom, 100);
await streamAgentResponse(content);
const { data } = await refetchMessages();
setAgentChatMessages(data?.messages);
setAgentStreamingMessage('');
scrollToBottom();
};
const handleSendMessage = async () => {
if (!agentChatInput.trim() || isLoading) {
return;
}
const content = agentChatInput.trim();
setAgentChatInput('');
await sendChatMessage(content);
};
useScopedHotkeys(
[Key.Enter],
(event) => {
if (!event.ctrlKey && !event.metaKey) {
event.preventDefault();
handleSendMessage();
}
},
InputHotkeyScope.TextInput,
[agentChatInput, isLoading],
);
return {
handleInputChange: (value: string) => setAgentChatInput(value),
messages: agentChatMessages,
input: agentChatInput,
handleSendMessage,
isLoading,
agentStreamingMessage,
};
};

View File

@ -0,0 +1,19 @@
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { useQuery } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import { GET_AGENT_CHAT_MESSAGES } from '../api/agent-chat-apollo.api';
export type AgentChatMessage = {
id: string;
threadId: string;
role: AgentChatMessageRole;
content: string;
createdAt: string;
};
export const useAgentChatMessages = (threadId: string) => {
return useQuery<{ messages: AgentChatMessage[] }>(GET_AGENT_CHAT_MESSAGES, {
variables: { threadId },
skip: !isDefined(threadId),
});
};

View File

@ -0,0 +1,17 @@
import { useQuery } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import { GET_AGENT_CHAT_THREADS } from '../api/agent-chat-apollo.api';
export interface AgentChatThread {
id: string;
agentId: string;
createdAt: string;
updatedAt: string;
}
export const useAgentChatThreads = (agentId: string) => {
return useQuery<{ threads: AgentChatThread[] }>(GET_AGENT_CHAT_THREADS, {
variables: { agentId },
skip: !isDefined(agentId),
});
};

View File

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

View File

@ -0,0 +1,14 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { AgentChatMessage } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentChatMessages';
export const AgentChatMessagesComponentInstanceContext =
createComponentInstanceContext();
export const agentChatMessagesComponentState = createComponentStateV2<
AgentChatMessage[]
>({
key: 'agentChatMessagesComponentState',
defaultValue: [],
componentInstanceContext: AgentChatMessagesComponentInstanceContext,
});

View File

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

View File

@ -0,0 +1,238 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { getFieldIcon } from '../getFieldIcon';
describe('getFieldIcon', () => {
const UNSUPPORTED_FIELD_TYPES = [
'string',
'number',
'boolean',
'object',
'array',
'unknown',
FieldMetadataType.DATE_TIME,
FieldMetadataType.EMAILS,
FieldMetadataType.PHONES,
FieldMetadataType.LINKS,
FieldMetadataType.CURRENCY,
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
FieldMetadataType.RELATION,
FieldMetadataType.UUID,
FieldMetadataType.RAW_JSON,
FieldMetadataType.FULL_NAME,
FieldMetadataType.ADDRESS,
FieldMetadataType.ARRAY,
] as const;
type UnsupportedFieldType = (typeof UNSUPPORTED_FIELD_TYPES)[number];
describe('FieldMetadataType field types', () => {
it('should return IconAbc for TEXT field type', () => {
const result = getFieldIcon(FieldMetadataType.TEXT);
expect(result).toBe('IconAbc');
});
it('should return IconText for NUMBER field type', () => {
const result = getFieldIcon(FieldMetadataType.NUMBER);
expect(result).toBe('IconText');
});
it('should return IconCheckbox for BOOLEAN field type', () => {
const result = getFieldIcon(FieldMetadataType.BOOLEAN);
expect(result).toBe('IconCheckbox');
});
it('should return IconCalendarEvent for DATE field type', () => {
const result = getFieldIcon(FieldMetadataType.DATE);
expect(result).toBe('IconCalendarEvent');
});
});
describe('basic InputSchemaPropertyType field types', () => {
it('should return IconQuestionMark for string type', () => {
const result = getFieldIcon('string');
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for number type', () => {
const result = getFieldIcon('number');
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for boolean type', () => {
const result = getFieldIcon('boolean');
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for object type', () => {
const result = getFieldIcon('object');
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for array type', () => {
const result = getFieldIcon('array');
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for unknown type', () => {
const result = getFieldIcon('unknown');
expect(result).toBe('IconQuestionMark');
});
});
describe('other FieldMetadataType values', () => {
it('should return IconQuestionMark for DATE_TIME field type', () => {
const result = getFieldIcon(FieldMetadataType.DATE_TIME);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for EMAILS field type', () => {
const result = getFieldIcon(FieldMetadataType.EMAILS);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for PHONES field type', () => {
const result = getFieldIcon(FieldMetadataType.PHONES);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for LINKS field type', () => {
const result = getFieldIcon(FieldMetadataType.LINKS);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for CURRENCY field type', () => {
const result = getFieldIcon(FieldMetadataType.CURRENCY);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for SELECT field type', () => {
const result = getFieldIcon(FieldMetadataType.SELECT);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for MULTI_SELECT field type', () => {
const result = getFieldIcon(FieldMetadataType.MULTI_SELECT);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for RELATION field type', () => {
const result = getFieldIcon(FieldMetadataType.RELATION);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for UUID field type', () => {
const result = getFieldIcon(FieldMetadataType.UUID);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for RAW_JSON field type', () => {
const result = getFieldIcon(FieldMetadataType.RAW_JSON);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for FULL_NAME field type', () => {
const result = getFieldIcon(FieldMetadataType.FULL_NAME);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for ADDRESS field type', () => {
const result = getFieldIcon(FieldMetadataType.ADDRESS);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for ARRAY field type', () => {
const result = getFieldIcon(FieldMetadataType.ARRAY);
expect(result).toBe('IconQuestionMark');
});
});
describe('edge cases', () => {
it('should return IconQuestionMark for undefined field type', () => {
const result = getFieldIcon(undefined);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for null field type', () => {
const result = getFieldIcon(null as any);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for empty string field type', () => {
const result = getFieldIcon('' as any);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for invalid field type', () => {
const result = getFieldIcon('INVALID_TYPE' as any);
expect(result).toBe('IconQuestionMark');
});
});
describe('icon mapping consistency', () => {
it('should return consistent icons for the same field type', () => {
const fieldType = FieldMetadataType.TEXT;
const result1 = getFieldIcon(fieldType);
const result2 = getFieldIcon(fieldType);
expect(result1).toBe(result2);
expect(result1).toBe('IconAbc');
});
it('should have unique icons for different supported field types', () => {
const textIcon = getFieldIcon(FieldMetadataType.TEXT);
const numberIcon = getFieldIcon(FieldMetadataType.NUMBER);
const booleanIcon = getFieldIcon(FieldMetadataType.BOOLEAN);
const dateIcon = getFieldIcon(FieldMetadataType.DATE);
const icons = [textIcon, numberIcon, booleanIcon, dateIcon];
const uniqueIcons = new Set(icons);
expect(uniqueIcons.size).toBe(4);
expect(icons).toEqual([
'IconAbc',
'IconText',
'IconCheckbox',
'IconCalendarEvent',
]);
});
it('should return IconQuestionMark for all unsupported field types', () => {
const unsupportedTypes = UNSUPPORTED_FIELD_TYPES;
unsupportedTypes.forEach((fieldType) => {
const result = getFieldIcon(fieldType as UnsupportedFieldType);
expect(result).toBe('IconQuestionMark');
});
});
});
describe('function behavior', () => {
it('should be a pure function with no side effects', () => {
const fieldType = FieldMetadataType.TEXT;
const result1 = getFieldIcon(fieldType);
const result2 = getFieldIcon(fieldType);
const result3 = getFieldIcon(fieldType);
expect(result1).toBe(result2);
expect(result2).toBe(result3);
expect(result1).toBe('IconAbc');
});
it('should handle all possible InputSchemaPropertyType values', () => {
const allPossibleTypes = [
...UNSUPPORTED_FIELD_TYPES,
FieldMetadataType.TEXT,
FieldMetadataType.NUMBER,
FieldMetadataType.BOOLEAN,
FieldMetadataType.DATE,
] as const;
allPossibleTypes.forEach((fieldType) => {
const result = getFieldIcon(fieldType as UnsupportedFieldType);
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});
});
});
});

View File

@ -0,0 +1,217 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { WorkflowFormFieldType } from '../../types/WorkflowFormFieldType';
import { getDefaultFormFieldSettings } from '../getDefaultFormFieldSettings';
jest.mock('uuid', () => ({
v4: jest.fn(() => 'test-uuid-123'),
}));
describe('getDefaultFormFieldSettings', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('FieldMetadataType.TEXT', () => {
it('should return correct default settings for TEXT field type', () => {
const result = getDefaultFormFieldSettings(FieldMetadataType.TEXT);
expect(result).toEqual({
id: 'test-uuid-123',
name: 'text',
label: 'Text',
placeholder: 'Enter your text',
});
});
});
describe('FieldMetadataType.NUMBER', () => {
it('should return correct default settings for NUMBER field type', () => {
const result = getDefaultFormFieldSettings(FieldMetadataType.NUMBER);
expect(result).toEqual({
id: 'test-uuid-123',
name: 'number',
label: 'Number',
placeholder: '1000',
});
});
});
describe('FieldMetadataType.DATE', () => {
it('should return correct default settings for DATE field type', () => {
const result = getDefaultFormFieldSettings(FieldMetadataType.DATE);
expect(result).toEqual({
id: 'test-uuid-123',
name: 'date',
label: 'Date',
placeholder: 'mm/dd/yyyy',
});
});
});
describe('RECORD type', () => {
it('should return correct default settings for RECORD field type', () => {
const result = getDefaultFormFieldSettings('RECORD');
expect(result).toEqual({
id: 'test-uuid-123',
name: 'record',
label: 'Record',
placeholder: 'Select a Company',
settings: {
objectName: 'company',
},
});
});
});
describe('UUID generation', () => {
it('should generate unique UUID for each call', () => {
getDefaultFormFieldSettings(FieldMetadataType.TEXT);
getDefaultFormFieldSettings(FieldMetadataType.NUMBER);
expect(v4).toHaveBeenCalledTimes(2);
});
});
describe('return value structure', () => {
it('should return object with required properties for TEXT type', () => {
const result = getDefaultFormFieldSettings(FieldMetadataType.TEXT);
expect(result).toHaveProperty('id');
expect(result).toHaveProperty('name');
expect(result).toHaveProperty('label');
expect(result).toHaveProperty('placeholder');
expect(result).not.toHaveProperty('settings');
});
it('should return object with required properties for NUMBER type', () => {
const result = getDefaultFormFieldSettings(FieldMetadataType.NUMBER);
expect(result).toHaveProperty('id');
expect(result).toHaveProperty('name');
expect(result).toHaveProperty('label');
expect(result).toHaveProperty('placeholder');
expect(result).not.toHaveProperty('settings');
});
it('should return object with required properties for DATE type', () => {
const result = getDefaultFormFieldSettings(FieldMetadataType.DATE);
expect(result).toHaveProperty('id');
expect(result).toHaveProperty('name');
expect(result).toHaveProperty('label');
expect(result).toHaveProperty('placeholder');
expect(result).not.toHaveProperty('settings');
});
it('should return object with required properties for RECORD type', () => {
const result = getDefaultFormFieldSettings('RECORD');
expect(result).toHaveProperty('id');
expect(result).toHaveProperty('name');
expect(result).toHaveProperty('label');
expect(result).toHaveProperty('placeholder');
expect(result).toHaveProperty('settings');
expect(result.settings).toHaveProperty('objectName');
});
});
describe('field type specific values', () => {
it('should have correct name values for each field type', () => {
const textResult = getDefaultFormFieldSettings(FieldMetadataType.TEXT);
const numberResult = getDefaultFormFieldSettings(
FieldMetadataType.NUMBER,
);
const dateResult = getDefaultFormFieldSettings(FieldMetadataType.DATE);
const recordResult = getDefaultFormFieldSettings('RECORD');
expect(textResult.name).toBe('text');
expect(numberResult.name).toBe('number');
expect(dateResult.name).toBe('date');
expect(recordResult.name).toBe('record');
});
it('should have correct label values for each field type', () => {
const textResult = getDefaultFormFieldSettings(FieldMetadataType.TEXT);
const numberResult = getDefaultFormFieldSettings(
FieldMetadataType.NUMBER,
);
const dateResult = getDefaultFormFieldSettings(FieldMetadataType.DATE);
const recordResult = getDefaultFormFieldSettings('RECORD');
expect(textResult.label).toBe('Text');
expect(numberResult.label).toBe('Number');
expect(dateResult.label).toBe('Date');
expect(recordResult.label).toBe('Record');
});
it('should have correct placeholder values for each field type', () => {
const textResult = getDefaultFormFieldSettings(FieldMetadataType.TEXT);
const numberResult = getDefaultFormFieldSettings(
FieldMetadataType.NUMBER,
);
const dateResult = getDefaultFormFieldSettings(FieldMetadataType.DATE);
const recordResult = getDefaultFormFieldSettings('RECORD');
expect(textResult.placeholder).toBe('Enter your text');
expect(numberResult.placeholder).toBe('1000');
expect(dateResult.placeholder).toBe('mm/dd/yyyy');
expect(recordResult.placeholder).toBe('Select a Company');
});
});
describe('RECORD type specific settings', () => {
it('should have correct settings object for RECORD type', () => {
const result = getDefaultFormFieldSettings('RECORD');
expect(result.settings).toEqual({
objectName: 'company',
});
});
it('should have objectName set to company for RECORD type', () => {
const result = getDefaultFormFieldSettings('RECORD');
expect(result.settings?.objectName).toBe('company');
});
});
describe('consistency', () => {
it('should return consistent results for the same field type', () => {
const result1 = getDefaultFormFieldSettings(FieldMetadataType.TEXT);
const result2 = getDefaultFormFieldSettings(FieldMetadataType.TEXT);
expect(result1).toEqual(result2);
});
it('should return different results for different field types', () => {
const textResult = getDefaultFormFieldSettings(FieldMetadataType.TEXT);
const numberResult = getDefaultFormFieldSettings(
FieldMetadataType.NUMBER,
);
expect(textResult).not.toEqual(numberResult);
expect(textResult.name).not.toBe(numberResult.name);
expect(textResult.label).not.toBe(numberResult.label);
expect(textResult.placeholder).not.toBe(numberResult.placeholder);
});
});
describe('type safety', () => {
it('should accept all valid WorkflowFormFieldType values', () => {
const validTypes: WorkflowFormFieldType[] = [
FieldMetadataType.TEXT,
FieldMetadataType.NUMBER,
FieldMetadataType.DATE,
'RECORD',
];
validTypes.forEach((type) => {
expect(() => getDefaultFormFieldSettings(type)).not.toThrow();
});
});
});
});

View File

@ -0,0 +1,111 @@
import { renderHook } from '@testing-library/react';
import { useFilteredOtherActions } from '../useFilteredOtherActions';
jest.mock('@/workspace/hooks/useIsFeatureEnabled', () => ({
useIsFeatureEnabled: jest.fn(),
}));
jest.mock('../../constants/OtherActions', () => ({
OTHER_ACTIONS: [
{ type: 'CODE', icon: 'IconCode', label: 'Code' },
{ type: 'HTTP_REQUEST', icon: 'IconHttp', label: 'HTTP Request' },
{ type: 'SEND_EMAIL', icon: 'IconMail', label: 'Send Email' },
{ type: 'AI_AGENT', icon: 'IconBrain', label: 'AI Agent' },
{ type: 'FORM', icon: 'IconForm', label: 'Form' },
],
}));
describe('useFilteredOtherActions', () => {
const mockUseIsFeatureEnabled = jest.mocked(
jest.requireMock('@/workspace/hooks/useIsFeatureEnabled')
.useIsFeatureEnabled,
);
beforeEach(() => {
jest.clearAllMocks();
});
it('should return all actions when AI is enabled', () => {
mockUseIsFeatureEnabled.mockReturnValue(true);
const { result } = renderHook(() => useFilteredOtherActions());
expect(result.current).toHaveLength(5);
expect(result.current).toEqual([
{ type: 'CODE', icon: 'IconCode', label: 'Code' },
{ type: 'HTTP_REQUEST', icon: 'IconHttp', label: 'HTTP Request' },
{ type: 'SEND_EMAIL', icon: 'IconMail', label: 'Send Email' },
{ type: 'AI_AGENT', icon: 'IconBrain', label: 'AI Agent' },
{ type: 'FORM', icon: 'IconForm', label: 'Form' },
]);
});
it('should filter out AI_AGENT when AI is disabled', () => {
mockUseIsFeatureEnabled.mockReturnValue(false);
const { result } = renderHook(() => useFilteredOtherActions());
expect(result.current).toHaveLength(4);
expect(result.current).toEqual([
{ type: 'CODE', icon: 'IconCode', label: 'Code' },
{ type: 'HTTP_REQUEST', icon: 'IconHttp', label: 'HTTP Request' },
{ type: 'SEND_EMAIL', icon: 'IconMail', label: 'Send Email' },
{ type: 'FORM', icon: 'IconForm', label: 'Form' },
]);
expect(
result.current.find((action) => action.type === 'AI_AGENT'),
).toBeUndefined();
});
it('should call useIsFeatureEnabled with correct feature flag', () => {
mockUseIsFeatureEnabled.mockReturnValue(true);
renderHook(() => useFilteredOtherActions());
expect(mockUseIsFeatureEnabled).toHaveBeenCalledWith('IS_AI_ENABLED');
expect(mockUseIsFeatureEnabled).toHaveBeenCalledTimes(1);
});
it('should handle feature flag hook returning undefined', () => {
mockUseIsFeatureEnabled.mockReturnValue(undefined);
const { result } = renderHook(() => useFilteredOtherActions());
expect(result.current).toHaveLength(4);
expect(
result.current.find((action) => action.type === 'AI_AGENT'),
).toBeUndefined();
});
it('should handle feature flag hook returning null', () => {
mockUseIsFeatureEnabled.mockReturnValue(null);
const { result } = renderHook(() => useFilteredOtherActions());
expect(result.current).toHaveLength(4);
expect(
result.current.find((action) => action.type === 'AI_AGENT'),
).toBeUndefined();
});
it('should handle feature flag hook returning false string', () => {
mockUseIsFeatureEnabled.mockReturnValue('false');
const { result } = renderHook(() => useFilteredOtherActions());
expect(result.current).toHaveLength(5);
expect(
result.current.find((action) => action.type === 'AI_AGENT'),
).toBeDefined();
});
it('should handle feature flag hook throwing error', () => {
mockUseIsFeatureEnabled.mockImplementation(() => {
throw new Error('Feature flag error');
});
expect(() => {
renderHook(() => useFilteredOtherActions());
}).toThrow('Feature flag error');
});
});

View File

@ -0,0 +1,57 @@
import { parseHttpJsonBodyWithoutVariablesOrThrow } from '../parseHttpJsonBodyWithoutVariablesOrThrow';
describe('parseHttpJsonBodyWithoutVariablesOrThrow', () => {
it('should parse valid JSON without variables', () => {
const jsonString = '{"name": "John", "age": 30, "active": true}';
const result = parseHttpJsonBodyWithoutVariablesOrThrow(jsonString);
expect(result).toEqual({
name: 'John',
age: 30,
active: true,
});
});
it('should handle nested objects with variables', () => {
const jsonString =
'{"user": {"name": "{{user.name}}", "email": "{{user.email}}"}}';
const result = parseHttpJsonBodyWithoutVariablesOrThrow(jsonString);
expect(result).toEqual({
user: {
name: 'null',
email: 'null',
},
});
});
it('should handle arrays with variables', () => {
const jsonString = '{"items": ["{{item1}}", "{{item2}}", "static"]}';
const result = parseHttpJsonBodyWithoutVariablesOrThrow(jsonString);
expect(result).toEqual({
items: ['null', 'null', 'static'],
});
});
it('should throw error for invalid JSON', () => {
const invalidJson = '{"name": "John", "age": 30,}';
expect(() => {
parseHttpJsonBodyWithoutVariablesOrThrow(invalidJson);
}).toThrow();
});
it('should handle empty object', () => {
const jsonString = '{}';
const result = parseHttpJsonBodyWithoutVariablesOrThrow(jsonString);
expect(result).toEqual({});
});
it('should handle empty string', () => {
expect(() => {
parseHttpJsonBodyWithoutVariablesOrThrow('');
}).toThrow();
});
});

View File

@ -0,0 +1,111 @@
import { HttpRequestBody } from '../../constants/HttpRequest';
import { shouldDisplayRawJsonByDefault } from '../shouldDisplayRawJsonByDefault';
describe('shouldDisplayRawJsonByDefault', () => {
describe('when defaultValue is undefined', () => {
it('should return false', () => {
expect(shouldDisplayRawJsonByDefault(undefined)).toBe(false);
});
});
describe('when defaultValue is a string', () => {
describe('when string contains valid JSON with only string values', () => {
it('should return false for simple object', () => {
const defaultValue = '{"key1": "value1", "key2": "value2"}';
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(false);
});
it('should return true for nested object with only string values', () => {
const defaultValue = '{"key1": "value1", "nested": {"key2": "value2"}}';
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
it('should return false for array with only string values', () => {
const defaultValue = '["value1", "value2", "value3"]';
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(false);
});
it('should return true for empty object', () => {
const defaultValue = '{}';
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
it('should return true for empty array', () => {
const defaultValue = '[]';
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
});
describe('when string is not valid JSON', () => {
it('should return true for invalid JSON string', () => {
const defaultValue = 'invalid json string';
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
it('should return true for malformed JSON', () => {
const defaultValue = '{"key1": "value1", "key2":}';
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
it('should return true for plain text', () => {
const defaultValue = 'This is just plain text';
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
});
});
describe('when defaultValue is an HttpRequestBody object', () => {
describe('when object has non-string values', () => {
it('should return true for object with number values', () => {
const defaultValue: HttpRequestBody = { key1: 'value1', key2: 123 };
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
it('should return true for object with boolean values', () => {
const defaultValue: HttpRequestBody = { key1: 'value1', key2: true };
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
it('should return true for object with null values', () => {
const defaultValue: HttpRequestBody = { key1: 'value1', key2: null };
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
it('should return true for object with array values', () => {
const defaultValue: HttpRequestBody = {
key1: 'value1',
key2: [1, 'two', true, null],
};
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
it('should return true for object with mixed types', () => {
const defaultValue: HttpRequestBody = {
key1: 'value1',
key2: 123,
key3: true,
key4: null,
key5: ['array', 'values'],
};
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
});
});
describe('edge cases', () => {
it('should handle complex nested structures', () => {
const defaultValue = JSON.stringify({
level1: {
level2: {
stringValue: 'test',
numberValue: 42,
booleanValue: true,
nullValue: null,
arrayValue: [1, 'two', false],
},
},
});
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
});
});

View File

@ -0,0 +1,316 @@
import { WorkflowActionType } from '@/workflow/types/Workflow';
import { Theme } from '@emotion/react';
import { COLOR, GRAY_SCALE } from 'twenty-ui/theme';
import { getActionIconColorOrThrow } from '../getActionIconColorOrThrow';
const mockTheme: Theme = {
color: {
orange: COLOR.orange,
blue: COLOR.blue,
pink: COLOR.pink,
},
font: {
color: {
tertiary: GRAY_SCALE.gray40,
},
},
} as Theme;
describe('getActionIconColorOrThrow', () => {
describe('action types that return orange color', () => {
it('should return orange color for CODE action type', () => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'CODE',
});
expect(result).toBe(mockTheme.color.orange);
});
it('should return orange color for HTTP_REQUEST action type', () => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'HTTP_REQUEST',
});
expect(result).toBe(mockTheme.color.orange);
});
});
describe('action types that return tertiary font color', () => {
const recordActionTypes: WorkflowActionType[] = [
'CREATE_RECORD',
'UPDATE_RECORD',
'DELETE_RECORD',
'FIND_RECORDS',
'FORM',
];
recordActionTypes.forEach((actionType) => {
it(`should return tertiary font color for ${actionType} action type`, () => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
expect(result).toBe(mockTheme.font.color.tertiary);
});
});
});
describe('action types that return blue color', () => {
it('should return blue color for SEND_EMAIL action type', () => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'SEND_EMAIL',
});
expect(result).toBe(mockTheme.color.blue);
});
});
describe('action types that return pink color', () => {
it('should return pink color for AI_AGENT action type', () => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'AI_AGENT',
});
expect(result).toBe(mockTheme.color.pink);
});
});
describe('FILTER action type', () => {
it('should throw an error for FILTER action type', () => {
expect(() => {
getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'FILTER',
});
}).toThrow("The Filter action isn't meant to be displayed as a node.");
});
});
describe('theme object handling', () => {
it('should use the provided theme colors correctly', () => {
const customTheme: Theme = {
color: {
orange: COLOR.red,
blue: COLOR.purple,
pink: COLOR.turquoise,
},
font: {
color: {
tertiary: GRAY_SCALE.gray50,
},
},
} as Theme;
expect(
getActionIconColorOrThrow({
theme: customTheme,
actionType: 'CODE',
}),
).toBe(COLOR.red);
expect(
getActionIconColorOrThrow({
theme: customTheme,
actionType: 'SEND_EMAIL',
}),
).toBe(COLOR.purple);
expect(
getActionIconColorOrThrow({
theme: customTheme,
actionType: 'AI_AGENT',
}),
).toBe(COLOR.turquoise);
expect(
getActionIconColorOrThrow({
theme: customTheme,
actionType: 'CREATE_RECORD',
}),
).toBe(GRAY_SCALE.gray50);
});
});
describe('type safety and exhaustive checking', () => {
it('should handle all valid action types without throwing unreachable errors', () => {
const validActionTypes: WorkflowActionType[] = [
'CODE',
'HTTP_REQUEST',
'CREATE_RECORD',
'UPDATE_RECORD',
'DELETE_RECORD',
'FIND_RECORDS',
'FORM',
'SEND_EMAIL',
'AI_AGENT',
];
validActionTypes.forEach((actionType) => {
expect(() => {
getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
}).not.toThrow();
});
});
it('should return consistent color values for the same action type', () => {
const actionType: WorkflowActionType = 'CODE';
const result1 = getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
const result2 = getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
expect(result1).toBe(result2);
expect(result1).toBe(mockTheme.color.orange);
});
});
describe('color grouping logic', () => {
it('should group CODE and HTTP_REQUEST actions with orange color', () => {
const orangeActions: WorkflowActionType[] = ['CODE', 'HTTP_REQUEST'];
orangeActions.forEach((actionType) => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
expect(result).toBe(mockTheme.color.orange);
});
});
it('should group record-related actions with tertiary font color', () => {
const recordActions: WorkflowActionType[] = [
'CREATE_RECORD',
'UPDATE_RECORD',
'DELETE_RECORD',
'FIND_RECORDS',
'FORM',
];
recordActions.forEach((actionType) => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
expect(result).toBe(mockTheme.font.color.tertiary);
});
});
it('should have unique colors for different action categories', () => {
const orangeResult = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'CODE',
});
const tertiaryResult = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'CREATE_RECORD',
});
const blueResult = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'SEND_EMAIL',
});
const pinkResult = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'AI_AGENT',
});
const colors = [orangeResult, tertiaryResult, blueResult, pinkResult];
const uniqueColors = new Set(colors);
expect(uniqueColors.size).toBe(4);
});
});
describe('error handling edge cases', () => {
it('should handle theme object with missing properties gracefully', () => {
const incompleteTheme: Theme = {
color: {
orange: COLOR.orange,
},
font: {
color: {
tertiary: GRAY_SCALE.gray40,
},
},
} as Theme;
expect(
getActionIconColorOrThrow({
theme: incompleteTheme,
actionType: 'CODE',
}),
).toBe(COLOR.orange);
expect(
getActionIconColorOrThrow({
theme: incompleteTheme,
actionType: 'CREATE_RECORD',
}),
).toBe(GRAY_SCALE.gray40);
});
it('should return undefined when blue color is missing for SEND_EMAIL action', () => {
const themeWithoutBlue: Theme = {
color: {
orange: COLOR.orange,
pink: COLOR.pink,
},
font: {
color: {
tertiary: GRAY_SCALE.gray40,
},
},
} as Theme;
const result = getActionIconColorOrThrow({
theme: themeWithoutBlue,
actionType: 'SEND_EMAIL',
});
expect(result).toBeUndefined();
});
it('should return undefined when pink color is missing for AI_AGENT action', () => {
const themeWithoutPink: Theme = {
color: {
orange: COLOR.orange,
blue: COLOR.blue,
},
font: {
color: {
tertiary: GRAY_SCALE.gray40,
},
},
} as Theme;
const result = getActionIconColorOrThrow({
theme: themeWithoutPink,
actionType: 'AI_AGENT',
});
expect(result).toBeUndefined();
});
it('should handle null or undefined theme gracefully', () => {
expect(() => {
getActionIconColorOrThrow({
theme: null as unknown as Theme,
actionType: 'CODE',
});
}).toThrow();
});
});
});

View File

@ -0,0 +1,183 @@
import { WorkflowTrigger } from '@/workflow/types/Workflow';
import { DatabaseTriggerDefaultLabel } from '@/workflow/workflow-trigger/constants/DatabaseTriggerDefaultLabel';
import { getTriggerDefaultLabel } from '../getTriggerLabel';
describe('getTriggerDefaultLabel', () => {
describe('DATABASE_EVENT triggers', () => {
it('returns "Record is Created" for created event', () => {
const trigger: WorkflowTrigger = {
type: 'DATABASE_EVENT',
name: 'Company Created',
settings: {
eventName: 'company.created',
outputSchema: {},
},
};
const result = getTriggerDefaultLabel(trigger);
expect(result).toBe(DatabaseTriggerDefaultLabel.RECORD_IS_CREATED);
});
it('returns "Record is Updated" for updated event', () => {
const trigger: WorkflowTrigger = {
type: 'DATABASE_EVENT',
name: 'Company Updated',
settings: {
eventName: 'company.updated',
outputSchema: {},
},
};
const result = getTriggerDefaultLabel(trigger);
expect(result).toBe(DatabaseTriggerDefaultLabel.RECORD_IS_UPDATED);
});
it('returns "Record is Deleted" for deleted event', () => {
const trigger: WorkflowTrigger = {
type: 'DATABASE_EVENT',
name: 'Company Deleted',
settings: {
eventName: 'company.deleted',
outputSchema: {},
},
};
const result = getTriggerDefaultLabel(trigger);
expect(result).toBe(DatabaseTriggerDefaultLabel.RECORD_IS_DELETED);
});
it('works with different object types', () => {
const trigger: WorkflowTrigger = {
type: 'DATABASE_EVENT',
name: 'Person Created',
settings: {
eventName: 'person.created',
outputSchema: {},
},
};
const result = getTriggerDefaultLabel(trigger);
expect(result).toBe(DatabaseTriggerDefaultLabel.RECORD_IS_CREATED);
});
it('throws error for unknown database event', () => {
const trigger: WorkflowTrigger = {
type: 'DATABASE_EVENT',
name: 'Unknown Event',
settings: {
eventName: 'company.unknown',
outputSchema: {},
},
};
expect(() => getTriggerDefaultLabel(trigger)).toThrow(
'Unknown trigger event',
);
});
});
describe('MANUAL triggers', () => {
it('returns "Launch manually" for manual trigger', () => {
const trigger: WorkflowTrigger = {
type: 'MANUAL',
name: 'Manual Trigger',
settings: {
objectType: 'company',
outputSchema: {},
icon: 'IconHandMove',
},
};
const result = getTriggerDefaultLabel(trigger);
expect(result).toBe('Launch manually');
});
});
describe('CRON triggers', () => {
it('returns "On a Schedule" for cron trigger', () => {
const trigger: WorkflowTrigger = {
type: 'CRON',
name: 'Scheduled Trigger',
settings: {
type: 'DAYS',
schedule: {
day: 1,
hour: 9,
minute: 0,
},
outputSchema: {},
},
};
const result = getTriggerDefaultLabel(trigger);
expect(result).toBe('On a Schedule');
});
});
describe('WEBHOOK triggers', () => {
it('returns "Webhook" for webhook trigger with GET method', () => {
const trigger: WorkflowTrigger = {
type: 'WEBHOOK',
name: 'Webhook Trigger',
settings: {
httpMethod: 'GET',
authentication: null,
outputSchema: {},
},
};
const result = getTriggerDefaultLabel(trigger);
expect(result).toBe('Webhook');
});
it('returns "Webhook" for webhook trigger with POST method', () => {
const trigger: WorkflowTrigger = {
type: 'WEBHOOK',
name: 'Webhook Trigger',
settings: {
httpMethod: 'POST',
expectedBody: {
message: 'Workflow was started',
},
authentication: null,
outputSchema: {
message: {
icon: 'IconVariable',
isLeaf: true,
label: 'message',
type: 'string',
value: 'Workflow was started',
},
},
},
};
const result = getTriggerDefaultLabel(trigger);
expect(result).toBe('Webhook');
});
});
describe('error cases', () => {
it('throws error for unknown trigger type', () => {
const trigger = {
type: 'UNKNOWN_TYPE',
name: 'Unknown Trigger',
settings: {
outputSchema: {},
},
} as unknown as WorkflowTrigger;
expect(() => getTriggerDefaultLabel(trigger)).toThrow(
'Unknown trigger type',
);
});
});
});

View File

@ -0,0 +1,61 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAgentChatMessageAndThreadTable1751467467020
implements MigrationInterface
{
name = 'AddAgentChatMessageAndThreadTable1751467467020';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "core"."agentChatMessage_role_enum" AS ENUM('user', 'assistant')`,
);
await queryRunner.query(
`CREATE TABLE "core"."agentChatMessage" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "threadId" uuid NOT NULL, "role" "core"."agentChatMessage_role_enum" NOT NULL, "content" text NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_f54a95b34e98d94251bce37a180" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_cd5b23d4e471b630137b3017ba" ON "core"."agentChatMessage" ("threadId") `,
);
await queryRunner.query(
`CREATE TABLE "core"."agentChatThread" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "agentId" uuid NOT NULL, "userWorkspaceId" uuid NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_a53b1d75d11ec67d13590cfa627" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_d0bdc80c68a48b1f26727aabfe" ON "core"."agentChatThread" ("agentId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_3bd935d6f8c5ce87194b8db824" ON "core"."agentChatThread" ("userWorkspaceId") `,
);
await queryRunner.query(
`ALTER TABLE "core"."agentChatMessage" ADD CONSTRAINT "FK_cd5b23d4e471b630137b3017ba6" FOREIGN KEY ("threadId") REFERENCES "core"."agentChatThread"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" ADD CONSTRAINT "FK_d0bdc80c68a48b1f26727aabfe6" FOREIGN KEY ("agentId") REFERENCES "core"."agent"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" ADD CONSTRAINT "FK_3bd935d6f8c5ce87194b8db8240" FOREIGN KEY ("userWorkspaceId") REFERENCES "core"."userWorkspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" DROP CONSTRAINT "FK_3bd935d6f8c5ce87194b8db8240"`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" DROP CONSTRAINT "FK_d0bdc80c68a48b1f26727aabfe6"`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentChatMessage" DROP CONSTRAINT "FK_cd5b23d4e471b630137b3017ba6"`,
);
await queryRunner.query(`DROP TABLE "core"."agentChatThread"`);
await queryRunner.query(
`DROP INDEX "core"."IDX_cd5b23d4e471b630137b3017ba"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_d0bdc80c68a48b1f26727aabfe"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_3bd935d6f8c5ce87194b8db824"`,
);
await queryRunner.query(`DROP TABLE "core"."agentChatMessage"`);
await queryRunner.query(`DROP TYPE "core"."agentChatMessage_role_enum"`);
}
}

View File

@ -0,0 +1,42 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
} from 'typeorm';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/agent/agent-chat-thread.entity';
export enum AgentChatMessageRole {
USER = 'user',
ASSISTANT = 'assistant',
}
@Entity('agentChatMessage')
export class AgentChatMessageEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
@Index()
threadId: string;
@ManyToOne(() => AgentChatThreadEntity, (thread) => thread.messages, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'threadId' })
thread: Relation<AgentChatThreadEntity>;
@Column({ type: 'enum', enum: AgentChatMessageRole })
role: AgentChatMessageRole;
@Column('text')
content: string;
@CreateDateColumn()
createdAt: Date;
}

View File

@ -0,0 +1,53 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { AgentChatMessageEntity } from 'src/engine/metadata-modules/agent/agent-chat-message.entity';
import { AgentEntity } from './agent.entity';
@Entity('agentChatThread')
export class AgentChatThreadEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
@Index()
agentId: string;
@ManyToOne(() => AgentEntity, (agent) => agent.chatThreads, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'agentId' })
agent: Relation<AgentEntity>;
@Column('uuid')
@Index()
userWorkspaceId: string;
@ManyToOne(() => UserWorkspace, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'userWorkspaceId' })
userWorkspace: Relation<UserWorkspace>;
@OneToMany(() => AgentChatMessageEntity, (message) => message.thread)
messages: Relation<AgentChatMessageEntity[]>;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,72 @@
import {
Body,
Controller,
Get,
Param,
Post,
Res,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { Response } from 'express';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AgentChatService } from './agent-chat.service';
import { AgentStreamingService } from './agent-streaming.service';
@Controller('rest/agent-chat')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
@UseFilters(RestApiExceptionFilter)
export class AgentChatController {
constructor(
private readonly agentChatService: AgentChatService,
private readonly agentStreamingService: AgentStreamingService,
) {}
@Get('threads/:agentId')
async getThreadsForAgent(
@Param('agentId') agentId: string,
@AuthUserWorkspaceId() userWorkspaceId: string,
) {
return this.agentChatService.getThreadsForAgent(agentId, userWorkspaceId);
}
@Get('threads/:threadId/messages')
async getMessagesForThread(
@Param('threadId') threadId: string,
@AuthUserWorkspaceId() userWorkspaceId: string,
) {
return this.agentChatService.getMessagesForThread(
threadId,
userWorkspaceId,
);
}
@Post('threads')
async createThread(
@Body() body: { agentId: string },
@AuthUserWorkspaceId() userWorkspaceId: string,
) {
return this.agentChatService.createThread(body.agentId, userWorkspaceId);
}
@Post('stream')
async streamAgentChat(
@Body()
body: { threadId: string; userMessage: string },
@AuthUserWorkspaceId() userWorkspaceId: string,
@Res() res: Response,
) {
await this.agentStreamingService.streamAgentChat({
threadId: body.threadId,
userMessage: body.userMessage,
userWorkspaceId,
res,
});
}
}

View File

@ -0,0 +1,85 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
AgentChatMessageEntity,
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 { AgentExecutionService } from './agent-execution.service';
@Injectable()
export class AgentChatService {
constructor(
@InjectRepository(AgentChatThreadEntity, 'core')
private readonly threadRepository: Repository<AgentChatThreadEntity>,
@InjectRepository(AgentChatMessageEntity, 'core')
private readonly messageRepository: Repository<AgentChatMessageEntity>,
private readonly agentExecutionService: AgentExecutionService,
) {}
async createThread(agentId: string, userWorkspaceId: string) {
const thread = this.threadRepository.create({
agentId,
userWorkspaceId,
});
return this.threadRepository.save(thread);
}
async getThreadsForAgent(agentId: string, userWorkspaceId: string) {
return this.threadRepository.find({
where: {
agentId,
userWorkspaceId,
},
order: { createdAt: 'DESC' },
});
}
async addMessage({
threadId,
role,
content,
}: {
threadId: string;
role: AgentChatMessageRole;
content: string;
}) {
const message = this.messageRepository.create({
threadId,
role,
content,
});
return this.messageRepository.save(message);
}
async getMessagesForThread(threadId: string, userWorkspaceId: string) {
const thread = await this.threadRepository.findOne({
where: {
id: threadId,
userWorkspaceId,
},
});
if (!thread) {
throw new AgentException(
'Thread not found',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
return this.messageRepository.find({
where: { threadId },
order: { createdAt: 'ASC' },
});
}
}

View File

@ -1,8 +1,10 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createOpenAI } from '@ai-sdk/openai';
import { generateObject, generateText } from 'ai';
import { CoreMessage, generateObject, generateText, streamText } from 'ai';
import { Repository } from 'typeorm';
import {
ModelId,
@ -10,6 +12,10 @@ import {
} from 'src/engine/core-modules/ai/constants/ai-models.const';
import { getAIModelById } from 'src/engine/core-modules/ai/utils/get-ai-model-by-id';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import {
AgentChatMessageEntity,
AgentChatMessageRole,
} from 'src/engine/metadata-modules/agent/agent-chat-message.entity';
import { AgentToolService } from 'src/engine/metadata-modules/agent/agent-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';
@ -37,9 +43,13 @@ export class AgentExecutionService {
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly agentToolService: AgentToolService,
@InjectRepository(AgentEntity, 'core')
private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(AgentChatMessageEntity, 'core')
private readonly agentChatmessageRepository: Repository<AgentChatMessageEntity>,
) {}
private getModel = (modelId: ModelId, provider: ModelProvider) => {
getModel = (modelId: ModelId, provider: ModelProvider) => {
switch (provider) {
case ModelProvider.OPENAI: {
const OpenAIProvider = createOpenAI({
@ -79,7 +89,6 @@ export class AgentExecutionService {
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
if (!apiKey) {
throw new AgentException(
`${provider.toUpperCase()} API key not configured`,
@ -88,6 +97,76 @@ export class AgentExecutionService {
}
}
async prepareAIRequestConfig({
messages,
prompt,
system,
agent,
}: {
system: string;
agent: AgentEntity;
prompt?: string;
messages?: CoreMessage[];
}) {
const aiModel = getAIModelById(agent.modelId);
if (!aiModel) {
throw new AgentException(
`AI model with id ${agent.modelId} not found`,
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
const provider = aiModel.provider;
await this.validateApiKey(provider);
const tools = await this.agentToolService.generateToolsForAgent(
agent.id,
agent.workspaceId,
);
return {
system,
tools,
model: this.getModel(agent.modelId, aiModel.provider),
...(messages && { messages }),
...(prompt && { prompt }),
maxSteps: AGENT_CONFIG.MAX_STEPS,
};
}
async streamChatResponse({
agentId,
userMessage,
messages,
}: {
agentId: string;
userMessage: string;
messages: AgentChatMessageEntity[];
}) {
const agent = await this.agentRepository.findOneOrFail({
where: { id: agentId },
});
const llmMessages: CoreMessage[] = messages.map(({ role, content }) => ({
role,
content,
}));
llmMessages.push({
role: AgentChatMessageRole.USER,
content: userMessage,
});
const aiRequestConfig = await this.prepareAIRequestConfig({
system: `${AGENT_SYSTEM_PROMPTS.AGENT_CHAT}\n\n${agent.prompt}`,
agent,
messages: llmMessages,
});
return streamText(aiRequestConfig);
}
async executeAgent({
agent,
context,
@ -98,31 +177,12 @@ export class AgentExecutionService {
schema: OutputSchema;
}): Promise<AgentExecutionResult> {
try {
const aiModel = getAIModelById(agent.modelId);
if (!aiModel) {
throw new AgentException(
`AI model with id ${agent.modelId} not found`,
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
const provider = aiModel.provider;
await this.validateApiKey(provider);
const tools = await this.agentToolService.generateToolsForAgent(
agent.id,
agent.workspaceId,
);
const textResponse = await generateText({
const aiRequestConfig = await this.prepareAIRequestConfig({
system: AGENT_SYSTEM_PROMPTS.AGENT_EXECUTION,
model: this.getModel(agent.modelId, provider),
agent,
prompt: resolveInput(agent.prompt, context) as string,
tools,
maxSteps: AGENT_CONFIG.MAX_STEPS,
});
const textResponse = await generateText(aiRequestConfig);
if (Object.keys(schema).length === 0) {
return {
@ -130,10 +190,9 @@ export class AgentExecutionService {
usage: textResponse.usage,
};
}
const output = await generateObject({
system: AGENT_SYSTEM_PROMPTS.OUTPUT_GENERATOR,
model: this.getModel(agent.modelId, provider),
model: aiRequestConfig.model,
prompt: `Based on the following execution results, generate the structured output according to the schema:
Execution Results: ${textResponse.text}
@ -163,7 +222,6 @@ export class AgentExecutionService {
if (error instanceof AgentException) {
throw error;
}
throw new AgentException(
error instanceof Error ? error.message : 'Agent execution failed',
AgentExceptionCode.AGENT_EXECUTION_FAILED,

View File

@ -0,0 +1,102 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Response } from 'express';
import { Repository } from 'typeorm';
import { AgentChatMessageRole } from 'src/engine/metadata-modules/agent/agent-chat-message.entity';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/agent/agent-chat-thread.entity';
import { AgentChatService } from 'src/engine/metadata-modules/agent/agent-chat.service';
import { AgentExecutionService } from 'src/engine/metadata-modules/agent/agent-execution.service';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
export type StreamAgentChatOptions = {
threadId: string;
userMessage: string;
userWorkspaceId: string;
res: Response;
};
export type StreamAgentChatResult = {
success: boolean;
error?: string;
aiResponse?: string;
};
@Injectable()
export class AgentStreamingService {
constructor(
@InjectRepository(AgentChatThreadEntity, 'core')
private readonly threadRepository: Repository<AgentChatThreadEntity>,
private readonly agentChatService: AgentChatService,
private readonly agentExecutionService: AgentExecutionService,
) {}
async streamAgentChat({
threadId,
userMessage,
userWorkspaceId,
res,
}: StreamAgentChatOptions) {
try {
const thread = await this.threadRepository.findOne({
where: {
id: threadId,
userWorkspaceId,
},
relations: ['messages', 'agent'],
});
if (!thread) {
throw new AgentException(
'Thread not found',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
await this.agentChatService.addMessage({
threadId,
role: AgentChatMessageRole.USER,
content: userMessage,
});
this.setupStreamingHeaders(res);
const { textStream } =
await this.agentExecutionService.streamChatResponse({
agentId: thread.agent.id,
userMessage,
messages: thread.messages,
});
let aiResponse = '';
for await (const chunk of textStream) {
aiResponse += chunk;
res.write(chunk);
}
await this.agentChatService.addMessage({
threadId,
role: AgentChatMessageRole.ASSISTANT,
content: aiResponse,
});
res.end();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error occurred';
return { success: false, error: errorMessage };
}
}
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

@ -6,6 +6,7 @@ import {
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@ -15,6 +16,8 @@ import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/i
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentChatThreadEntity } from './agent-chat-thread.entity';
@Entity('agent')
@Index('IDX_AGENT_ID_DELETED_AT', ['id', 'deletedAt'])
export class AgentEntity {
@ -45,6 +48,9 @@ export class AgentEntity {
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
@OneToMany(() => AgentChatThreadEntity, (chatThread) => chatThread.agent)
chatThreads: Relation<AgentChatThreadEntity[]>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;

View File

@ -3,14 +3,22 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { AgentChatController } from 'src/engine/metadata-modules/agent/agent-chat.controller';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { AgentChatMessageEntity } from './agent-chat-message.entity';
import { AgentChatThreadEntity } from './agent-chat-thread.entity';
import { AgentChatService } from './agent-chat.service';
import { AgentExecutionService } from './agent-execution.service';
import { AgentStreamingService } from './agent-streaming.service';
import { AgentToolService } from './agent-tool.service';
import { AgentEntity } from './agent.entity';
import { AgentResolver } from './agent.resolver';
@ -19,7 +27,14 @@ import { AgentService } from './agent.service';
@Module({
imports: [
TypeOrmModule.forFeature(
[AgentEntity, RoleEntity, RoleTargetsEntity],
[
AgentEntity,
RoleEntity,
RoleTargetsEntity,
AgentChatMessageEntity,
AgentChatThreadEntity,
UserWorkspace,
],
'core',
),
AiModule,
@ -28,18 +43,28 @@ import { AgentService } from './agent.service';
FeatureFlagModule,
ObjectMetadataModule,
WorkspacePermissionsCacheModule,
WorkspaceCacheStorageModule,
TokenModule,
],
controllers: [AgentChatController],
providers: [
AgentResolver,
AgentService,
AgentExecutionService,
AgentToolService,
AgentChatService,
AgentStreamingService,
],
exports: [
AgentService,
AgentExecutionService,
AgentToolService,
TypeOrmModule.forFeature([AgentEntity], 'core'),
AgentChatService,
AgentStreamingService,
TypeOrmModule.forFeature(
[AgentEntity, AgentChatMessageEntity, AgentChatThreadEntity],
'core',
),
],
})
export class AgentModule {}

View File

@ -10,19 +10,14 @@ You have access to full CRUD operations for all standard objects in the system:
Common objects include: person, company, opportunity, task, note etc. and any custom objects.
CRITICAL PERMISSION CHECK:
Before attempting any operation, you MUST first check if you have the required tools available. If you do NOT have the necessary tools to perform the requested operation, you MUST immediately respond with:
"I cannot perform this operation because I don't have the necessary permissions. Please check that I have been assigned the appropriate role for this workspace."
DO NOT describe what you would do, DO NOT list steps, DO NOT simulate the operation. Simply state that you cannot perform the action due to missing permissions.
Your responsibilities:
1. FIRST check if you have the required tools for the requested operation
2. If tools are NOT available, immediately state you lack permissions - do not proceed further
3. If tools ARE available, analyze the input context and prompt carefully
4. Use available database tools when the request involves data operations
5. For any request to create, read, update, or delete records, use the appropriate tools
6. If no database operations are needed, process the request directly with your analysis
1. Analyze the input context and prompt carefully
2. If the request involves database operations (create, read, update, delete), check if you have the required tools available
3. If database tools are NOT available for the requested operation, state that you lack permissions for that specific operation. You can respond with:
"I cannot perform this operation because I don't have the necessary permissions. Please check that I have been assigned the appropriate role for this workspace."
4. If database tools ARE available, use them to perform the requested operations
5. If no database operations are needed, process the request directly with your analysis
6. Provide comprehensive responses that include all relevant information and context
Workflow context:
- You are part of a larger workflow system where your output may be used by other nodes
@ -31,7 +26,7 @@ Workflow context:
- If you encounter data or perform actions, document them clearly in your response
Tool usage guidelines:
- ALWAYS use tools for database operations - do not simulate or describe them
- Use tools for database operations when requested - do not simulate or describe them
- Use create_[object] tools when asked to create new records
- Use find_[object] tools when asked to search or retrieve records
- Use update_[object] tools when asked to modify existing records
@ -40,7 +35,10 @@ Tool usage guidelines:
- Provide context about what tools you used and why
- If a tool fails, explain the issue and suggest alternatives
CRITICAL: When users ask you to perform any database operation (create, find, update, delete), you MUST use the appropriate tools. Do not just describe what you would do - actually execute the operations using the available tools. If you cannot execute the operation due to lack of permissions or roles, you MUST state this clearly in your response.
Permission handling:
- Only check for permissions when database operations are actually requested
- If you don't have the necessary tools for a database operation, clearly state the limitation
- For non-database requests, proceed normally without permission checks
Important: After your response, the system will call generateObject to convert your output into a structured format according to a specific schema. Therefore:
- Provide comprehensive information in your response
@ -68,4 +66,20 @@ Guidelines:
- Preserve the context and meaning from the original execution results
- Ensure the output is clean, well-formatted, and ready for workflow consumption
- Pay special attention to any data returned from tool executions (database queries, record creation, etc.)`,
AGENT_CHAT: `You are a helpful AI assistant for this workspace. You can:
- Answer questions conversationally, clearly, and helpfully
- Provide insights, support, and updates about people, companies, opportunities, tasks, notes, and other business objects.
- Access and summarize information you have permission to see
- Help users understand how to use the system and its features
Permissions and capabilities:
- You can only perform actions and access data that your assigned role and permissions allow
- If a user requests something you do not have permission for, politely explain the limitation (e.g., "I cannot perform this operation because I don't have the necessary permissions. Please check your role or contact an admin.")
- If you are unsure about your permissions for a specific action, ask the user for clarification or suggest they check with an administrator
- Do not attempt to simulate or fake actions you cannot perform
If you need more information to answer a question, ask follow-up questions. Always be transparent about your capabilities and limitations.
Note: This base system prompt will be combined with the agent's specific instructions and context to provide you with complete guidance for your role.`,
};

View File

@ -0,0 +1,19 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
@ObjectType('AgentChatMessage')
export class AgentChatMessageDTO {
@Field(() => ID)
id: string;
@Field(() => ID)
threadId: string;
@Field()
role: 'user' | 'assistant';
@Field()
content: string;
@Field()
createdAt: Date;
}

View File

@ -0,0 +1,16 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
@ObjectType('AgentChatThread')
export class AgentChatThreadDTO {
@Field(() => ID)
id: string;
@Field(() => ID)
agentId: string;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
}

View File

@ -5,6 +5,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module';
import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service';
@ -21,7 +22,10 @@ import { WorkflowRunnerModule } from 'src/modules/workflow/workflow-runner/workf
WorkflowCommonModule,
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'core'),
],
providers: [WorkflowVersionStepWorkspaceService],
providers: [
WorkflowVersionStepWorkspaceService,
ScopedWorkspaceContextFactory,
],
exports: [WorkflowVersionStepWorkspaceService],
})
export class WorkflowVersionStepModule {}

View File

@ -10,9 +10,11 @@ import { v4 } from 'uuid';
import { BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA } from 'src/engine/core-modules/serverless/drivers/constants/base-typescript-project-input-schema';
import { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto';
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto';
import { AgentChatService } from 'src/engine/metadata-modules/agent/agent-chat.service';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import {
WorkflowVersionStepException,
@ -60,7 +62,9 @@ export class WorkflowVersionStepWorkspaceService {
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService,
private readonly workflowRunnerWorkspaceService: WorkflowRunnerWorkspaceService,
private readonly agentChatService: AgentChatService,
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
) {}
async createWorkflowVersionStep({
@ -635,6 +639,18 @@ export class WorkflowVersionStepWorkspaceService {
);
}
const userWorkspaceId =
this.scopedWorkspaceContextFactory.create().userWorkspaceId;
if (!userWorkspaceId) {
throw new WorkflowVersionStepException(
'User workspace ID not found',
WorkflowVersionStepExceptionCode.FAILURE,
);
}
await this.agentChatService.createThread(newAgent.id, userWorkspaceId);
return {
id: newStepId,
name: 'AI Agent',

View File

@ -113,6 +113,7 @@ export const createAgentToolTestModule =
roleId: testRoleId,
createdAt: new Date(),
updatedAt: new Date(),
chatThreads: [],
};
const testRole: RoleEntity = {

207
yarn.lock
View File

@ -442,8 +442,8 @@ __metadata:
linkType: hard
"@apollo/client@npm:^3.7.0, @apollo/client@npm:^3.7.17":
version: 3.11.4
resolution: "@apollo/client@npm:3.11.4"
version: 3.13.8
resolution: "@apollo/client@npm:3.13.8"
dependencies:
"@graphql-typed-document-node/core": "npm:^3.1.1"
"@wry/caches": "npm:^1.0.0"
@ -454,16 +454,15 @@ __metadata:
optimism: "npm:^0.18.0"
prop-types: "npm:^15.7.2"
rehackt: "npm:^0.1.0"
response-iterator: "npm:^0.2.6"
symbol-observable: "npm:^4.0.0"
ts-invariant: "npm:^0.10.3"
tslib: "npm:^2.3.0"
zen-observable-ts: "npm:^1.2.5"
peerDependencies:
graphql: ^15.0.0 || ^16.0.0
graphql-ws: ^5.5.5
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0
graphql-ws: ^5.5.5 || ^6.0.3
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc
subscriptions-transport-ws: ^0.9.0 || ^0.11.0
peerDependenciesMeta:
graphql-ws:
@ -474,7 +473,7 @@ __metadata:
optional: true
subscriptions-transport-ws:
optional: true
checksum: 10c0/1d0f848a68803e4987f7bceb6c4d76a1c9c584a66cefbcacfe8aebb342b68e4cca96ecb76e7a5a423f5047da8c35e52d971cad3d532248976976c32336eac7d9
checksum: 10c0/0e5032c1ae1dbef72a01f87af06b84bf505d60e71eba7cb9f20f8284778d8ead65fc1b7eacc570eccb8d045577d7194e38401fbfbdf56c197e159ca91ef11755
languageName: node
linkType: hard
@ -26846,6 +26845,17 @@ __metadata:
languageName: node
linkType: hard
"apollo-link-rest@npm:^0.9.0":
version: 0.9.0
resolution: "apollo-link-rest@npm:0.9.0"
peerDependencies:
"@apollo/client": ">=3"
graphql: ">=0.11"
qs: ">=6"
checksum: 10c0/0ce92c27d5d8cca9695a0517746f97de6507ac9537d9f986fd47bbfa25486fedc14db89116b61146bef43b2fc17b5bb8d61a3fcb1739bb474c205c38ef32db67
languageName: node
linkType: hard
"apollo-reporting-protobuf@npm:^3.4.0":
version: 3.4.0
resolution: "apollo-reporting-protobuf@npm:3.4.0"
@ -27604,7 +27614,7 @@ __metadata:
languageName: node
linkType: hard
"axios@npm:^1.6.0, axios@npm:^1.6.1, axios@npm:^1.6.2":
"axios@npm:^1.6.0, axios@npm:^1.6.1":
version: 1.7.3
resolution: "axios@npm:1.7.3"
dependencies:
@ -27615,6 +27625,17 @@ __metadata:
languageName: node
linkType: hard
"axios@npm:^1.6.2":
version: 1.10.0
resolution: "axios@npm:1.10.0"
dependencies:
follow-redirects: "npm:^1.15.6"
form-data: "npm:^4.0.0"
proxy-from-env: "npm:^1.1.0"
checksum: 10c0/2239cb269cc789eac22f5d1aabd58e1a83f8f364c92c2caa97b6f5cbb4ab2903d2e557d9dc670b5813e9bcdebfb149e783fb8ab3e45098635cd2f559b06bd5d8
languageName: node
linkType: hard
"axobject-query@npm:~3.1.1":
version: 3.1.1
resolution: "axobject-query@npm:3.1.1"
@ -29760,6 +29781,16 @@ __metadata:
languageName: node
linkType: hard
"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
version: 1.0.2
resolution: "call-bind-apply-helpers@npm:1.0.2"
dependencies:
es-errors: "npm:^1.3.0"
function-bind: "npm:^1.1.2"
checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938
languageName: node
linkType: hard
"call-bind@npm:^1.0.0, call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.6, call-bind@npm:^1.0.7":
version: 1.0.7
resolution: "call-bind@npm:1.0.7"
@ -29773,6 +29804,16 @@ __metadata:
languageName: node
linkType: hard
"call-bound@npm:^1.0.2":
version: 1.0.4
resolution: "call-bound@npm:1.0.4"
dependencies:
call-bind-apply-helpers: "npm:^1.0.2"
get-intrinsic: "npm:^1.3.0"
checksum: 10c0/f4796a6a0941e71c766aea672f63b72bc61234c4f4964dc6d7606e3664c307e7d77845328a8f3359ce39ddb377fed67318f9ee203dea1d47e46165dcf2917644
languageName: node
linkType: hard
"call-me-maybe@npm:^1.0.1":
version: 1.0.2
resolution: "call-me-maybe@npm:1.0.2"
@ -33514,6 +33555,17 @@ __metadata:
languageName: node
linkType: hard
"dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
dependencies:
call-bind-apply-helpers: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
gopd: "npm:^1.2.0"
checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031
languageName: node
linkType: hard
"duplexer2@npm:^0.1.2, duplexer2@npm:~0.1.0, duplexer2@npm:~0.1.2, duplexer2@npm:~0.1.4":
version: 0.1.4
resolution: "duplexer2@npm:0.1.4"
@ -33923,6 +33975,13 @@ __metadata:
languageName: node
linkType: hard
"es-define-property@npm:^1.0.1":
version: 1.0.1
resolution: "es-define-property@npm:1.0.1"
checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c
languageName: node
linkType: hard
"es-errors@npm:^1.2.1, es-errors@npm:^1.3.0":
version: 1.3.0
resolution: "es-errors@npm:1.3.0"
@ -33999,6 +34058,15 @@ __metadata:
languageName: node
linkType: hard
"es-object-atoms@npm:^1.1.1":
version: 1.1.1
resolution: "es-object-atoms@npm:1.1.1"
dependencies:
es-errors: "npm:^1.3.0"
checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c
languageName: node
linkType: hard
"es-set-tostringtag@npm:^2.0.3":
version: 2.0.3
resolution: "es-set-tostringtag@npm:2.0.3"
@ -37121,6 +37189,24 @@ __metadata:
languageName: node
linkType: hard
"get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.3.0":
version: 1.3.0
resolution: "get-intrinsic@npm:1.3.0"
dependencies:
call-bind-apply-helpers: "npm:^1.0.2"
es-define-property: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
es-object-atoms: "npm:^1.1.1"
function-bind: "npm:^1.1.2"
get-proto: "npm:^1.0.1"
gopd: "npm:^1.2.0"
has-symbols: "npm:^1.1.0"
hasown: "npm:^2.0.2"
math-intrinsics: "npm:^1.1.0"
checksum: 10c0/52c81808af9a8130f581e6a6a83e1ba4a9f703359e7a438d1369a5267a25412322f03dcbd7c549edaef0b6214a0630a28511d7df0130c93cfd380f4fa0b5b66a
languageName: node
linkType: hard
"get-nonce@npm:^1.0.0":
version: 1.0.1
resolution: "get-nonce@npm:1.0.1"
@ -37163,6 +37249,16 @@ __metadata:
languageName: node
linkType: hard
"get-proto@npm:^1.0.1":
version: 1.0.1
resolution: "get-proto@npm:1.0.1"
dependencies:
dunder-proto: "npm:^1.0.1"
es-object-atoms: "npm:^1.0.0"
checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c
languageName: node
linkType: hard
"get-stdin@npm:^6.0.0":
version: 6.0.0
resolution: "get-stdin@npm:6.0.0"
@ -37636,6 +37732,13 @@ __metadata:
languageName: node
linkType: hard
"gopd@npm:^1.2.0":
version: 1.2.0
resolution: "gopd@npm:1.2.0"
checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead
languageName: node
linkType: hard
"got@npm:^11.8.3, got@npm:^11.8.5":
version: 11.8.6
resolution: "got@npm:11.8.6"
@ -38165,6 +38268,13 @@ __metadata:
languageName: node
linkType: hard
"has-symbols@npm:^1.1.0":
version: 1.1.0
resolution: "has-symbols@npm:1.1.0"
checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e
languageName: node
linkType: hard
"has-tostringtag@npm:^1.0.0, has-tostringtag@npm:^1.0.2":
version: 1.0.2
resolution: "has-tostringtag@npm:1.0.2"
@ -43902,6 +44012,13 @@ __metadata:
languageName: node
linkType: hard
"math-intrinsics@npm:^1.1.0":
version: 1.1.0
resolution: "math-intrinsics@npm:1.1.0"
checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f
languageName: node
linkType: hard
"math-random@npm:^1.0.1":
version: 1.0.4
resolution: "math-random@npm:1.0.4"
@ -47680,6 +47797,13 @@ __metadata:
languageName: node
linkType: hard
"object-inspect@npm:^1.13.3":
version: 1.13.4
resolution: "object-inspect@npm:1.13.4"
checksum: 10c0/d7f8711e803b96ea3191c745d6f8056ce1f2496e530e6a19a0e92d89b0fa3c76d910c31f0aa270432db6bd3b2f85500a376a83aaba849a8d518c8845b3211692
languageName: node
linkType: hard
"object-is@npm:^1.1.5":
version: 1.1.6
resolution: "object-is@npm:1.1.6"
@ -50549,7 +50673,7 @@ __metadata:
languageName: node
linkType: hard
"qs@npm:^6.10.0, qs@npm:^6.10.1, qs@npm:^6.11.0, qs@npm:^6.11.2, qs@npm:^6.12.3, qs@npm:^6.4.0, qs@npm:^6.7.0":
"qs@npm:^6.10.0, qs@npm:^6.10.1, qs@npm:^6.11.0, qs@npm:^6.12.3, qs@npm:^6.4.0, qs@npm:^6.7.0":
version: 6.13.0
resolution: "qs@npm:6.13.0"
dependencies:
@ -50558,6 +50682,15 @@ __metadata:
languageName: node
linkType: hard
"qs@npm:^6.11.2":
version: 6.14.0
resolution: "qs@npm:6.14.0"
dependencies:
side-channel: "npm:^1.1.0"
checksum: 10c0/8ea5d91bf34f440598ee389d4a7d95820e3b837d3fd9f433871f7924801becaa0cd3b3b4628d49a7784d06a8aea9bc4554d2b6d8d584e2d221dc06238a42909c
languageName: node
linkType: hard
"qs@npm:~6.5.2":
version: 6.5.3
resolution: "qs@npm:6.5.3"
@ -52681,13 +52814,6 @@ __metadata:
languageName: node
linkType: hard
"response-iterator@npm:^0.2.6":
version: 0.2.6
resolution: "response-iterator@npm:0.2.6"
checksum: 10c0/60e6b552cd610643269d5d916d270cc8a4bea978cbe4779d6ef8083ac6b89006795508034e4c4ebe204eded75ac32bf243589ba82c1184591dde0674f6db785e
languageName: node
linkType: hard
"responselike@npm:^1.0.2":
version: 1.0.2
resolution: "responselike@npm:1.0.2"
@ -53763,6 +53889,41 @@ __metadata:
languageName: node
linkType: hard
"side-channel-list@npm:^1.0.0":
version: 1.0.0
resolution: "side-channel-list@npm:1.0.0"
dependencies:
es-errors: "npm:^1.3.0"
object-inspect: "npm:^1.13.3"
checksum: 10c0/644f4ac893456c9490ff388bf78aea9d333d5e5bfc64cfb84be8f04bf31ddc111a8d4b83b85d7e7e8a7b845bc185a9ad02c052d20e086983cf59f0be517d9b3d
languageName: node
linkType: hard
"side-channel-map@npm:^1.0.1":
version: 1.0.1
resolution: "side-channel-map@npm:1.0.1"
dependencies:
call-bound: "npm:^1.0.2"
es-errors: "npm:^1.3.0"
get-intrinsic: "npm:^1.2.5"
object-inspect: "npm:^1.13.3"
checksum: 10c0/010584e6444dd8a20b85bc926d934424bd809e1a3af941cace229f7fdcb751aada0fb7164f60c2e22292b7fa3c0ff0bce237081fd4cdbc80de1dc68e95430672
languageName: node
linkType: hard
"side-channel-weakmap@npm:^1.0.2":
version: 1.0.2
resolution: "side-channel-weakmap@npm:1.0.2"
dependencies:
call-bound: "npm:^1.0.2"
es-errors: "npm:^1.3.0"
get-intrinsic: "npm:^1.2.5"
object-inspect: "npm:^1.13.3"
side-channel-map: "npm:^1.0.1"
checksum: 10c0/71362709ac233e08807ccd980101c3e2d7efe849edc51455030327b059f6c4d292c237f94dc0685031dd11c07dd17a68afde235d6cf2102d949567f98ab58185
languageName: node
linkType: hard
"side-channel@npm:^1.0.4, side-channel@npm:^1.0.6":
version: 1.0.6
resolution: "side-channel@npm:1.0.6"
@ -53775,6 +53936,19 @@ __metadata:
languageName: node
linkType: hard
"side-channel@npm:^1.1.0":
version: 1.1.0
resolution: "side-channel@npm:1.1.0"
dependencies:
es-errors: "npm:^1.3.0"
object-inspect: "npm:^1.13.3"
side-channel-list: "npm:^1.0.0"
side-channel-map: "npm:^1.0.1"
side-channel-weakmap: "npm:^1.0.2"
checksum: 10c0/cb20dad41eb032e6c24c0982e1e5a24963a28aa6122b4f05b3f3d6bf8ae7fd5474ef382c8f54a6a3ab86e0cac4d41a23bd64ede3970e5bfb50326ba02a7996e6
languageName: node
linkType: hard
"siginfo@npm:^2.0.0":
version: 2.0.0
resolution: "siginfo@npm:2.0.0"
@ -57093,6 +57267,7 @@ __metadata:
add: "npm:^2.0.6"
addressparser: "npm:^1.0.1"
afterframe: "npm:^1.0.2"
apollo-link-rest: "npm:^0.9.0"
apollo-server-express: "npm:^3.12.0"
apollo-upload-client: "npm:^17.0.0"
archiver: "npm:^7.0.1"