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:
@ -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",
|
||||
|
||||
@ -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[],
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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,6 +107,10 @@ export const WorkflowEditActionAiAgent = ({
|
||||
behaveAsLinks={false}
|
||||
componentInstanceId={WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID}
|
||||
/>
|
||||
{activeTabId === WorkflowAiAgentTabId.CHAT ? (
|
||||
<AIChatTab agentId={agentId} />
|
||||
) : (
|
||||
<>
|
||||
<WorkflowStepHeader
|
||||
onTitleChange={(newName: string) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
@ -165,5 +176,7 @@ export const WorkflowEditActionAiAgent = ({
|
||||
)}
|
||||
</WorkflowStepBody>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
export enum AgentChatMessageRole {
|
||||
USER = 'user',
|
||||
ASSISTANT = 'assistant',
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
export enum WorkflowAiAgentTabId {
|
||||
SETTINGS = 'settings',
|
||||
TOOLS = 'tools',
|
||||
CHAT = 'chat',
|
||||
}
|
||||
|
||||
export const WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID =
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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),
|
||||
});
|
||||
};
|
||||
@ -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),
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const agentChatInputState = atom<string>({
|
||||
default: '',
|
||||
key: 'agentChatInputState',
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const agentStreamingMessageState = atom<string>({
|
||||
key: 'agentStreamingMessageState',
|
||||
default: '',
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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"`);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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.`,
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -113,6 +113,7 @@ export const createAgentToolTestModule =
|
||||
roleId: testRoleId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
chatThreads: [],
|
||||
};
|
||||
|
||||
const testRole: RoleEntity = {
|
||||
|
||||
207
yarn.lock
207
yarn.lock
@ -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"
|
||||
|
||||
Reference in New Issue
Block a user