Feat: Agent chat multi thread support (#13216)

Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: MD Readul Islam <99027968+readul-islam@users.noreply.github.com>
Co-authored-by: readul-islam <developer.readul@gamil.com>
Co-authored-by: Thomas des Francs <tdesfrancs@gmail.com>
Co-authored-by: Guillim <guillim@users.noreply.github.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Jean-Baptiste Ronssin <65334819+jbronssin@users.noreply.github.com>
Co-authored-by: kahkashan shaik <93042682+kahkashanshaik@users.noreply.github.com>
Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
Co-authored-by: Naifer <161821705+omarNaifer12@users.noreply.github.com>
Co-authored-by: Marie Stoppa <marie.stoppa@essec.edu>
This commit is contained in:
Abdul Rahman
2025-07-16 12:56:40 +05:30
committed by GitHub
parent ffcbfa6215
commit 8edf59a521
40 changed files with 944 additions and 79 deletions
@@ -9,6 +9,7 @@ export const GET_AGENT_CHAT_THREADS = gql`
) {
id
agentId
title
createdAt
updatedAt
}
@@ -1,18 +1,28 @@
import { TextArea } from '@/ui/input/components/TextArea';
import { keyframes, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Avatar, IconDotsVertical, IconSparkles } from 'twenty-ui/display';
import {
Avatar,
IconDotsVertical,
IconHistory,
IconMessageCirclePlus,
IconSparkles,
} from 'twenty-ui/display';
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { AgentChatFilePreview } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFilePreview';
import { AgentChatFileUpload } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFileUpload';
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { t } from '@lingui/core/macro';
import { Button } from 'twenty-ui/input';
import { AgentChatMessage } from '~/generated/graphql';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { useAgentChat } from '../hooks/useAgentChat';
import { AgentChatMessage } from '../hooks/useAgentChatMessages';
import { AIChatSkeletonLoader } from './AIChatSkeletonLoader';
import { AgentChatSelectedFilesPreview } from './AgentChatSelectedFilesPreview';
@@ -188,7 +198,13 @@ const StyledFilesContainer = styled.div`
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const AIChatTab = ({ agentId }: { agentId: string }) => {
export const AIChatTab = ({
agentId,
isWorkflowAgentNodeChat,
}: {
agentId: string;
isWorkflowAgentNodeChat?: boolean;
}) => {
const theme = useTheme();
const {
@@ -201,6 +217,9 @@ export const AIChatTab = ({ agentId }: { agentId: string }) => {
scrollWrapperId,
} = useAgentChat(agentId);
const { createAgentChatThread } = useCreateNewAIChatThread({ agentId });
const { navigateCommandMenu } = useCommandMenu();
const getAssistantMessageContent = (message: AgentChatMessage) => {
if (message.content !== '') {
return message.content;
@@ -308,6 +327,28 @@ export const AIChatTab = ({ agentId }: { agentId: string }) => {
onChange={handleInputChange}
/>
<StyledButtonsContainer>
{!isWorkflowAgentNodeChat && (
<>
<Button
variant="secondary"
size="small"
Icon={IconHistory}
onClick={() =>
navigateCommandMenu({
page: CommandMenuPages.ViewPreviousAIChats,
pageTitle: t`View Previous AI Chats`,
pageIcon: IconHistory,
})
}
/>
<Button
variant="secondary"
size="small"
Icon={IconMessageCirclePlus}
onClick={() => createAgentChatThread()}
/>
</>
)}
<AgentChatFileUpload agentId={agentId} />
<Button
variant="primary"
@@ -108,7 +108,7 @@ export const WorkflowEditActionAiAgent = ({
componentInstanceId={WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID}
/>
{activeTabId === WorkflowAiAgentTabId.CHAT ? (
<AIChatTab agentId={agentId} />
<AIChatTab agentId={agentId} isWorkflowAgentNodeChat />
) : (
<>
<WorkflowStepHeader
@@ -4,6 +4,7 @@ import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { currentAIChatThreadComponentState } from '@/ai/states/currentAIChatThreadComponentState';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperElement';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@@ -12,17 +13,21 @@ import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions
import { agentChatSelectedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatSelectedFilesComponentState';
import { agentChatUploadedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatUploadedFilesComponentState';
import { useApolloClient } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import {
useGetAgentChatMessagesQuery,
useGetAgentChatThreadsQuery,
} from '~/generated-metadata/graphql';
import { AgentChatMessage } from '~/generated/graphql';
import { agentChatInputState } from '../states/agentChatInputState';
import { agentChatMessagesComponentState } from '../states/agentChatMessagesComponentState';
import { agentStreamingMessageState } from '../states/agentStreamingMessageState';
import { parseAgentStreamingChunk } from '../utils/parseAgentStreamingChunk';
import { AgentChatMessage, useAgentChatMessages } from './useAgentChatMessages';
import { useAgentChatThreads } from './useAgentChatThreads';
interface OptimisticMessage extends AgentChatMessage {
type OptimisticMessage = AgentChatMessage & {
isPending: boolean;
}
};
export const useAgentChat = (agentId: string) => {
const apolloClient = useApolloClient();
@@ -33,6 +38,10 @@ export const useAgentChat = (agentId: string) => {
agentId,
);
const [currentThreadId, setCurrentThreadId] = useRecoilComponentStateV2(
currentAIChatThreadComponentState,
agentId,
);
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
useRecoilComponentStateV2(agentChatUploadedFilesComponentState, agentId);
@@ -61,26 +70,37 @@ export const useAgentChat = (agentId: string) => {
});
};
const { data: { threads = [] } = {}, loading: threadsLoading } =
useAgentChatThreads(agentId);
const currentThreadId = threads[0]?.id;
const { loading: threadsLoading } = useGetAgentChatThreadsQuery({
variables: { agentId },
skip: isDefined(currentThreadId),
onCompleted: (data) => {
if (data.agentChatThreads.length > 0) {
setCurrentThreadId(data.agentChatThreads[0].id);
}
},
});
const { loading: messagesLoading, refetch: refetchMessages } =
useAgentChatMessages(currentThreadId, ({ messages }) => {
setAgentChatMessages(messages);
scrollToBottom();
useGetAgentChatMessagesQuery({
variables: { threadId: currentThreadId as string },
skip: !isDefined(currentThreadId),
onCompleted: ({ agentChatMessages }) => {
setAgentChatMessages(agentChatMessages);
scrollToBottom();
},
});
const isLoading =
messagesLoading ||
threadsLoading ||
!currentThreadId ||
isStreaming ||
agentChatSelectedFiles.length > 0;
const createOptimisticMessages = (content: string): AgentChatMessage[] => {
const optimisticUserMessage: OptimisticMessage = {
id: v4(),
threadId: currentThreadId,
threadId: currentThreadId as string,
role: AgentChatMessageRole.USER,
content,
createdAt: new Date().toISOString(),
@@ -90,7 +110,7 @@ export const useAgentChat = (agentId: string) => {
const optimisticAiMessage: OptimisticMessage = {
id: v4(),
threadId: currentThreadId,
threadId: currentThreadId as string,
role: AgentChatMessageRole.ASSISTANT,
content: '',
createdAt: new Date().toISOString(),
@@ -163,7 +183,7 @@ export const useAgentChat = (agentId: string) => {
const { data } = await refetchMessages();
setAgentChatMessages(data?.messages);
setAgentChatMessages(data?.agentChatMessages);
setAgentStreamingMessage({
toolCall: '',
streamingText: '',
@@ -172,7 +192,7 @@ export const useAgentChat = (agentId: string) => {
};
const handleSendMessage = async () => {
if (!agentChatInput.trim() || isLoading) {
if (agentChatInput.trim() === '' || isLoading === true) {
return;
}
const content = agentChatInput.trim();
@@ -1,25 +0,0 @@
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 { File } from '~/generated-metadata/graphql';
import { GET_AGENT_CHAT_MESSAGES } from '../api/agent-chat-apollo.api';
export type AgentChatMessage = {
id: string;
threadId: string;
role: AgentChatMessageRole;
content: string;
createdAt: string;
files: File[];
};
export const useAgentChatMessages = (
threadId: string,
onCompleted?: (data: { messages: AgentChatMessage[] }) => void,
) => {
return useQuery<{ messages: AgentChatMessage[] }>(GET_AGENT_CHAT_MESSAGES, {
variables: { threadId },
skip: !isDefined(threadId),
onCompleted,
});
};
@@ -1,17 +0,0 @@
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),
});
};
@@ -1,6 +1,6 @@
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';
import { AgentChatMessage } from '~/generated-metadata/graphql';
export const AgentChatMessagesComponentInstanceContext =
createComponentInstanceContext();