Show tool execution messages in AI agent chat (#13117)
https://github.com/user-attachments/assets/c0a42726-50ac-496e-a993-9d6076a84a6a --------- Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
+59
-10
@@ -1,5 +1,5 @@
|
||||
import { TextArea } from '@/ui/input/components/TextArea';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { keyframes, useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import React from 'react';
|
||||
import { Avatar, IconDotsVertical, IconSparkles } from 'twenty-ui/display';
|
||||
@@ -11,6 +11,7 @@ import { t } from '@lingui/core/macro';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
|
||||
import { useAgentChat } from '../hooks/useAgentChat';
|
||||
import { AgentChatMessage } from '../hooks/useAgentChatMessages';
|
||||
import { AIChatSkeletonLoader } from './AIChatSkeletonLoader';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
@@ -86,9 +87,11 @@ const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledMessageRow = styled.div`
|
||||
const StyledMessageRow = styled.div<{ isShowingToolCall?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: ${({ isShowingToolCall }) =>
|
||||
isShowingToolCall ? 'center' : 'flex-start'};
|
||||
gap: ${({ theme }) => theme.spacing(3)};
|
||||
width: 100%;
|
||||
`;
|
||||
@@ -152,6 +155,23 @@ const StyledMessageContainer = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const dots = keyframes`
|
||||
0% { content: ''; }
|
||||
33% { content: '.'; }
|
||||
66% { content: '..'; }
|
||||
100% { content: '...'; }
|
||||
`;
|
||||
|
||||
const StyledToolCallContainer = styled.div`
|
||||
&::after {
|
||||
display: inline-block;
|
||||
content: '';
|
||||
animation: ${dots} 750ms steps(3, end) infinite;
|
||||
width: 2ch;
|
||||
text-align: left;
|
||||
}
|
||||
`;
|
||||
|
||||
type AIChatTabProps = {
|
||||
agentId: string;
|
||||
};
|
||||
@@ -168,16 +188,49 @@ export const AIChatTab: React.FC<AIChatTabProps> = ({ agentId }) => {
|
||||
agentStreamingMessage,
|
||||
} = useAgentChat(agentId);
|
||||
|
||||
const getAssistantMessageContent = (message: AgentChatMessage) => {
|
||||
if (message.content !== '') {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
if (agentStreamingMessage.streamingText !== '') {
|
||||
return agentStreamingMessage.streamingText;
|
||||
}
|
||||
|
||||
if (agentStreamingMessage.toolCall !== '') {
|
||||
return (
|
||||
<StyledToolCallContainer>
|
||||
{agentStreamingMessage.toolCall}
|
||||
</StyledToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledDotsIconContainer>
|
||||
<StyledDotsIcon size={theme.icon.size.xl} />
|
||||
</StyledDotsIconContainer>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
{messages.length !== 0 && (
|
||||
<StyledScrollWrapper componentInstanceId={agentId}>
|
||||
<StyledScrollWrapper
|
||||
componentInstanceId={`scroll-wrapper-ai-chat-${agentId}`}
|
||||
>
|
||||
{messages.map((msg) => (
|
||||
<StyledMessageBubble
|
||||
key={msg.id}
|
||||
isUser={msg.role === AgentChatMessageRole.USER}
|
||||
>
|
||||
<StyledMessageRow>
|
||||
<StyledMessageRow
|
||||
isShowingToolCall={
|
||||
msg.role === AgentChatMessageRole.ASSISTANT &&
|
||||
msg.content === '' &&
|
||||
agentStreamingMessage.streamingText === '' &&
|
||||
agentStreamingMessage.toolCall !== ''
|
||||
}
|
||||
>
|
||||
{msg.role === AgentChatMessageRole.ASSISTANT && (
|
||||
<StyledAvatarContainer>
|
||||
<Avatar
|
||||
@@ -197,12 +250,8 @@ export const AIChatTab: React.FC<AIChatTabProps> = ({ agentId }) => {
|
||||
<StyledMessageText
|
||||
isUser={msg.role === AgentChatMessageRole.USER}
|
||||
>
|
||||
{msg.role === AgentChatMessageRole.ASSISTANT && !msg.content
|
||||
? agentStreamingMessage || (
|
||||
<StyledDotsIconContainer>
|
||||
<StyledDotsIcon size={theme.icon.size.xl} />
|
||||
</StyledDotsIconContainer>
|
||||
)
|
||||
{msg.role === AgentChatMessageRole.ASSISTANT
|
||||
? getAssistantMessageContent(msg)
|
||||
: msg.content}
|
||||
</StyledMessageText>
|
||||
{msg.content && (
|
||||
|
||||
+26
-16
@@ -8,11 +8,11 @@ import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWr
|
||||
import { STREAM_CHAT_QUERY } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/api/agent-chat-apollo.api';
|
||||
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v4 } from 'uuid';
|
||||
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';
|
||||
|
||||
@@ -50,21 +50,14 @@ export const useAgentChat = (agentId: string) => {
|
||||
useAgentChatThreads(agentId);
|
||||
const currentThreadId = threads[0]?.id;
|
||||
|
||||
const {
|
||||
data: messagesData,
|
||||
loading: messagesLoading,
|
||||
refetch: refetchMessages,
|
||||
} = useAgentChatMessages(currentThreadId);
|
||||
const { loading: messagesLoading, refetch: refetchMessages } =
|
||||
useAgentChatMessages(currentThreadId, ({ messages }) => {
|
||||
setAgentChatMessages(messages);
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
const isLoading = messagesLoading || threadsLoading || isStreaming;
|
||||
|
||||
if (
|
||||
agentChatMessages.length === 0 &&
|
||||
isDefined(messagesData?.messages?.length)
|
||||
) {
|
||||
setAgentChatMessages(messagesData.messages);
|
||||
}
|
||||
|
||||
const createOptimisticMessages = (content: string): AgentChatMessage[] => {
|
||||
const optimisticUserMessage: OptimisticMessage = {
|
||||
id: v4(),
|
||||
@@ -104,8 +97,22 @@ export const useAgentChat = (agentId: string) => {
|
||||
},
|
||||
context: {
|
||||
onChunk: (chunk: string) => {
|
||||
setAgentStreamingMessage(chunk);
|
||||
scrollToBottom();
|
||||
parseAgentStreamingChunk(chunk, {
|
||||
onTextDelta: (message: string) => {
|
||||
setAgentStreamingMessage((prev) => ({
|
||||
...prev,
|
||||
streamingText: prev.streamingText + message,
|
||||
}));
|
||||
scrollToBottom();
|
||||
},
|
||||
onToolCall: (message: string) => {
|
||||
setAgentStreamingMessage((prev) => ({
|
||||
...prev,
|
||||
toolCall: message,
|
||||
}));
|
||||
scrollToBottom();
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -128,7 +135,10 @@ export const useAgentChat = (agentId: string) => {
|
||||
const { data } = await refetchMessages();
|
||||
|
||||
setAgentChatMessages(data?.messages);
|
||||
setAgentStreamingMessage('');
|
||||
setAgentStreamingMessage({
|
||||
toolCall: '',
|
||||
streamingText: '',
|
||||
});
|
||||
scrollToBottom();
|
||||
};
|
||||
|
||||
|
||||
+5
-1
@@ -11,9 +11,13 @@ export type AgentChatMessage = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export const useAgentChatMessages = (threadId: string) => {
|
||||
export const useAgentChatMessages = (
|
||||
threadId: string,
|
||||
onCompleted?: (data: { messages: AgentChatMessage[] }) => void,
|
||||
) => {
|
||||
return useQuery<{ messages: AgentChatMessage[] }>(GET_AGENT_CHAT_MESSAGES, {
|
||||
variables: { threadId },
|
||||
skip: !isDefined(threadId),
|
||||
onCompleted,
|
||||
});
|
||||
};
|
||||
|
||||
+8
-2
@@ -1,6 +1,12 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const agentStreamingMessageState = atom<string>({
|
||||
export const agentStreamingMessageState = atom<{
|
||||
toolCall: string;
|
||||
streamingText: string;
|
||||
}>({
|
||||
key: 'agentStreamingMessageState',
|
||||
default: '',
|
||||
default: {
|
||||
toolCall: '',
|
||||
streamingText: '',
|
||||
},
|
||||
});
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
export type AgentStreamingEvent = {
|
||||
type: 'text-delta' | 'tool-call';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type AgentStreamingParserCallbacks = {
|
||||
onTextDelta?: (message: string) => void;
|
||||
onToolCall?: (message: string) => void;
|
||||
onError?: (error: Error, rawLine: string) => void;
|
||||
};
|
||||
|
||||
export const parseAgentStreamingChunk = (
|
||||
chunk: string,
|
||||
callbacks: AgentStreamingParserCallbacks,
|
||||
): void => {
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() !== '') {
|
||||
try {
|
||||
const event = JSON.parse(line) as AgentStreamingEvent;
|
||||
|
||||
switch (event.type) {
|
||||
case 'text-delta':
|
||||
callbacks.onTextDelta?.(event.message);
|
||||
break;
|
||||
case 'tool-call':
|
||||
callbacks.onToolCall?.(event.message);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to parse stream event:', error);
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(`Unknown parsing error: ${String(error)}`);
|
||||
callbacks.onError?.(errorMessage, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user