mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
Restructure agent chat messages with parts-based architecture (#14749)
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
parent
84cbd8e092
commit
2685f4a5b9
69 changed files with 1200 additions and 1539 deletions
|
|
@ -29,6 +29,7 @@
|
|||
"workerDirectory": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^2.0.51",
|
||||
"@apollo/client": "^3.7.17",
|
||||
"@blocknote/mantine": "^0.31.1",
|
||||
"@blocknote/react": "^0.31.1",
|
||||
|
|
@ -67,6 +68,7 @@
|
|||
"@tiptap/extensions": "^3.4.2",
|
||||
"@tiptap/react": "^3.4.2",
|
||||
"@xyflow/react": "^12.4.2",
|
||||
"ai": "^5.0.49",
|
||||
"apollo-link-rest": "^0.9.0",
|
||||
"apollo-upload-client": "^17.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
|
|
|
|||
|
|
@ -71,11 +71,40 @@ export type AgentChatMessage = {
|
|||
createdAt: Scalars['DateTime'];
|
||||
files: Array<File>;
|
||||
id: Scalars['UUID'];
|
||||
rawContent?: Maybe<Scalars['String']>;
|
||||
parts: Array<AgentChatMessagePart>;
|
||||
role: Scalars['String'];
|
||||
threadId: Scalars['UUID'];
|
||||
};
|
||||
|
||||
export type AgentChatMessagePart = {
|
||||
__typename?: 'AgentChatMessagePart';
|
||||
createdAt: Scalars['DateTime'];
|
||||
errorDetails?: Maybe<Scalars['JSON']>;
|
||||
errorMessage?: Maybe<Scalars['String']>;
|
||||
fileFilename?: Maybe<Scalars['String']>;
|
||||
fileMediaType?: Maybe<Scalars['String']>;
|
||||
fileUrl?: Maybe<Scalars['String']>;
|
||||
id: Scalars['UUID'];
|
||||
messageId: Scalars['UUID'];
|
||||
orderIndex: Scalars['Int'];
|
||||
providerMetadata?: Maybe<Scalars['JSON']>;
|
||||
reasoningContent?: Maybe<Scalars['String']>;
|
||||
sourceDocumentFilename?: Maybe<Scalars['String']>;
|
||||
sourceDocumentMediaType?: Maybe<Scalars['String']>;
|
||||
sourceDocumentSourceId?: Maybe<Scalars['String']>;
|
||||
sourceDocumentTitle?: Maybe<Scalars['String']>;
|
||||
sourceUrlSourceId?: Maybe<Scalars['String']>;
|
||||
sourceUrlTitle?: Maybe<Scalars['String']>;
|
||||
sourceUrlUrl?: Maybe<Scalars['String']>;
|
||||
state?: Maybe<Scalars['String']>;
|
||||
textContent?: Maybe<Scalars['String']>;
|
||||
toolCallId?: Maybe<Scalars['String']>;
|
||||
toolInput?: Maybe<Scalars['JSON']>;
|
||||
toolName?: Maybe<Scalars['String']>;
|
||||
toolOutput?: Maybe<Scalars['JSON']>;
|
||||
type: Scalars['String'];
|
||||
};
|
||||
|
||||
export type AgentChatThread = {
|
||||
__typename?: 'AgentChatThread';
|
||||
agentId: Scalars['UUID'];
|
||||
|
|
@ -1203,6 +1232,7 @@ export type File = {
|
|||
};
|
||||
|
||||
export enum FileFolder {
|
||||
AgentChat = 'AgentChat',
|
||||
Attachment = 'Attachment',
|
||||
File = 'File',
|
||||
PersonPicture = 'PersonPicture',
|
||||
|
|
@ -4308,7 +4338,7 @@ export type GetAgentChatMessagesQueryVariables = Exact<{
|
|||
}>;
|
||||
|
||||
|
||||
export type GetAgentChatMessagesQuery = { __typename?: 'Query', agentChatMessages: Array<{ __typename?: 'AgentChatMessage', id: string, threadId: string, role: string, createdAt: string, rawContent?: string | null, files: Array<{ __typename?: 'File', id: string, name: string, fullPath: string, size: number, type: string, createdAt: string }> }> };
|
||||
export type GetAgentChatMessagesQuery = { __typename?: 'Query', agentChatMessages: Array<{ __typename?: 'AgentChatMessage', id: string, threadId: string, role: string, createdAt: string, parts: Array<{ __typename?: 'AgentChatMessagePart', id: string, messageId: string, orderIndex: number, type: string, textContent?: string | null, reasoningContent?: string | null, toolName?: string | null, toolCallId?: string | null, toolInput?: any | null, toolOutput?: any | null, state?: string | null, errorMessage?: string | null, errorDetails?: any | null, sourceUrlSourceId?: string | null, sourceUrlUrl?: string | null, sourceUrlTitle?: string | null, sourceDocumentSourceId?: string | null, sourceDocumentMediaType?: string | null, sourceDocumentTitle?: string | null, sourceDocumentFilename?: string | null, fileMediaType?: string | null, fileFilename?: string | null, fileUrl?: string | null, providerMetadata?: any | null, createdAt: string }>, files: Array<{ __typename?: 'File', id: string, name: string, fullPath: string, size: number, type: string, createdAt: string }> }> };
|
||||
|
||||
export type GetAgentChatThreadsQueryVariables = Exact<{
|
||||
agentId: Scalars['UUID'];
|
||||
|
|
@ -6568,7 +6598,33 @@ export const GetAgentChatMessagesDocument = gql`
|
|||
threadId
|
||||
role
|
||||
createdAt
|
||||
rawContent
|
||||
parts {
|
||||
id
|
||||
messageId
|
||||
orderIndex
|
||||
type
|
||||
textContent
|
||||
reasoningContent
|
||||
toolName
|
||||
toolCallId
|
||||
toolInput
|
||||
toolOutput
|
||||
state
|
||||
errorMessage
|
||||
errorDetails
|
||||
sourceUrlSourceId
|
||||
sourceUrlUrl
|
||||
sourceUrlTitle
|
||||
sourceDocumentSourceId
|
||||
sourceDocumentMediaType
|
||||
sourceDocumentTitle
|
||||
sourceDocumentFilename
|
||||
fileMediaType
|
||||
fileFilename
|
||||
fileUrl
|
||||
providerMetadata
|
||||
createdAt
|
||||
}
|
||||
files {
|
||||
id
|
||||
name
|
||||
|
|
|
|||
|
|
@ -71,11 +71,40 @@ export type AgentChatMessage = {
|
|||
createdAt: Scalars['DateTime'];
|
||||
files: Array<File>;
|
||||
id: Scalars['UUID'];
|
||||
rawContent?: Maybe<Scalars['String']>;
|
||||
parts: Array<AgentChatMessagePart>;
|
||||
role: Scalars['String'];
|
||||
threadId: Scalars['UUID'];
|
||||
};
|
||||
|
||||
export type AgentChatMessagePart = {
|
||||
__typename?: 'AgentChatMessagePart';
|
||||
createdAt: Scalars['DateTime'];
|
||||
errorDetails?: Maybe<Scalars['JSON']>;
|
||||
errorMessage?: Maybe<Scalars['String']>;
|
||||
fileFilename?: Maybe<Scalars['String']>;
|
||||
fileMediaType?: Maybe<Scalars['String']>;
|
||||
fileUrl?: Maybe<Scalars['String']>;
|
||||
id: Scalars['UUID'];
|
||||
messageId: Scalars['UUID'];
|
||||
orderIndex: Scalars['Int'];
|
||||
providerMetadata?: Maybe<Scalars['JSON']>;
|
||||
reasoningContent?: Maybe<Scalars['String']>;
|
||||
sourceDocumentFilename?: Maybe<Scalars['String']>;
|
||||
sourceDocumentMediaType?: Maybe<Scalars['String']>;
|
||||
sourceDocumentSourceId?: Maybe<Scalars['String']>;
|
||||
sourceDocumentTitle?: Maybe<Scalars['String']>;
|
||||
sourceUrlSourceId?: Maybe<Scalars['String']>;
|
||||
sourceUrlTitle?: Maybe<Scalars['String']>;
|
||||
sourceUrlUrl?: Maybe<Scalars['String']>;
|
||||
state?: Maybe<Scalars['String']>;
|
||||
textContent?: Maybe<Scalars['String']>;
|
||||
toolCallId?: Maybe<Scalars['String']>;
|
||||
toolInput?: Maybe<Scalars['JSON']>;
|
||||
toolName?: Maybe<Scalars['String']>;
|
||||
toolOutput?: Maybe<Scalars['JSON']>;
|
||||
type: Scalars['String'];
|
||||
};
|
||||
|
||||
export type AgentChatThread = {
|
||||
__typename?: 'AgentChatThread';
|
||||
agentId: Scalars['UUID'];
|
||||
|
|
@ -1167,6 +1196,7 @@ export type File = {
|
|||
};
|
||||
|
||||
export enum FileFolder {
|
||||
AgentChat = 'AgentChat',
|
||||
Attachment = 'Attachment',
|
||||
File = 'File',
|
||||
PersonPicture = 'PersonPicture',
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
import { ErrorStepRenderer } from '@/ai/components/ErrorStepRenderer';
|
||||
import { ReasoningSummaryDisplay } from '@/ai/components/ReasoningSummaryDisplay';
|
||||
import { ToolStepRenderer } from '@/ai/components/ToolStepRenderer';
|
||||
import type { ParsedStep } from '@/ai/types/ParsedStep';
|
||||
import { hasStructuredStreamData } from '@/ai/utils/hasStructuredStreamData';
|
||||
import { parseStream } from '@/ai/utils/parseStream';
|
||||
import { IconDotsVertical } from 'twenty-ui/display';
|
||||
|
||||
import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer';
|
||||
import { agentStreamingMessageState } from '@/ai/states/agentStreamingMessageState';
|
||||
import { ToolStepRenderer } from '@/ai/components/ToolStepRenderer';
|
||||
import { type ToolInput } from '@/ai/types/ToolInput';
|
||||
import { type ToolOutput } from '@/ai/types/ToolOutput';
|
||||
import { keyframes, useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { ToolUIPart, UIDataTypes, UIMessagePart, UITools } from 'ai';
|
||||
|
||||
const StyledStepsContainer = styled.div`
|
||||
display: flex;
|
||||
|
|
@ -60,60 +57,55 @@ const LoadingDotsIcon = () => {
|
|||
};
|
||||
|
||||
export const AIChatAssistantMessageRenderer = ({
|
||||
streamData,
|
||||
messageParts,
|
||||
isLastMessageStreaming,
|
||||
}: {
|
||||
streamData: string;
|
||||
messageParts: UIMessagePart<UIDataTypes, UITools>[];
|
||||
isLastMessageStreaming: boolean;
|
||||
}) => {
|
||||
const agentStreamingMessage = useRecoilValue(agentStreamingMessageState);
|
||||
const isStreaming = streamData === agentStreamingMessage;
|
||||
|
||||
if (!streamData) {
|
||||
return <LoadingDotsIcon />;
|
||||
}
|
||||
|
||||
const hasStructuredData = hasStructuredStreamData(streamData);
|
||||
|
||||
if (!hasStructuredData) {
|
||||
return <LazyMarkdownRenderer text={streamData} />;
|
||||
}
|
||||
|
||||
const steps = parseStream(streamData);
|
||||
|
||||
if (!steps.length) {
|
||||
return <LoadingDotsIcon />;
|
||||
}
|
||||
|
||||
const renderStep = (step: ParsedStep, index: number) => {
|
||||
const renderStep = (
|
||||
step: UIMessagePart<UIDataTypes, UITools>,
|
||||
index: number,
|
||||
) => {
|
||||
switch (step.type) {
|
||||
case 'tool':
|
||||
return <ToolStepRenderer key={index} events={step.events} />;
|
||||
case 'reasoning':
|
||||
return (
|
||||
<ReasoningSummaryDisplay
|
||||
key={index}
|
||||
content={step.content}
|
||||
isThinking={step.isThinking}
|
||||
content={step.text}
|
||||
isThinking={step.state === 'streaming'}
|
||||
/>
|
||||
);
|
||||
case 'text':
|
||||
return <LazyMarkdownRenderer key={index} text={step.content} />;
|
||||
case 'error':
|
||||
return (
|
||||
<ErrorStepRenderer
|
||||
key={index}
|
||||
message={step.message}
|
||||
error={step.error}
|
||||
/>
|
||||
);
|
||||
return <LazyMarkdownRenderer key={index} text={step.text} />;
|
||||
default:
|
||||
{
|
||||
if (step.type.includes('tool-')) {
|
||||
const { output, input, type } = step as ToolUIPart;
|
||||
return (
|
||||
<ToolStepRenderer
|
||||
key={index}
|
||||
input={input as ToolInput}
|
||||
output={output as ToolOutput}
|
||||
toolName={type.split('-')[1]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!messageParts.length) {
|
||||
return <LoadingDotsIcon />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StyledStepsContainer>{steps.map(renderStep)}</StyledStepsContainer>
|
||||
{isStreaming && <StyledToolCallContainer />}
|
||||
<StyledStepsContainer>
|
||||
{messageParts.map(renderStep)}
|
||||
</StyledStepsContainer>
|
||||
{isLastMessageStreaming && <StyledToolCallContainer />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import { Avatar, IconSparkles } from 'twenty-ui/display';
|
|||
|
||||
import { AgentChatFilePreview } from '@/ai/components/internal/AgentChatFilePreview';
|
||||
import { AgentChatMessageRole } from '@/ai/constants/AgentChatMessageRole';
|
||||
import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton';
|
||||
|
||||
import { AIChatAssistantMessageRenderer } from '@/ai/components/AIChatAssistantMessageRenderer';
|
||||
import { type AgentChatMessage } from '~/generated/graphql';
|
||||
import { type UIMessageWithMetadata } from '@/ai/types/UIMessageWithMetadata';
|
||||
import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
|
||||
const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
|
||||
|
|
@ -74,7 +74,8 @@ const StyledMessageText = styled.div<{ isUser?: boolean }>`
|
|||
}
|
||||
|
||||
p {
|
||||
margin: ${({ theme }) => theme.spacing(1)} 0;
|
||||
margin-block: ${({ isUser, theme }) =>
|
||||
isUser ? '0' : `${theme.spacing(1)}`};
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
|
@ -137,10 +138,10 @@ const StyledFilesContainer = styled.div`
|
|||
|
||||
export const AIChatMessage = ({
|
||||
message,
|
||||
agentStreamingMessage,
|
||||
isLastMessageStreaming,
|
||||
}: {
|
||||
message: AgentChatMessage;
|
||||
agentStreamingMessage: string;
|
||||
message: UIMessageWithMetadata;
|
||||
isLastMessageStreaming: boolean;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { localeCatalog } = useRecoilValue(dateLocaleState);
|
||||
|
|
@ -170,30 +171,33 @@ export const AIChatMessage = ({
|
|||
<StyledMessageText
|
||||
isUser={message.role === AgentChatMessageRole.USER}
|
||||
>
|
||||
{message.role === AgentChatMessageRole.ASSISTANT ? (
|
||||
<AIChatAssistantMessageRenderer
|
||||
streamData={message.rawContent || agentStreamingMessage}
|
||||
/>
|
||||
) : (
|
||||
message.rawContent
|
||||
)}
|
||||
<AIChatAssistantMessageRenderer
|
||||
isLastMessageStreaming={isLastMessageStreaming}
|
||||
messageParts={message.parts}
|
||||
/>
|
||||
</StyledMessageText>
|
||||
{message.files.length > 0 && (
|
||||
{message.parts.length > 0 && (
|
||||
<StyledFilesContainer>
|
||||
{message.files.map((file) => (
|
||||
<AgentChatFilePreview key={file.id} file={file} />
|
||||
))}
|
||||
{message.parts
|
||||
.filter((part) => part.type === 'file')
|
||||
.map((file) => (
|
||||
<AgentChatFilePreview key={file.filename} file={file} />
|
||||
))}
|
||||
</StyledFilesContainer>
|
||||
)}
|
||||
{message.rawContent && (
|
||||
{message.parts.length > 0 && message.metadata?.createdAt && (
|
||||
<StyledMessageFooter className="message-footer">
|
||||
<span>
|
||||
{beautifyPastDateRelativeToNow(
|
||||
message.createdAt,
|
||||
message.metadata?.createdAt,
|
||||
localeCatalog,
|
||||
)}
|
||||
</span>
|
||||
<LightCopyIconButton copyText={message.rawContent} />
|
||||
<LightCopyIconButton
|
||||
copyText={
|
||||
message.parts.find((part) => part.type === 'text')?.text ?? ''
|
||||
}
|
||||
/>
|
||||
</StyledMessageFooter>
|
||||
)}
|
||||
</StyledMessageContainer>
|
||||
|
|
|
|||
|
|
@ -14,10 +14,8 @@ import { AIChatMessage } from '@/ai/components/AIChatMessage';
|
|||
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
|
||||
import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview';
|
||||
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
|
||||
import { SendMessageWithRecordsContextButton } from '@/ai/components/internal/SendMessageWithRecordsContextButton';
|
||||
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
|
||||
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { type UIMessageWithMetadata } from '@/ai/types/UIMessageWithMetadata';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useState } from 'react';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
|
|
@ -61,24 +59,23 @@ const StyledButtonsContainer = styled.div`
|
|||
export const AIChatTab = ({
|
||||
agentId,
|
||||
isWorkflowAgentNodeChat,
|
||||
uiMessages,
|
||||
}: {
|
||||
agentId: string;
|
||||
isWorkflowAgentNodeChat?: boolean;
|
||||
uiMessages: UIMessageWithMetadata[];
|
||||
}) => {
|
||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||
|
||||
const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValue(
|
||||
contextStoreCurrentObjectMetadataItemIdComponentState,
|
||||
);
|
||||
|
||||
const {
|
||||
messages,
|
||||
isLoading,
|
||||
input,
|
||||
handleInputChange,
|
||||
agentStreamingMessage,
|
||||
scrollWrapperId,
|
||||
} = useAgentChat(agentId);
|
||||
messages,
|
||||
handleSendMessage,
|
||||
isStreaming,
|
||||
} = useAgentChat(agentId, uiMessages);
|
||||
const { uploadFiles } = useAIChatFileUpload({ agentId });
|
||||
|
||||
const { createAgentChatThread } = useCreateNewAIChatThread({ agentId });
|
||||
|
|
@ -101,14 +98,17 @@ export const AIChatTab = ({
|
|||
<StyledScrollWrapper componentInstanceId={scrollWrapperId}>
|
||||
{messages.map((message) => (
|
||||
<AIChatMessage
|
||||
agentStreamingMessage={agentStreamingMessage}
|
||||
isLastMessageStreaming={
|
||||
isStreaming &&
|
||||
message.id === messages[messages.length - 1].id
|
||||
}
|
||||
message={message}
|
||||
key={message.id}
|
||||
/>
|
||||
))}
|
||||
</StyledScrollWrapper>
|
||||
)}
|
||||
{messages.length === 0 && !isLoading && <AIChatEmptyState />}
|
||||
{messages.length === 0 && <AIChatEmptyState />}
|
||||
{isLoading && messages.length === 0 && <AIChatSkeletonLoader />}
|
||||
|
||||
<StyledInputArea>
|
||||
|
|
@ -145,11 +145,12 @@ export const AIChatTab = ({
|
|||
</>
|
||||
)}
|
||||
<AgentChatFileUploadButton agentId={agentId} />
|
||||
{contextStoreCurrentObjectMetadataItemId ? (
|
||||
<SendMessageWithRecordsContextButton agentId={agentId} />
|
||||
) : (
|
||||
<SendMessageButton agentId={agentId} />
|
||||
)}
|
||||
<SendMessageButton
|
||||
agentId={agentId}
|
||||
handleSendMessage={handleSendMessage}
|
||||
input={input}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</StyledButtonsContainer>
|
||||
</StyledInputArea>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -6,12 +6,9 @@ import { IconChevronDown, IconChevronUp } from 'twenty-ui/display';
|
|||
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
|
||||
|
||||
import { ShimmeringText } from '@/ai/components/ShimmeringText';
|
||||
import { type ToolInput } from '@/ai/types/ToolInput';
|
||||
import { type ToolOutput } from '@/ai/types/ToolOutput';
|
||||
import { getToolIcon } from '@/ai/utils/getToolIcon';
|
||||
import type {
|
||||
ToolCallEvent,
|
||||
ToolEvent,
|
||||
ToolResultEvent,
|
||||
} from 'twenty-shared/ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
|
|
@ -70,32 +67,26 @@ const StyledIconTextContainer = styled.div`
|
|||
}
|
||||
`;
|
||||
|
||||
export const ToolStepRenderer = ({ events }: { events: ToolEvent[] }) => {
|
||||
export const ToolStepRenderer = ({
|
||||
input,
|
||||
output,
|
||||
toolName,
|
||||
}: {
|
||||
input: ToolInput;
|
||||
output: ToolOutput;
|
||||
toolName: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const toolCallEvent = events[0] as ToolCallEvent | undefined;
|
||||
const toolResultEvent = events.find(
|
||||
(event): event is ToolResultEvent => event.type === 'tool-result',
|
||||
);
|
||||
const isExpandable = isDefined(output);
|
||||
|
||||
if (!toolCallEvent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolOutput =
|
||||
toolResultEvent?.output?.error ?? toolResultEvent?.output?.result;
|
||||
|
||||
const isExpandable = isDefined(toolOutput);
|
||||
|
||||
if (!toolResultEvent) {
|
||||
if (!output) {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledLoadingContainer>
|
||||
<ShimmeringText>
|
||||
<StyledDisplayMessage>
|
||||
{toolCallEvent.input.loadingMessage}
|
||||
</StyledDisplayMessage>
|
||||
<StyledDisplayMessage>{input?.loadingMessage}</StyledDisplayMessage>
|
||||
</ShimmeringText>
|
||||
</StyledLoadingContainer>
|
||||
</StyledContainer>
|
||||
|
|
@ -103,13 +94,16 @@ export const ToolStepRenderer = ({ events }: { events: ToolEvent[] }) => {
|
|||
}
|
||||
|
||||
const displayMessage =
|
||||
toolResultEvent?.output &&
|
||||
typeof toolResultEvent.output === 'object' &&
|
||||
'message' in toolResultEvent.output
|
||||
? (toolResultEvent.output as { message: string }).message
|
||||
output && typeof output === 'object' && 'message' in output
|
||||
? (output as { message: string }).message
|
||||
: undefined;
|
||||
|
||||
const ToolIcon = getToolIcon(toolCallEvent.toolName);
|
||||
const result =
|
||||
output && typeof output === 'object' && 'result' in output
|
||||
? (output as { result: string }).result
|
||||
: output;
|
||||
|
||||
const ToolIcon = getToolIcon(toolName);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
|
|
@ -132,7 +126,7 @@ export const ToolStepRenderer = ({ events }: { events: ToolEvent[] }) => {
|
|||
{isExpandable && (
|
||||
<AnimatedExpandableContainer isExpanded={isExpanded}>
|
||||
<StyledContentContainer>
|
||||
<StyledPre>{JSON.stringify(toolOutput, null, 2)}</StyledPre>
|
||||
<StyledPre>{JSON.stringify(result, null, 2)}</StyledPre>
|
||||
</StyledContentContainer>
|
||||
</AnimatedExpandableContainer>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,9 @@ import { AgentChatContextRecordPreview } from '@/ai/components/internal/AgentCha
|
|||
import { agentChatSelectedFilesComponentState } from '@/ai/states/agentChatSelectedFilesComponentState';
|
||||
import { agentChatUploadedFilesComponentState } from '@/ai/states/agentChatUploadedFilesComponentState';
|
||||
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useDeleteFileMutation } from '~/generated-metadata/graphql';
|
||||
import { AgentChatFilePreview } from './AgentChatFilePreview';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
|
|
@ -25,31 +22,15 @@ const StyledPreviewsContainer = styled.div`
|
|||
`;
|
||||
|
||||
export const AgentChatContextPreview = ({ agentId }: { agentId: string }) => {
|
||||
const { t } = useLingui();
|
||||
const [agentChatSelectedFiles, setAgentChatSelectedFiles] =
|
||||
useRecoilComponentState(agentChatSelectedFilesComponentState, agentId);
|
||||
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
|
||||
useRecoilComponentState(agentChatUploadedFilesComponentState, agentId);
|
||||
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const [deleteFile] = useDeleteFileMutation();
|
||||
|
||||
const handleRemoveUploadedFile = async (fileId: string) => {
|
||||
const originalFiles = agentChatUploadedFiles;
|
||||
|
||||
const handleRemoveUploadedFile = async (fileIndex: number) => {
|
||||
setAgentChatUploadedFiles(
|
||||
agentChatUploadedFiles.filter((f) => f.id !== fileId),
|
||||
agentChatUploadedFiles.filter((f, index) => fileIndex !== index),
|
||||
);
|
||||
|
||||
try {
|
||||
await deleteFile({ variables: { fileId } });
|
||||
} catch {
|
||||
setAgentChatUploadedFiles(originalFiles);
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to remove file`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValue(
|
||||
|
|
@ -71,11 +52,11 @@ export const AgentChatContextPreview = ({ agentId }: { agentId: string }) => {
|
|||
isUploading
|
||||
/>
|
||||
))}
|
||||
{agentChatUploadedFiles.map((file) => (
|
||||
{agentChatUploadedFiles.map((file, index) => (
|
||||
<AgentChatFilePreview
|
||||
file={file}
|
||||
key={file.id}
|
||||
onRemove={() => handleRemoveUploadedFile(file.id)}
|
||||
key={index}
|
||||
onRemove={() => handleRemoveUploadedFile(index)}
|
||||
isUploading={false}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,37 @@
|
|||
import { getFileType } from '@/activities/files/utils/getFileType';
|
||||
import { IconMapping, useFileTypeColors } from '@/file/utils/fileIconMappings';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { type FileUIPart } from 'ai';
|
||||
import { AvatarChip, Chip, ChipVariant } from 'twenty-ui/components';
|
||||
import { IconX } from 'twenty-ui/display';
|
||||
import { Loader } from 'twenty-ui/feedback';
|
||||
import { type File as FileDocument } from '~/generated-metadata/graphql';
|
||||
|
||||
export const AgentChatFilePreview = ({
|
||||
file,
|
||||
onRemove,
|
||||
isUploading,
|
||||
}: {
|
||||
file: File | FileDocument;
|
||||
file: FileUIPart | File;
|
||||
onRemove?: () => void;
|
||||
isUploading?: boolean;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const iconColors = useFileTypeColors();
|
||||
|
||||
const fileName =
|
||||
file instanceof File ? file.name : (file.filename ?? 'Unknown file');
|
||||
|
||||
return (
|
||||
<Chip
|
||||
label={file.name}
|
||||
label={fileName}
|
||||
variant={ChipVariant.Static}
|
||||
leftComponent={
|
||||
isUploading ? (
|
||||
<Loader color="yellow" />
|
||||
) : (
|
||||
<AvatarChip
|
||||
Icon={IconMapping[getFileType(file.name)]}
|
||||
IconBackgroundColor={iconColors[getFileType(file.name)]}
|
||||
Icon={IconMapping[getFileType(fileName)]}
|
||||
IconBackgroundColor={iconColors[getFileType(fileName)]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,16 @@
|
|||
import { Button } from 'twenty-ui/input';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useAgentChat } from '@/ai/hooks/useAgentChat';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
|
||||
export const SendMessageButton = ({
|
||||
records,
|
||||
agentId,
|
||||
handleSendMessage,
|
||||
input,
|
||||
isLoading,
|
||||
}: {
|
||||
agentId: string;
|
||||
records?: ObjectRecord[];
|
||||
handleSendMessage: () => void;
|
||||
input: string;
|
||||
isLoading: boolean;
|
||||
}) => {
|
||||
const { isLoading, handleSendMessage, input } = useAgentChat(
|
||||
agentId,
|
||||
records,
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
|
||||
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
|
||||
|
||||
export const SendMessageWithRecordsContextButton = ({
|
||||
agentId,
|
||||
}: {
|
||||
agentId: string;
|
||||
}) => {
|
||||
const { records } = useFindManyRecordsSelectedInContextStore({
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
return <SendMessageButton records={records} agentId={agentId} />;
|
||||
};
|
||||
|
|
@ -7,7 +7,33 @@ export const GET_AGENT_CHAT_MESSAGES = gql`
|
|||
threadId
|
||||
role
|
||||
createdAt
|
||||
rawContent
|
||||
parts {
|
||||
id
|
||||
messageId
|
||||
orderIndex
|
||||
type
|
||||
textContent
|
||||
reasoningContent
|
||||
toolName
|
||||
toolCallId
|
||||
toolInput
|
||||
toolOutput
|
||||
state
|
||||
errorMessage
|
||||
errorDetails
|
||||
sourceUrlSourceId
|
||||
sourceUrlUrl
|
||||
sourceUrlTitle
|
||||
sourceDocumentSourceId
|
||||
sourceDocumentMediaType
|
||||
sourceDocumentTitle
|
||||
sourceDocumentFilename
|
||||
fileMediaType
|
||||
fileFilename
|
||||
fileUrl
|
||||
providerMetadata
|
||||
createdAt
|
||||
}
|
||||
files {
|
||||
id
|
||||
name
|
||||
|
|
|
|||
|
|
@ -4,15 +4,15 @@ import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient
|
|||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
type File as FileDocument,
|
||||
useCreateFileMutation,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { type FileUIPart } from 'ai';
|
||||
import { buildSignedPath, isDefined } from 'twenty-shared/utils';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import { useUploadFileMutation } from '~/generated-metadata/graphql';
|
||||
import { FileFolder } from '~/generated/graphql';
|
||||
|
||||
export const useAIChatFileUpload = ({ agentId }: { agentId: string }) => {
|
||||
const coreClient = useApolloCoreClient();
|
||||
const [createFile] = useCreateFileMutation({ client: coreClient });
|
||||
const [uploadFile] = useUploadFileMutation({ client: coreClient });
|
||||
const { t } = useLingui();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const [agentChatSelectedFiles, setAgentChatSelectedFiles] =
|
||||
|
|
@ -20,23 +20,35 @@ export const useAIChatFileUpload = ({ agentId }: { agentId: string }) => {
|
|||
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
|
||||
useRecoilComponentState(agentChatUploadedFilesComponentState, agentId);
|
||||
|
||||
const sendFile = async (file: File) => {
|
||||
const sendFile = async (file: File): Promise<FileUIPart | null> => {
|
||||
try {
|
||||
const result = await createFile({
|
||||
const result = await uploadFile({
|
||||
variables: {
|
||||
file,
|
||||
fileFolder: FileFolder.AgentChat,
|
||||
},
|
||||
});
|
||||
|
||||
const uploadedFile = result?.data?.createFile;
|
||||
const response = result?.data?.uploadFile;
|
||||
|
||||
if (!isDefined(uploadedFile)) {
|
||||
if (!isDefined(response)) {
|
||||
throw new Error(t`Couldn't upload the file.`);
|
||||
}
|
||||
|
||||
const signedPath = buildSignedPath({
|
||||
path: response.path,
|
||||
token: response.token,
|
||||
});
|
||||
|
||||
setAgentChatSelectedFiles(
|
||||
agentChatSelectedFiles.filter((f) => f.name !== file.name),
|
||||
);
|
||||
return uploadedFile;
|
||||
return {
|
||||
filename: file.name,
|
||||
mediaType: file.type,
|
||||
url: `${REACT_APP_SERVER_BASE_URL}/files/${signedPath}`,
|
||||
type: 'file',
|
||||
};
|
||||
} catch {
|
||||
const fileName = file.name;
|
||||
enqueueErrorSnackBar({
|
||||
|
|
@ -51,7 +63,7 @@ export const useAIChatFileUpload = ({ agentId }: { agentId: string }) => {
|
|||
files.map((file) => sendFile(file)),
|
||||
);
|
||||
|
||||
const successfulUploads = uploadResults.reduce<FileDocument[]>(
|
||||
const successfulUploads = uploadResults.reduce<FileUIPart[]>(
|
||||
(acc, result) => {
|
||||
if (result.status === 'fulfilled' && isDefined(result.value)) {
|
||||
acc.push(result.value);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
import { AgentChatMessageRole } from '@/ai/constants/AgentChatMessageRole';
|
||||
import { STREAM_CHAT_QUERY } from '@/ai/rest-api/agent-chat-apollo.api';
|
||||
import {
|
||||
type AIChatObjectMetadataAndRecordContext,
|
||||
agentChatObjectMetadataAndRecordContextState,
|
||||
|
|
@ -13,36 +10,35 @@ import { agentChatSelectedFilesComponentState } from '@/ai/states/agentChatSelec
|
|||
import { agentChatUploadedFilesComponentState } from '@/ai/states/agentChatUploadedFilesComponentState';
|
||||
import { currentAIChatThreadComponentState } from '@/ai/states/currentAIChatThreadComponentState';
|
||||
import { isAgentChatCurrentContextActiveState } from '@/ai/states/isAgentChatCurrentContextActiveState';
|
||||
import { type UIMessageWithMetadata } from '@/ai/types/UIMessageWithMetadata';
|
||||
import { getTokenPair } from '@/apollo/utils/getTokenPair';
|
||||
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
|
||||
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
|
||||
import { useGetObjectMetadataItemById } from '@/object-metadata/hooks/useGetObjectMetadataItemById';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { DefaultChatTransport } from 'ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v4 } from 'uuid';
|
||||
import {
|
||||
useGetAgentChatMessagesQuery,
|
||||
useGetAgentChatThreadsQuery,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { type AgentChatMessage } from '~/generated/graphql';
|
||||
import { REST_API_BASE_URL } from '../../apollo/constant/rest-api-base-url';
|
||||
import { agentChatInputState } from '../states/agentChatInputState';
|
||||
import { agentChatMessagesComponentState } from '../states/agentChatMessagesComponentState';
|
||||
import { agentStreamingMessageState } from '../states/agentStreamingMessageState';
|
||||
|
||||
type OptimisticMessage = AgentChatMessage & {
|
||||
isPending: boolean;
|
||||
};
|
||||
|
||||
export const useAgentChat = (agentId: string, records?: ObjectRecord[]) => {
|
||||
const apolloClient = useApolloClient();
|
||||
export const useAgentChat = (
|
||||
agentId: string,
|
||||
uiMessages: UIMessageWithMetadata[],
|
||||
) => {
|
||||
const { getObjectMetadataItemById } = useGetObjectMetadataItemById();
|
||||
|
||||
const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValue(
|
||||
contextStoreCurrentObjectMetadataItemIdComponentState,
|
||||
);
|
||||
|
||||
const { records } = useFindManyRecordsSelectedInContextStore({
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const isAgentChatCurrentContextActive = useRecoilComponentValue(
|
||||
isAgentChatCurrentContextActiveState,
|
||||
agentId,
|
||||
|
|
@ -58,32 +54,40 @@ export const useAgentChat = (agentId: string, records?: ObjectRecord[]) => {
|
|||
agentId,
|
||||
);
|
||||
|
||||
const [currentThreadId, setCurrentThreadId] = useRecoilComponentState(
|
||||
const currentThreadId = useRecoilComponentValue(
|
||||
currentAIChatThreadComponentState,
|
||||
agentId,
|
||||
);
|
||||
|
||||
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
|
||||
useRecoilComponentState(agentChatUploadedFilesComponentState, agentId);
|
||||
|
||||
const [agentChatMessages, setAgentChatMessages] = useRecoilComponentState(
|
||||
agentChatMessagesComponentState,
|
||||
agentId,
|
||||
);
|
||||
|
||||
const [agentChatInput, setAgentChatInput] =
|
||||
useRecoilState(agentChatInputState);
|
||||
|
||||
const [agentStreamingMessage, setAgentStreamingMessage] = useRecoilState(
|
||||
agentStreamingMessageState,
|
||||
);
|
||||
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
|
||||
const scrollWrapperId = `scroll-wrapper-ai-chat-${agentId}`;
|
||||
|
||||
const { scrollWrapperHTMLElement } =
|
||||
useScrollWrapperHTMLElement(scrollWrapperId);
|
||||
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const { sendMessage, messages, status, error } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: `${REST_API_BASE_URL}/agent-chat/stream`,
|
||||
headers: () => ({
|
||||
Authorization: `Bearer ${getTokenPair()?.accessOrWorkspaceAgnosticToken.token}`,
|
||||
}),
|
||||
}),
|
||||
messages: uiMessages,
|
||||
id: currentThreadId as string,
|
||||
onError: (error) => {
|
||||
enqueueErrorSnackBar({ message: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const isStreaming = status === 'streaming';
|
||||
|
||||
const scrollToBottom = () => {
|
||||
scrollWrapperHTMLElement?.scroll({
|
||||
top: scrollWrapperHTMLElement.scrollHeight,
|
||||
|
|
@ -91,64 +95,10 @@ export const useAgentChat = (agentId: string, records?: ObjectRecord[]) => {
|
|||
});
|
||||
};
|
||||
|
||||
const { loading: threadsLoading } = useGetAgentChatThreadsQuery({
|
||||
variables: { agentId },
|
||||
skip: isDefined(currentThreadId),
|
||||
onCompleted: (data) => {
|
||||
if (data.agentChatThreads.length > 0) {
|
||||
setCurrentThreadId(data.agentChatThreads[0].id);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { loading: messagesLoading, refetch: refetchMessages } =
|
||||
useGetAgentChatMessagesQuery({
|
||||
variables: { threadId: currentThreadId as string },
|
||||
skip: !isDefined(currentThreadId),
|
||||
onCompleted: ({ agentChatMessages }) => {
|
||||
setAgentChatMessages(agentChatMessages);
|
||||
scrollToBottom();
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading =
|
||||
messagesLoading ||
|
||||
threadsLoading ||
|
||||
!currentThreadId ||
|
||||
isStreaming ||
|
||||
agentChatSelectedFiles.length > 0;
|
||||
|
||||
const createOptimisticMessages = (content: string): AgentChatMessage[] => {
|
||||
const optimisticUserMessage: OptimisticMessage = {
|
||||
id: v4(),
|
||||
threadId: currentThreadId as string,
|
||||
role: AgentChatMessageRole.USER,
|
||||
rawContent: content,
|
||||
createdAt: new Date().toISOString(),
|
||||
isPending: true,
|
||||
files: agentChatUploadedFiles,
|
||||
};
|
||||
|
||||
const optimisticAiMessage: OptimisticMessage = {
|
||||
id: v4(),
|
||||
threadId: currentThreadId as string,
|
||||
role: AgentChatMessageRole.ASSISTANT,
|
||||
rawContent: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
isPending: true,
|
||||
files: [],
|
||||
};
|
||||
|
||||
return [optimisticUserMessage, optimisticAiMessage];
|
||||
};
|
||||
|
||||
const streamAgentResponse = async (content: string) => {
|
||||
if (!currentThreadId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
setIsStreaming(true);
|
||||
!currentThreadId || isStreaming || agentChatSelectedFiles.length > 0;
|
||||
|
||||
const sendChatMessage = async (content: string) => {
|
||||
const recordIdsByObjectMetadataNameSingular = [];
|
||||
|
||||
if (
|
||||
|
|
@ -164,47 +114,21 @@ export const useAgentChat = (agentId: string, records?: ObjectRecord[]) => {
|
|||
});
|
||||
}
|
||||
|
||||
await apolloClient.query({
|
||||
query: STREAM_CHAT_QUERY,
|
||||
variables: {
|
||||
requestBody: {
|
||||
sendMessage(
|
||||
{
|
||||
text: content,
|
||||
files: agentChatUploadedFiles,
|
||||
},
|
||||
{
|
||||
body: {
|
||||
threadId: currentThreadId,
|
||||
userMessage: content,
|
||||
fileIds: agentChatUploadedFiles.map((file) => file.id),
|
||||
recordIdsByObjectMetadataNameSingular:
|
||||
recordIdsByObjectMetadataNameSingular,
|
||||
recordIdsByObjectMetadataNameSingular,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
onChunk: (chunk: string) => {
|
||||
setAgentStreamingMessage((prev) => prev + chunk);
|
||||
scrollToBottom();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setIsStreaming(false);
|
||||
};
|
||||
|
||||
const sendChatMessage = async (content: string) => {
|
||||
const optimisticMessages = createOptimisticMessages(content);
|
||||
|
||||
setAgentChatMessages((prevMessages) => [
|
||||
...prevMessages,
|
||||
...optimisticMessages,
|
||||
]);
|
||||
);
|
||||
|
||||
setAgentChatUploadedFiles([]);
|
||||
|
||||
setTimeout(scrollToBottom, 100);
|
||||
|
||||
await streamAgentResponse(content);
|
||||
|
||||
const { data } = await refetchMessages();
|
||||
|
||||
setAgentChatMessages(data?.agentChatMessages);
|
||||
setAgentStreamingMessage('');
|
||||
scrollToBottom();
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
|
|
@ -239,13 +163,14 @@ export const useAgentChat = (agentId: string, records?: ObjectRecord[]) => {
|
|||
|
||||
return {
|
||||
handleInputChange: (value: string) => setAgentChatInput(value),
|
||||
messages: agentChatMessages,
|
||||
messages,
|
||||
input: agentChatInput,
|
||||
context: agentChatContext,
|
||||
handleSetContext,
|
||||
handleSendMessage,
|
||||
isLoading,
|
||||
agentStreamingMessage,
|
||||
scrollWrapperId,
|
||||
isStreaming,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_AGENT_CHAT_THREADS = gql`
|
||||
query GetAgentChatThreads($agentId: String!) {
|
||||
threads(agentId: $agentId)
|
||||
@rest(
|
||||
type: "AgentChatThread"
|
||||
path: "/agent-chat/threads/{args.agentId}"
|
||||
) {
|
||||
id
|
||||
agentId
|
||||
title
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_AGENT_CHAT_MESSAGES = gql`
|
||||
query GetAgentChatMessages($threadId: String!) {
|
||||
messages(threadId: $threadId)
|
||||
@rest(
|
||||
type: "AgentChatMessage"
|
||||
path: "/agent-chat/threads/{args.threadId}/messages"
|
||||
) {
|
||||
id
|
||||
threadId
|
||||
role
|
||||
content
|
||||
createdAt
|
||||
files
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const STREAM_CHAT_QUERY = gql`
|
||||
query StreamChatResponse($requestBody: JSON!) {
|
||||
streamChatResponse(requestBody: $requestBody)
|
||||
@stream(
|
||||
type: "StreamChatResponse"
|
||||
path: "/agent-chat/stream"
|
||||
method: "POST"
|
||||
bodyKey: "requestBody"
|
||||
)
|
||||
}
|
||||
`;
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { AgentChatMessagesComponentInstanceContext } from '@/ai/states/agentChatMessagesComponentState';
|
||||
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
|
||||
import { type File } from '~/generated-metadata/graphql';
|
||||
import { type FileUIPart } from 'ai';
|
||||
|
||||
export const agentChatUploadedFilesComponentState = createComponentState<
|
||||
File[]
|
||||
FileUIPart[]
|
||||
>({
|
||||
key: 'agentChatUploadedFilesComponentState',
|
||||
defaultValue: [],
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
import { atom } from 'recoil';
|
||||
|
||||
export const agentStreamingMessageState = atom<string>({
|
||||
key: 'agentStreamingMessageState',
|
||||
default: '',
|
||||
});
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import type { ToolEvent } from 'twenty-shared/ai';
|
||||
|
||||
export type ParsedStep =
|
||||
| { type: 'tool'; events: ToolEvent[] }
|
||||
| { type: 'reasoning'; content: string; isThinking: boolean }
|
||||
| { type: 'text'; content: string }
|
||||
| { type: 'error'; message: string; error?: unknown };
|
||||
4
packages/twenty-front/src/modules/ai/types/ToolInput.ts
Normal file
4
packages/twenty-front/src/modules/ai/types/ToolInput.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export type ToolInput = {
|
||||
loadingMessage: string;
|
||||
input: Record<string, unknown>;
|
||||
};
|
||||
1
packages/twenty-front/src/modules/ai/types/ToolOutput.ts
Normal file
1
packages/twenty-front/src/modules/ai/types/ToolOutput.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type ToolOutput = Record<string, unknown>;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { type UIMessage } from 'ai';
|
||||
|
||||
export type UIMessageWithMetadata = UIMessage & {
|
||||
metadata: {
|
||||
createdAt: string;
|
||||
};
|
||||
};
|
||||
|
|
@ -1,440 +0,0 @@
|
|||
import { parseStream } from '../parseStream';
|
||||
|
||||
describe('parseStream', () => {
|
||||
describe('tool-call events', () => {
|
||||
it('should parse tool-call event correctly', () => {
|
||||
const streamText = JSON.stringify({
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call-123',
|
||||
toolName: 'send_email',
|
||||
args: {
|
||||
loadingMessage: 'Sending email...',
|
||||
input: { to: 'test@example.com' },
|
||||
},
|
||||
});
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'tool',
|
||||
events: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call-123',
|
||||
toolName: 'send_email',
|
||||
args: {
|
||||
loadingMessage: 'Sending email...',
|
||||
input: { to: 'test@example.com' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool-result events', () => {
|
||||
it('should parse tool-result event and append to existing tool entry', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call-123',
|
||||
toolName: 'send_email',
|
||||
args: { loadingMessage: 'Sending email...', input: {} },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-123',
|
||||
toolName: 'send_email',
|
||||
result: { success: true, result: 'Email sent', message: 'Success' },
|
||||
message: 'Email sent successfully',
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'tool',
|
||||
events: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call-123',
|
||||
toolName: 'send_email',
|
||||
args: { loadingMessage: 'Sending email...', input: {} },
|
||||
},
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-123',
|
||||
toolName: 'send_email',
|
||||
result: { success: true, result: 'Email sent', message: 'Success' },
|
||||
message: 'Email sent successfully',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create new tool entry for orphaned tool-result', () => {
|
||||
const streamText = JSON.stringify({
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-456',
|
||||
toolName: 'http_request',
|
||||
result: {
|
||||
success: true,
|
||||
result: 'Response received',
|
||||
message: 'Success',
|
||||
},
|
||||
message: 'Request completed',
|
||||
});
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'tool',
|
||||
events: [
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-456',
|
||||
toolName: 'http_request',
|
||||
result: {
|
||||
success: true,
|
||||
result: 'Response received',
|
||||
message: 'Success',
|
||||
},
|
||||
message: 'Request completed',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reasoning events', () => {
|
||||
it('should parse reasoning events correctly', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({ type: 'reasoning-start' }),
|
||||
JSON.stringify({
|
||||
type: 'reasoning-delta',
|
||||
text: 'Let me think about this...',
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'reasoning-delta',
|
||||
text: ' I need to consider the options.',
|
||||
}),
|
||||
JSON.stringify({ type: 'reasoning-end' }),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'reasoning',
|
||||
content: 'Let me think about this... I need to consider the options.',
|
||||
isThinking: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle reasoning without end as thinking', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({ type: 'reasoning-start' }),
|
||||
JSON.stringify({
|
||||
type: 'reasoning-delta',
|
||||
text: 'Still thinking...',
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'reasoning',
|
||||
content: 'Still thinking...',
|
||||
isThinking: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should concatenate multiple reasoning deltas', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({ type: 'reasoning-start' }),
|
||||
JSON.stringify({ type: 'reasoning-delta', text: 'First part' }),
|
||||
JSON.stringify({ type: 'reasoning-delta', text: ' second part' }),
|
||||
JSON.stringify({ type: 'reasoning-delta', text: ' third part' }),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'reasoning',
|
||||
content: 'First part second part third part',
|
||||
isThinking: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('text-delta events', () => {
|
||||
it('should parse text-delta events correctly', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({ type: 'text-delta', text: 'Hello, ' }),
|
||||
JSON.stringify({ type: 'text-delta', text: 'world!' }),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'text',
|
||||
content: 'Hello, world!',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty text', () => {
|
||||
const streamText = JSON.stringify({ type: 'text-delta', text: '' });
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'text',
|
||||
content: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error events', () => {
|
||||
it('should parse error events correctly', () => {
|
||||
const streamText = JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Something went wrong',
|
||||
error: { code: 'TIMEOUT', details: 'Request timed out' },
|
||||
});
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'error',
|
||||
message: 'Something went wrong',
|
||||
error: { code: 'TIMEOUT', details: 'Request timed out' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle error without error details', () => {
|
||||
const streamText = JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Generic error',
|
||||
});
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'error',
|
||||
message: 'Generic error',
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default message when none provided', () => {
|
||||
const streamText = JSON.stringify({
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'error',
|
||||
message: 'An error occurred',
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('step-finish events', () => {
|
||||
it('should flush current text block on step-finish', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({ type: 'text-delta', text: 'Some text' }),
|
||||
JSON.stringify({ type: 'step-finish' }),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'text',
|
||||
content: 'Some text',
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark reasoning as not thinking on step-finish', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({ type: 'reasoning-start' }),
|
||||
JSON.stringify({ type: 'reasoning-delta', text: 'Thinking...' }),
|
||||
JSON.stringify({ type: 'step-finish' }),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'reasoning',
|
||||
content: 'Thinking...',
|
||||
isThinking: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed events', () => {
|
||||
it('should handle mixed event types correctly', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({ type: 'text-delta', text: 'Starting...' }),
|
||||
JSON.stringify({
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'send_email',
|
||||
args: { loadingMessage: 'Sending...', input: {} },
|
||||
}),
|
||||
JSON.stringify({ type: 'reasoning-start' }),
|
||||
JSON.stringify({ type: 'reasoning-delta', text: 'Let me think...' }),
|
||||
JSON.stringify({
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'send_email',
|
||||
result: { success: true, message: 'Done' },
|
||||
message: 'Email sent',
|
||||
}),
|
||||
JSON.stringify({ type: 'text-delta', text: 'Finished!' }),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[0]).toEqual({ type: 'text', content: 'Starting...' });
|
||||
expect(result[1]).toEqual({
|
||||
type: 'tool',
|
||||
events: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'send_email',
|
||||
args: { loadingMessage: 'Sending...', input: {} },
|
||||
},
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'send_email',
|
||||
result: { success: true, message: 'Done' },
|
||||
message: 'Email sent',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result[2]).toEqual({
|
||||
type: 'reasoning',
|
||||
content: 'Let me think...',
|
||||
isThinking: true,
|
||||
});
|
||||
expect(result[3]).toEqual({
|
||||
type: 'text',
|
||||
content: 'Finished!',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty stream', () => {
|
||||
const result = parseStream('');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle whitespace-only stream', () => {
|
||||
const result = parseStream(' \n \t ');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should skip invalid JSON lines', () => {
|
||||
const streamText = [
|
||||
'invalid json line',
|
||||
JSON.stringify({ type: 'text-delta', text: 'Valid content' }),
|
||||
'another invalid line',
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'text',
|
||||
content: 'Valid content',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unknown event types', () => {
|
||||
const streamText = JSON.stringify({
|
||||
type: 'unknown-event',
|
||||
data: 'some data',
|
||||
});
|
||||
|
||||
const result = parseStream(streamText);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should flush remaining text block at end', () => {
|
||||
const streamText = JSON.stringify({
|
||||
type: 'text-delta',
|
||||
text: 'Unflushed content',
|
||||
});
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'text',
|
||||
content: 'Unflushed content',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('text block transitions', () => {
|
||||
it('should create new text block when switching from reasoning to text', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({ type: 'reasoning-start' }),
|
||||
JSON.stringify({ type: 'reasoning-delta', text: 'Thinking...' }),
|
||||
JSON.stringify({ type: 'text-delta', text: 'Speaking...' }),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'reasoning',
|
||||
content: 'Thinking...',
|
||||
isThinking: true,
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
type: 'text',
|
||||
content: 'Speaking...',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create new reasoning block when switching from text to reasoning', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({ type: 'text-delta', text: 'Speaking...' }),
|
||||
JSON.stringify({ type: 'reasoning-start' }),
|
||||
JSON.stringify({ type: 'reasoning-delta', text: 'Thinking...' }),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'text',
|
||||
content: 'Speaking...',
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
type: 'reasoning',
|
||||
content: 'Thinking...',
|
||||
isThinking: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { type UIMessageWithMetadata } from '@/ai/types/UIMessageWithMetadata';
|
||||
import { mapDBPartToUIMessagePart } from '@/ai/utils/mapDBPartToUIMessagePart';
|
||||
import { type AgentChatMessage } from '~/generated/graphql';
|
||||
|
||||
export const mapDBMessagesToUIMessages = (
|
||||
dbMessages: AgentChatMessage[],
|
||||
): UIMessageWithMetadata[] => {
|
||||
return dbMessages.map((dbMessage) => ({
|
||||
id: dbMessage.id,
|
||||
role: dbMessage.role as UIMessageWithMetadata['role'],
|
||||
parts: dbMessage.parts.map(mapDBPartToUIMessagePart),
|
||||
metadata: {
|
||||
createdAt: dbMessage.createdAt,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import {
|
||||
type ReasoningUIPart,
|
||||
type ToolUIPart,
|
||||
type UIMessagePart,
|
||||
type UITool,
|
||||
} from 'ai';
|
||||
import { type AgentChatMessagePart } from '~/generated/graphql';
|
||||
|
||||
export const mapDBPartToUIMessagePart = (
|
||||
part: AgentChatMessagePart,
|
||||
): UIMessagePart<never, Record<string, UITool>> => {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return {
|
||||
type: 'text',
|
||||
text: part.textContent!,
|
||||
};
|
||||
case 'reasoning':
|
||||
return {
|
||||
type: 'reasoning',
|
||||
text: part.reasoningContent!,
|
||||
state: part.state as ReasoningUIPart['state'],
|
||||
};
|
||||
case 'file':
|
||||
return {
|
||||
type: 'file',
|
||||
mediaType: part.fileMediaType!,
|
||||
filename: part.fileFilename!,
|
||||
url: part.fileUrl!,
|
||||
};
|
||||
case 'source-url':
|
||||
return {
|
||||
type: 'source-url',
|
||||
sourceId: part.sourceUrlSourceId!,
|
||||
url: part.sourceUrlUrl!,
|
||||
title: part.sourceUrlTitle!,
|
||||
providerMetadata: part.providerMetadata ?? undefined,
|
||||
};
|
||||
case 'source-document':
|
||||
return {
|
||||
type: 'source-document',
|
||||
sourceId: part.sourceDocumentSourceId!,
|
||||
mediaType: part.sourceDocumentMediaType!,
|
||||
title: part.sourceDocumentTitle!,
|
||||
filename: part.sourceDocumentFilename!,
|
||||
providerMetadata: part.providerMetadata ?? undefined,
|
||||
};
|
||||
case 'step-start':
|
||||
return {
|
||||
type: 'step-start',
|
||||
};
|
||||
default:
|
||||
{
|
||||
if (part.type.includes('tool-') === true) {
|
||||
return {
|
||||
type: part.type as `tool-${string}`,
|
||||
toolCallId: part.toolCallId!,
|
||||
input: part.toolInput,
|
||||
output: part.toolOutput,
|
||||
errorText: part.errorMessage!,
|
||||
state: part.state,
|
||||
} as ToolUIPart;
|
||||
}
|
||||
}
|
||||
throw new Error(`Unsupported part type: ${part.type}`);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
import {
|
||||
parseStreamLine,
|
||||
splitStreamIntoLines,
|
||||
type ErrorEvent,
|
||||
type ReasoningDeltaEvent,
|
||||
type TextBlock,
|
||||
type TextDeltaEvent,
|
||||
type ToolCallEvent,
|
||||
type ToolEvent,
|
||||
type ToolResultEvent,
|
||||
} from 'twenty-shared/ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import type { ParsedStep } from '@/ai/types/ParsedStep';
|
||||
|
||||
const handleToolCall = (
|
||||
event: ToolCallEvent,
|
||||
output: ParsedStep[],
|
||||
flushTextBlock: () => void,
|
||||
) => {
|
||||
flushTextBlock();
|
||||
output.push({
|
||||
type: 'tool',
|
||||
events: [event],
|
||||
});
|
||||
};
|
||||
|
||||
const handleToolResult = (
|
||||
event: ToolResultEvent,
|
||||
output: ParsedStep[],
|
||||
flushTextBlock: () => void,
|
||||
) => {
|
||||
flushTextBlock();
|
||||
|
||||
const toolEntry = output.find(
|
||||
(item): item is { type: 'tool'; events: ToolEvent[] } =>
|
||||
item.type === 'tool' &&
|
||||
item.events.some(
|
||||
(e) => e.type === 'tool-call' && e.toolCallId === event.toolCallId,
|
||||
),
|
||||
);
|
||||
|
||||
if (isDefined(toolEntry)) {
|
||||
toolEntry.events.push(event);
|
||||
} else {
|
||||
output.push({
|
||||
type: 'tool',
|
||||
events: [event],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleReasoningStart = (flushTextBlock: () => void): TextBlock => {
|
||||
flushTextBlock();
|
||||
return {
|
||||
type: 'reasoning',
|
||||
content: '',
|
||||
isThinking: true,
|
||||
};
|
||||
};
|
||||
|
||||
const handleReasoningDelta = (
|
||||
event: ReasoningDeltaEvent,
|
||||
currentTextBlock: TextBlock,
|
||||
flushTextBlock: () => void,
|
||||
): TextBlock => {
|
||||
if (!currentTextBlock || currentTextBlock.type !== 'reasoning') {
|
||||
flushTextBlock();
|
||||
return {
|
||||
type: 'reasoning',
|
||||
content: event.text || '',
|
||||
isThinking: true,
|
||||
};
|
||||
}
|
||||
currentTextBlock.content += event.text || '';
|
||||
return currentTextBlock;
|
||||
};
|
||||
|
||||
const handleReasoningEnd = (currentTextBlock: TextBlock): TextBlock => {
|
||||
if (currentTextBlock?.type === 'reasoning') {
|
||||
return {
|
||||
...currentTextBlock,
|
||||
isThinking: false,
|
||||
};
|
||||
}
|
||||
return currentTextBlock;
|
||||
};
|
||||
|
||||
const handleTextDelta = (
|
||||
event: TextDeltaEvent,
|
||||
currentTextBlock: TextBlock,
|
||||
flushTextBlock: () => void,
|
||||
): TextBlock => {
|
||||
if (!currentTextBlock || currentTextBlock.type !== 'text') {
|
||||
flushTextBlock();
|
||||
return { type: 'text', content: event.text || '' };
|
||||
}
|
||||
currentTextBlock.content += event.text || '';
|
||||
return currentTextBlock;
|
||||
};
|
||||
|
||||
const handleStepFinish = (
|
||||
currentTextBlock: TextBlock,
|
||||
flushTextBlock: () => void,
|
||||
): TextBlock => {
|
||||
if (currentTextBlock?.type === 'reasoning') {
|
||||
currentTextBlock.isThinking = false;
|
||||
}
|
||||
flushTextBlock();
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleError = (
|
||||
event: ErrorEvent,
|
||||
output: ParsedStep[],
|
||||
flushTextBlock: () => void,
|
||||
) => {
|
||||
flushTextBlock();
|
||||
output.push({
|
||||
type: 'error',
|
||||
message: event.message || 'An error occurred',
|
||||
error: event.error,
|
||||
});
|
||||
};
|
||||
|
||||
export const parseStream = (streamText: string): ParsedStep[] => {
|
||||
const lines = splitStreamIntoLines(streamText);
|
||||
const output: ParsedStep[] = [];
|
||||
let currentTextBlock: TextBlock = null;
|
||||
|
||||
const flushTextBlock = () => {
|
||||
if (isDefined(currentTextBlock)) {
|
||||
output.push(currentTextBlock);
|
||||
currentTextBlock = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const event = parseStreamLine(line);
|
||||
if (!event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'tool-call':
|
||||
handleToolCall(event, output, flushTextBlock);
|
||||
break;
|
||||
|
||||
case 'tool-result':
|
||||
handleToolResult(event, output, flushTextBlock);
|
||||
break;
|
||||
|
||||
case 'reasoning-start':
|
||||
currentTextBlock = handleReasoningStart(flushTextBlock);
|
||||
break;
|
||||
|
||||
case 'reasoning-delta':
|
||||
currentTextBlock = handleReasoningDelta(
|
||||
event,
|
||||
currentTextBlock,
|
||||
flushTextBlock,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'reasoning-end':
|
||||
currentTextBlock = handleReasoningEnd(currentTextBlock);
|
||||
break;
|
||||
|
||||
case 'text-delta':
|
||||
currentTextBlock = handleTextDelta(
|
||||
event,
|
||||
currentTextBlock,
|
||||
flushTextBlock,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'step-finish':
|
||||
currentTextBlock = handleStepFinish(currentTextBlock, flushTextBlock);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
handleError(event, output, flushTextBlock);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
flushTextBlock();
|
||||
return output;
|
||||
};
|
||||
|
|
@ -1,7 +1,16 @@
|
|||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { AIChatTab } from '@/ai/components/AIChatTab';
|
||||
import { currentAIChatThreadComponentState } from '@/ai/states/currentAIChatThreadComponentState';
|
||||
import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
useGetAgentChatMessagesQuery,
|
||||
useGetAgentChatThreadsQuery,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
height: 100%;
|
||||
|
|
@ -21,17 +30,45 @@ export const CommandMenuAskAIPage = () => {
|
|||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
const agentId = currentWorkspace?.defaultAgent?.id;
|
||||
|
||||
if (!agentId) {
|
||||
const [currentThreadId, setCurrentThreadId] = useRecoilComponentState(
|
||||
currentAIChatThreadComponentState,
|
||||
agentId,
|
||||
);
|
||||
|
||||
const { loading: threadsLoading } = useGetAgentChatThreadsQuery({
|
||||
variables: { agentId: agentId! },
|
||||
skip: isDefined(currentThreadId) || !isDefined(agentId),
|
||||
onCompleted: (data) => {
|
||||
if (data.agentChatThreads.length > 0) {
|
||||
setCurrentThreadId(data.agentChatThreads[0].id);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { loading, data } = useGetAgentChatMessagesQuery({
|
||||
variables: { threadId: currentThreadId as string },
|
||||
skip: !isDefined(currentThreadId),
|
||||
});
|
||||
|
||||
const isLoading = loading || !currentThreadId || threadsLoading;
|
||||
|
||||
if (!agentId || isLoading) {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledEmptyState>No AI Agent found.</StyledEmptyState>
|
||||
<StyledEmptyState>
|
||||
{isLoading ? t`Loading...` : t`No AI Agent found.`}
|
||||
</StyledEmptyState>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<AIChatTab agentId={agentId} />
|
||||
<AIChatTab
|
||||
agentId={agentId}
|
||||
key={currentThreadId}
|
||||
uiMessages={mapDBMessagesToUIMessages(data?.agentChatMessages || [])}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
13
packages/twenty-server/@types/common.d.ts
vendored
13
packages/twenty-server/@types/common.d.ts
vendored
|
|
@ -1,13 +0,0 @@
|
|||
type DeepPartial<T> = {
|
||||
[K in keyof T]?: T[K] extends Array<infer R>
|
||||
? Array<DeepPartial<R>>
|
||||
: DeepPartial<T[K]>;
|
||||
};
|
||||
|
||||
type ExcludeFunctions<T> = T extends Function ? never : T;
|
||||
|
||||
/**
|
||||
* Wrapper type used to circumvent ESM modules circular dependency issue
|
||||
* caused by reflection metadata saving the type of the property.
|
||||
*/
|
||||
type CircularDep<T> = T;
|
||||
|
|
@ -3,6 +3,7 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
|||
import { Command } from 'nest-commander';
|
||||
import { type ViewFilterOperand as SharedViewFilterOperand } from 'twenty-shared/types';
|
||||
import { DataSource, In, Repository, type QueryRunner } from 'typeorm';
|
||||
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import {
|
||||
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
|
||||
|
|
@ -477,7 +478,7 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat
|
|||
viewName = 'All {objectLabelPlural}';
|
||||
}
|
||||
|
||||
const coreView: Partial<ViewEntity> = {
|
||||
const coreView: QueryDeepPartialEntity<ViewEntity> = {
|
||||
id: workspaceView.id,
|
||||
name: viewName,
|
||||
objectMetadataId: workspaceView.objectMetadataId,
|
||||
|
|
@ -517,7 +518,7 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat
|
|||
queryRunner: QueryRunner,
|
||||
): Promise<void> {
|
||||
for (const field of workspaceViewFields) {
|
||||
const coreViewField: Partial<ViewFieldEntity> = {
|
||||
const coreViewField: QueryDeepPartialEntity<ViewFieldEntity> = {
|
||||
id: field.id,
|
||||
fieldMetadataId: field.fieldMetadataId,
|
||||
viewId: field.viewId,
|
||||
|
|
@ -549,7 +550,7 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat
|
|||
continue;
|
||||
}
|
||||
|
||||
const coreViewFilter: Partial<ViewFilterEntity> = {
|
||||
const coreViewFilter: QueryDeepPartialEntity<ViewFilterEntity> = {
|
||||
id: filter.id,
|
||||
fieldMetadataId: filter.fieldMetadataId,
|
||||
viewId: filter.viewId,
|
||||
|
|
@ -586,7 +587,7 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat
|
|||
|
||||
const direction = sort.direction.toUpperCase() as ViewSortDirection;
|
||||
|
||||
const coreViewSort: Partial<ViewSortEntity> = {
|
||||
const coreViewSort: QueryDeepPartialEntity<ViewSortEntity> = {
|
||||
id: sort.id,
|
||||
fieldMetadataId: sort.fieldMetadataId,
|
||||
viewId: sort.viewId,
|
||||
|
|
@ -616,7 +617,7 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat
|
|||
continue;
|
||||
}
|
||||
|
||||
const coreViewGroup: Partial<ViewGroupEntity> = {
|
||||
const coreViewGroup: QueryDeepPartialEntity<ViewGroupEntity> = {
|
||||
id: group.id,
|
||||
fieldMetadataId: group.fieldMetadataId,
|
||||
viewId: group.viewId,
|
||||
|
|
@ -644,20 +645,21 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat
|
|||
(a, b) =>
|
||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
)) {
|
||||
const coreViewFilterGroup: Partial<ViewFilterGroupEntity> = {
|
||||
id: filterGroup.id,
|
||||
viewId: filterGroup.viewId,
|
||||
logicalOperator:
|
||||
filterGroup.logicalOperator as ViewFilterGroupLogicalOperator,
|
||||
parentViewFilterGroupId: filterGroup.parentViewFilterGroupId,
|
||||
positionInViewFilterGroup: filterGroup.positionInViewFilterGroup,
|
||||
workspaceId,
|
||||
createdAt: new Date(filterGroup.createdAt),
|
||||
updatedAt: new Date(filterGroup.updatedAt),
|
||||
deletedAt: filterGroup.deletedAt
|
||||
? new Date(filterGroup.deletedAt)
|
||||
: null,
|
||||
};
|
||||
const coreViewFilterGroup: QueryDeepPartialEntity<ViewFilterGroupEntity> =
|
||||
{
|
||||
id: filterGroup.id,
|
||||
viewId: filterGroup.viewId,
|
||||
logicalOperator:
|
||||
filterGroup.logicalOperator as ViewFilterGroupLogicalOperator,
|
||||
parentViewFilterGroupId: filterGroup.parentViewFilterGroupId,
|
||||
positionInViewFilterGroup: filterGroup.positionInViewFilterGroup,
|
||||
workspaceId,
|
||||
createdAt: new Date(filterGroup.createdAt),
|
||||
updatedAt: new Date(filterGroup.updatedAt),
|
||||
deletedAt: filterGroup.deletedAt
|
||||
? new Date(filterGroup.deletedAt)
|
||||
: null,
|
||||
};
|
||||
|
||||
const repository = queryRunner.manager.getRepository(
|
||||
ViewFilterGroupEntity,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class NameOfYourMigration1758767315179 implements MigrationInterface {
|
||||
name = 'CreateAgentChatMessagePartTableAndRemoveRawContent1758767315179';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "core"."agentChatMessagePart" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "messageId" uuid NOT NULL, "orderIndex" integer NOT NULL, "type" character varying NOT NULL, "textContent" text, "reasoningContent" text, "toolName" character varying, "toolCallId" character varying, "toolInput" jsonb, "toolOutput" jsonb, "state" character varying, "errorMessage" text, "errorDetails" jsonb, "sourceUrlSourceId" character varying, "sourceUrlUrl" character varying, "sourceUrlTitle" character varying, "sourceDocumentSourceId" character varying, "sourceDocumentMediaType" character varying, "sourceDocumentTitle" character varying, "sourceDocumentFilename" character varying, "fileMediaType" character varying, "fileFilename" character varying, "fileUrl" character varying, "providerMetadata" jsonb, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_c28499bb0699730d41e57e1fe23" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_5d4b48eeebfa7b23cd2226a874" ON "core"."agentChatMessagePart" ("messageId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatMessage" DROP COLUMN "rawContent"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatMessagePart" ADD CONSTRAINT "FK_5d4b48eeebfa7b23cd2226a874f" FOREIGN KEY ("messageId") REFERENCES "core"."agentChatMessage"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatMessagePart" DROP CONSTRAINT "FK_5d4b48eeebfa7b23cd2226a874f"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatMessage" ADD "rawContent" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "core"."IDX_5d4b48eeebfa7b23cd2226a874"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "core"."agentChatMessagePart"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import {
|
|||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import {
|
||||
ApiKeyException,
|
||||
|
|
@ -83,7 +85,7 @@ export class ApiKeyResolver {
|
|||
@AuthWorkspace() workspace: Workspace,
|
||||
@Args('input') input: UpdateApiKeyDTO,
|
||||
): Promise<ApiKey | null> {
|
||||
const updateData: Partial<ApiKey> = {};
|
||||
const updateData: QueryDeepPartialEntity<ApiKey> = {};
|
||||
|
||||
if (input.name !== undefined) updateData.name = input.name;
|
||||
if (input.expiresAt !== undefined)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { DataSource, IsNull, Repository } from 'typeorm';
|
||||
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/api-key-role.service';
|
||||
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
|
|
@ -78,7 +79,7 @@ export class ApiKeyService {
|
|||
async update(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
updateData: Partial<ApiKey>,
|
||||
updateData: QueryDeepPartialEntity<ApiKey>,
|
||||
): Promise<ApiKey | null> {
|
||||
const apiKey = await this.findById(id, workspaceId);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import {
|
|||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
|
||||
import { type ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { ApiKeyService } from 'src/engine/core-modules/api-key/api-key.service';
|
||||
|
|
@ -65,7 +67,7 @@ export class ApiKeyController {
|
|||
@Body() updateApiKeyDto: UpdateApiKeyDTO,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<ApiKey | null> {
|
||||
const updateData: Partial<ApiKey> = {};
|
||||
const updateData: QueryDeepPartialEntity<ApiKey> = {};
|
||||
|
||||
if (updateApiKeyDto.name !== undefined)
|
||||
updateData.name = updateApiKeyDto.name;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export enum FileFolder {
|
|||
ServerlessFunction = 'serverless-function',
|
||||
ServerlessFunctionToDelete = 'serverless-function-to-delete',
|
||||
File = 'file',
|
||||
AgentChat = 'agent-chat',
|
||||
}
|
||||
|
||||
registerEnumType(FileFolder, {
|
||||
|
|
@ -42,6 +43,9 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
|
|||
[FileFolder.File]: {
|
||||
ignoreExpirationToken: false,
|
||||
},
|
||||
[FileFolder.AgentChat]: {
|
||||
ignoreExpirationToken: false,
|
||||
},
|
||||
};
|
||||
|
||||
export type AllowedFolders = KebabCase<keyof typeof FileFolder>;
|
||||
|
|
|
|||
|
|
@ -3,15 +3,16 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Repository } from 'typeorm';
|
||||
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
|
||||
import { PublicDomainDTO } from 'src/engine/core-modules/public-domain/dtos/public-domain.dto';
|
||||
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
|
||||
import {
|
||||
PublicDomainException,
|
||||
PublicDomainExceptionCode,
|
||||
} from 'src/engine/core-modules/public-domain/public-domain.exception';
|
||||
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Injectable()
|
||||
export class PublicDomainService {
|
||||
|
|
@ -90,7 +91,9 @@ export class PublicDomainService {
|
|||
});
|
||||
|
||||
try {
|
||||
await this.publicDomainRepository.insert(publicDomain);
|
||||
await this.publicDomainRepository.insert(
|
||||
publicDomain as QueryDeepPartialEntity<PublicDomain>,
|
||||
);
|
||||
} catch (error) {
|
||||
await this.dnsManagerService.deleteHostnameSilently(formattedDomain, {
|
||||
isPublicDomain: true,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import { CreateWebhookDTO } from 'src/engine/core-modules/webhook/dtos/create-webhook.dto';
|
||||
import { DeleteWebhookDTO } from 'src/engine/core-modules/webhook/dtos/delete-webhook.dto';
|
||||
import { GetWebhookDTO } from 'src/engine/core-modules/webhook/dtos/get-webhook.dto';
|
||||
|
|
@ -56,7 +58,7 @@ export class WebhookResolver {
|
|||
@Args('input') input: UpdateWebhookDTO,
|
||||
): Promise<Webhook | null> {
|
||||
try {
|
||||
const updateData: Partial<Webhook> = {};
|
||||
const updateData: QueryDeepPartialEntity<Webhook> = {};
|
||||
|
||||
if (input.targetUrl !== undefined) updateData.targetUrl = input.targetUrl;
|
||||
if (input.operations !== undefined)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { ArrayContains, IsNull, Repository } from 'typeorm';
|
||||
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import { Webhook } from './webhook.entity';
|
||||
import { WebhookException, WebhookExceptionCode } from './webhook.exception';
|
||||
|
|
@ -93,7 +94,7 @@ export class WebhookService {
|
|||
async update(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
updateData: Partial<Webhook>,
|
||||
updateData: QueryDeepPartialEntity<Webhook>,
|
||||
): Promise<Webhook | null> {
|
||||
const webhook = await this.findById(id, workspaceId);
|
||||
|
||||
|
|
@ -102,7 +103,9 @@ export class WebhookService {
|
|||
}
|
||||
|
||||
if (isDefined(updateData.targetUrl)) {
|
||||
const normalizedTargetUrl = this.normalizeTargetUrl(updateData.targetUrl);
|
||||
const normalizedTargetUrl = this.normalizeTargetUrl(
|
||||
updateData.targetUrl as string,
|
||||
);
|
||||
|
||||
if (!this.validateTargetUrl(normalizedTargetUrl)) {
|
||||
throw new WebhookException(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import { JSONValue } from 'ai';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Relation,
|
||||
} from 'typeorm';
|
||||
|
||||
import { AgentChatMessageEntity } from './agent-chat-message.entity';
|
||||
|
||||
@Entity('agentChatMessagePart')
|
||||
export class AgentChatMessagePartEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column('uuid')
|
||||
@Index()
|
||||
messageId: string;
|
||||
|
||||
@ManyToOne(() => AgentChatMessageEntity, (message) => message.parts, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'messageId' })
|
||||
message: Relation<AgentChatMessageEntity>;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
orderIndex: number;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
type: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
textContent: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
reasoningContent: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
toolName: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
toolCallId: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
toolInput: unknown | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
toolOutput: unknown | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
state: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
errorMessage: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
errorDetails: Record<string, unknown> | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
sourceUrlSourceId: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
sourceUrlUrl: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
sourceUrlTitle: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
sourceDocumentSourceId: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
sourceDocumentMediaType: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
sourceDocumentTitle: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
sourceDocumentFilename: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
fileMediaType: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
fileFilename: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
fileUrl: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
providerMetadata: Record<string, Record<string, JSONValue>> | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ import {
|
|||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/agent/agent-chat-thread.entity';
|
||||
|
||||
import { AgentChatMessagePartEntity } from './agent-chat-message-part.entity';
|
||||
|
||||
export enum AgentChatMessageRole {
|
||||
USER = 'user',
|
||||
ASSISTANT = 'assistant',
|
||||
|
|
@ -36,8 +38,8 @@ export class AgentChatMessageEntity {
|
|||
@Column({ type: 'enum', enum: AgentChatMessageRole })
|
||||
role: AgentChatMessageRole;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
rawContent: string | null;
|
||||
@OneToMany(() => AgentChatMessagePartEntity, (part) => part.message)
|
||||
parts: Relation<AgentChatMessagePartEntity[]>;
|
||||
|
||||
@OneToMany(() => FileEntity, (file) => file.message)
|
||||
files: Relation<FileEntity[]>;
|
||||
|
|
|
|||
|
|
@ -9,14 +9,15 @@ import {
|
|||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { UIDataTypes, UIMessage, UITools } from 'ai';
|
||||
import { Response } from 'express';
|
||||
|
||||
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
|
||||
|
||||
import { AgentChatService } from './agent-chat.service';
|
||||
|
|
@ -63,45 +64,21 @@ export class AgentChatController {
|
|||
@Body()
|
||||
body: {
|
||||
threadId: string;
|
||||
userMessage: string;
|
||||
fileIds?: string[];
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
recordIdsByObjectMetadataNameSingular?: RecordIdsByObjectMetadataNameSingularType;
|
||||
},
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Res() res: Response,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
try {
|
||||
await this.agentStreamingService.streamAgentChat({
|
||||
threadId: body.threadId,
|
||||
userMessage: body.userMessage,
|
||||
userWorkspaceId,
|
||||
workspace,
|
||||
fileIds: body.fileIds || [],
|
||||
recordIdsByObjectMetadataNameSingular:
|
||||
body.recordIdsByObjectMetadataNameSingular || [],
|
||||
res,
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle errors at controller level for streaming responses
|
||||
// since the RestApiExceptionFilter interferes with our streaming error handling
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.setHeader('Transfer-Encoding', 'chunked');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
}
|
||||
|
||||
res.write(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
}) + '\n',
|
||||
);
|
||||
|
||||
res.end();
|
||||
}
|
||||
this.agentStreamingService.streamAgentChat({
|
||||
threadId: body.threadId,
|
||||
messages: body.messages,
|
||||
userWorkspaceId,
|
||||
workspace,
|
||||
recordIdsByObjectMetadataNameSingular:
|
||||
body.recordIdsByObjectMetadataNameSingular || [],
|
||||
response,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,16 +3,19 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import type { UIDataTypes, UIMessage, UIMessagePart, UITools } from 'ai';
|
||||
|
||||
import { AgentChatMessagePartEntity } from 'src/engine/metadata-modules/agent/agent-chat-message-part.entity';
|
||||
import {
|
||||
AgentChatMessageEntity,
|
||||
type AgentChatMessageRole,
|
||||
AgentChatMessageRole,
|
||||
} from 'src/engine/metadata-modules/agent/agent-chat-message.entity';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/agent/agent-chat-thread.entity';
|
||||
import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/agent/agent.exception';
|
||||
import { mapUIMessagePartsToDBParts } from 'src/engine/metadata-modules/agent/utils/mapUIMessagePartsToDBParts';
|
||||
|
||||
import { AgentTitleGenerationService } from './agent-title-generation.service';
|
||||
|
||||
|
|
@ -23,8 +26,8 @@ export class AgentChatService {
|
|||
private readonly threadRepository: Repository<AgentChatThreadEntity>,
|
||||
@InjectRepository(AgentChatMessageEntity)
|
||||
private readonly messageRepository: Repository<AgentChatMessageEntity>,
|
||||
@InjectRepository(FileEntity)
|
||||
private readonly fileRepository: Repository<FileEntity>,
|
||||
@InjectRepository(AgentChatMessagePartEntity)
|
||||
private readonly messagePartRepository: Repository<AgentChatMessagePartEntity>,
|
||||
private readonly titleGenerationService: AgentTitleGenerationService,
|
||||
) {}
|
||||
|
||||
|
|
@ -67,32 +70,32 @@ export class AgentChatService {
|
|||
|
||||
async addMessage({
|
||||
threadId,
|
||||
role,
|
||||
rawContent,
|
||||
fileIds,
|
||||
uiMessage,
|
||||
}: {
|
||||
threadId: string;
|
||||
role: AgentChatMessageRole;
|
||||
rawContent: string | null;
|
||||
fileIds?: string[];
|
||||
uiMessage: Omit<UIMessage<unknown, UIDataTypes, UITools>, 'id'>;
|
||||
uiMessageParts?: UIMessagePart<UIDataTypes, UITools>[];
|
||||
}) {
|
||||
const message = this.messageRepository.create({
|
||||
threadId,
|
||||
role,
|
||||
rawContent,
|
||||
role: uiMessage.role as AgentChatMessageRole,
|
||||
});
|
||||
|
||||
const savedMessage = await this.messageRepository.save(message);
|
||||
|
||||
if (fileIds && fileIds.length > 0) {
|
||||
for (const fileId of fileIds) {
|
||||
await this.fileRepository.update(fileId, {
|
||||
messageId: savedMessage.id,
|
||||
});
|
||||
}
|
||||
if (uiMessage.parts && uiMessage.parts.length > 0) {
|
||||
const dbParts = mapUIMessagePartsToDBParts(
|
||||
uiMessage.parts,
|
||||
savedMessage.id,
|
||||
);
|
||||
|
||||
await this.messagePartRepository.save(dbParts);
|
||||
}
|
||||
|
||||
this.generateTitleIfNeeded(threadId, rawContent);
|
||||
this.generateTitleIfNeeded(
|
||||
threadId,
|
||||
uiMessage.parts.find((part) => part.type === 'text')?.text,
|
||||
);
|
||||
|
||||
return savedMessage;
|
||||
}
|
||||
|
|
@ -115,13 +118,13 @@ export class AgentChatService {
|
|||
return this.messageRepository.find({
|
||||
where: { threadId },
|
||||
order: { createdAt: 'ASC' },
|
||||
relations: ['files'],
|
||||
relations: ['parts', 'files'],
|
||||
});
|
||||
}
|
||||
|
||||
private async generateTitleIfNeeded(
|
||||
threadId: string,
|
||||
messageContent: string | null,
|
||||
messageContent?: string | null,
|
||||
) {
|
||||
const thread = await this.threadRepository.findOne({
|
||||
where: { id: threadId },
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import {
|
||||
type FilePart,
|
||||
type ImagePart,
|
||||
convertToModelMessages,
|
||||
LanguageModelUsage,
|
||||
type ModelMessage,
|
||||
stepCountIs,
|
||||
streamText,
|
||||
ToolSet,
|
||||
type UserContent,
|
||||
UserModelMessage,
|
||||
UIDataTypes,
|
||||
UIMessage,
|
||||
UITools,
|
||||
} from 'ai';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { getAppPath } from 'twenty-shared/utils';
|
||||
|
|
@ -20,20 +20,13 @@ import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-m
|
|||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { extractFolderPathAndFilename } from 'src/engine/core-modules/file/utils/extract-folderpath-and-filename.utils';
|
||||
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import {
|
||||
type AgentChatMessageEntity,
|
||||
AgentChatMessageRole,
|
||||
} from 'src/engine/metadata-modules/agent/agent-chat-message.entity';
|
||||
import { AgentHandoffToolService } from 'src/engine/metadata-modules/agent/agent-handoff-tool.service';
|
||||
import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-config.const';
|
||||
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constants/agent-system-prompts.const';
|
||||
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
|
||||
import { constructAssistantMessageContentFromStream } from 'src/engine/metadata-modules/agent/utils/constructAssistantMessageContentFromStream';
|
||||
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
import { AgentToolGeneratorService } from './agent-tool-generator.service';
|
||||
import { AgentEntity } from './agent.entity';
|
||||
|
|
@ -70,7 +63,7 @@ export class AgentExecutionService {
|
|||
}: {
|
||||
system: string;
|
||||
agent: AgentEntity | null;
|
||||
messages: ModelMessage[];
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
}) {
|
||||
try {
|
||||
if (agent) {
|
||||
|
|
@ -106,8 +99,8 @@ export class AgentExecutionService {
|
|||
system,
|
||||
tools,
|
||||
model: registeredModel.model,
|
||||
messages,
|
||||
maxSteps: AGENT_CONFIG.MAX_STEPS,
|
||||
messages: convertToModelMessages(messages),
|
||||
stopWhen: stepCountIs(AGENT_CONFIG.MAX_STEPS),
|
||||
...(registeredModel.doesSupportThinking && {
|
||||
providerOptions: {
|
||||
anthropic: {
|
||||
|
|
@ -128,39 +121,6 @@ export class AgentExecutionService {
|
|||
}
|
||||
}
|
||||
|
||||
private async buildUserMessageWithFiles(
|
||||
fileIds: string[],
|
||||
): Promise<(ImagePart | FilePart)[]> {
|
||||
const files = await this.fileRepository.find({
|
||||
where: {
|
||||
id: In(fileIds),
|
||||
},
|
||||
});
|
||||
|
||||
return await Promise.all(files.map((file) => this.createFilePart(file)));
|
||||
}
|
||||
|
||||
private async buildUserMessage(
|
||||
userMessage: string,
|
||||
fileIds: string[],
|
||||
): Promise<UserModelMessage> {
|
||||
const content: Exclude<UserContent, string> = [
|
||||
{
|
||||
type: 'text',
|
||||
text: userMessage,
|
||||
},
|
||||
];
|
||||
|
||||
if (fileIds.length !== 0) {
|
||||
content.push(...(await this.buildUserMessageWithFiles(fileIds)));
|
||||
}
|
||||
|
||||
return {
|
||||
role: AgentChatMessageRole.USER,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
private async getContextForSystemPrompt(
|
||||
workspace: Workspace,
|
||||
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType,
|
||||
|
|
@ -225,119 +185,72 @@ export class AgentExecutionService {
|
|||
return JSON.stringify(contextObject);
|
||||
}
|
||||
|
||||
private async createFilePart(
|
||||
file: FileEntity,
|
||||
): Promise<ImagePart | FilePart> {
|
||||
const { folderPath, filename } = extractFolderPathAndFilename(
|
||||
file.fullPath,
|
||||
);
|
||||
const fileStream = await this.fileService.getFileStream(
|
||||
folderPath,
|
||||
filename,
|
||||
file.workspaceId,
|
||||
);
|
||||
const fileBuffer = await streamToBuffer(fileStream);
|
||||
|
||||
if (file.type.startsWith('image')) {
|
||||
return {
|
||||
type: 'image',
|
||||
image: fileBuffer,
|
||||
mediaType: file.type,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'file',
|
||||
data: fileBuffer,
|
||||
mediaType: file.type,
|
||||
};
|
||||
}
|
||||
|
||||
private mapMessagesToCoreMessages(
|
||||
messages: AgentChatMessageEntity[],
|
||||
): ModelMessage[] {
|
||||
return messages
|
||||
.map(({ role, rawContent }): ModelMessage => {
|
||||
if (role === AgentChatMessageRole.USER) {
|
||||
return {
|
||||
role: 'user',
|
||||
content: rawContent ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: constructAssistantMessageContentFromStream(rawContent ?? ''),
|
||||
};
|
||||
})
|
||||
.filter((message) => message.content.length > 0);
|
||||
}
|
||||
|
||||
async streamChatResponse({
|
||||
workspace,
|
||||
userWorkspaceId,
|
||||
agentId,
|
||||
userMessage,
|
||||
messages,
|
||||
fileIds,
|
||||
recordIdsByObjectMetadataNameSingular,
|
||||
}: {
|
||||
workspace: Workspace;
|
||||
userWorkspaceId: string;
|
||||
agentId: string;
|
||||
userMessage: string;
|
||||
messages: AgentChatMessageEntity[];
|
||||
fileIds: string[];
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType;
|
||||
}) {
|
||||
const agent = await this.agentRepository.findOneOrFail({
|
||||
where: { id: agentId },
|
||||
});
|
||||
try {
|
||||
const agent = await this.agentRepository.findOneOrFail({
|
||||
where: { id: agentId },
|
||||
});
|
||||
|
||||
const llmMessages: ModelMessage[] =
|
||||
this.mapMessagesToCoreMessages(messages);
|
||||
let contextString = '';
|
||||
|
||||
let contextString = '';
|
||||
if (recordIdsByObjectMetadataNameSingular.length > 0) {
|
||||
const contextPart = await this.getContextForSystemPrompt(
|
||||
workspace,
|
||||
recordIdsByObjectMetadataNameSingular,
|
||||
userWorkspaceId,
|
||||
);
|
||||
|
||||
if (recordIdsByObjectMetadataNameSingular.length > 0) {
|
||||
const contextPart = await this.getContextForSystemPrompt(
|
||||
workspace,
|
||||
recordIdsByObjectMetadataNameSingular,
|
||||
userWorkspaceId,
|
||||
contextString = `\n\nCONTEXT:\n${contextPart}`;
|
||||
}
|
||||
|
||||
const aiRequestConfig = await this.prepareAIRequestConfig({
|
||||
system: `${AGENT_SYSTEM_PROMPTS.AGENT_CHAT}\n\n${agent.prompt}${contextString}`,
|
||||
agent,
|
||||
messages,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Sending request to AI model with ${messages.length} messages`,
|
||||
);
|
||||
|
||||
contextString = `\n\nCONTEXT:\n${contextPart}`;
|
||||
const model =
|
||||
await this.aiModelRegistryService.resolveModelForAgent(agent);
|
||||
|
||||
const stream = streamText(aiRequestConfig);
|
||||
|
||||
stream.usage
|
||||
.then((usage) => {
|
||||
this.aiBillingService.calculateAndBillUsage(
|
||||
model.modelId,
|
||||
usage,
|
||||
workspace.id,
|
||||
);
|
||||
})
|
||||
.catch((usageError) => {
|
||||
this.logger.error('Failed to get usage information:', usageError);
|
||||
});
|
||||
|
||||
return stream;
|
||||
} catch (error) {
|
||||
this.logger.error('Error in streamChatResponse:', error);
|
||||
throw new AgentException(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to stream chat response',
|
||||
AgentExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
const userMessageWithFiles = await this.buildUserMessage(
|
||||
userMessage,
|
||||
fileIds,
|
||||
);
|
||||
|
||||
llmMessages.push(userMessageWithFiles);
|
||||
|
||||
const aiRequestConfig = await this.prepareAIRequestConfig({
|
||||
system: `${AGENT_SYSTEM_PROMPTS.AGENT_CHAT}\n\n${agent.prompt}${contextString}`,
|
||||
agent,
|
||||
messages: llmMessages,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Sending request to AI model with ${llmMessages.length} messages`,
|
||||
);
|
||||
|
||||
const model = await this.aiModelRegistryService.resolveModelForAgent(agent);
|
||||
|
||||
const stream = streamText(aiRequestConfig);
|
||||
|
||||
stream.usage.then((usage) => {
|
||||
this.aiBillingService.calculateAndBillUsage(
|
||||
model.modelId,
|
||||
usage,
|
||||
workspace.id,
|
||||
);
|
||||
});
|
||||
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import {
|
||||
createUIMessageStream,
|
||||
pipeUIMessageStreamToResponse,
|
||||
UIDataTypes,
|
||||
UIMessage,
|
||||
UITools,
|
||||
} from 'ai';
|
||||
import { type Response } from 'express';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
|
|
@ -17,24 +24,13 @@ import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metad
|
|||
|
||||
export type StreamAgentChatOptions = {
|
||||
threadId: string;
|
||||
userMessage: string;
|
||||
userWorkspaceId: string;
|
||||
workspace: Workspace;
|
||||
fileIds: string[];
|
||||
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType;
|
||||
res: Response;
|
||||
response: Response;
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
};
|
||||
|
||||
const CLIENT_FORWARDED_EVENT_TYPES = [
|
||||
'text-delta',
|
||||
'reasoning',
|
||||
'reasoning-delta',
|
||||
'tool-call',
|
||||
'tool-input-delta',
|
||||
'tool-result',
|
||||
'error',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class AgentStreamingService {
|
||||
private readonly logger = new Logger(AgentStreamingService.name);
|
||||
|
|
@ -48,15 +44,12 @@ export class AgentStreamingService {
|
|||
|
||||
async streamAgentChat({
|
||||
threadId,
|
||||
userMessage,
|
||||
userWorkspaceId,
|
||||
workspace,
|
||||
fileIds,
|
||||
messages,
|
||||
recordIdsByObjectMetadataNameSingular,
|
||||
res,
|
||||
response,
|
||||
}: StreamAgentChatOptions) {
|
||||
let rawStreamString = '';
|
||||
|
||||
try {
|
||||
const thread = await this.threadRepository.findOne({
|
||||
where: {
|
||||
|
|
@ -73,74 +66,56 @@ export class AgentStreamingService {
|
|||
);
|
||||
}
|
||||
|
||||
this.setupStreamingHeaders(res);
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
const result = await this.agentExecutionService.streamChatResponse({
|
||||
workspace,
|
||||
agentId: thread.agent.id,
|
||||
userWorkspaceId,
|
||||
messages,
|
||||
recordIdsByObjectMetadataNameSingular,
|
||||
});
|
||||
|
||||
const { fullStream } =
|
||||
await this.agentExecutionService.streamChatResponse({
|
||||
workspace,
|
||||
agentId: thread.agent.id,
|
||||
userWorkspaceId,
|
||||
userMessage,
|
||||
messages: thread.messages,
|
||||
fileIds,
|
||||
recordIdsByObjectMetadataNameSingular,
|
||||
});
|
||||
writer.merge(
|
||||
result.toUIMessageStream({
|
||||
onError: (error) => {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
},
|
||||
onFinish: async ({ responseMessage }) => {
|
||||
if (responseMessage.parts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for await (const chunk of fullStream) {
|
||||
rawStreamString += JSON.stringify(chunk) + '\n';
|
||||
await this.agentChatService.addMessage({
|
||||
threadId,
|
||||
uiMessage: {
|
||||
role: AgentChatMessageRole.USER,
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
messages[messages.length - 1].parts.find(
|
||||
(part) => part.type === 'text',
|
||||
)?.text ?? '',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await this.agentChatService.addMessage({
|
||||
threadId,
|
||||
uiMessage: responseMessage,
|
||||
});
|
||||
},
|
||||
sendReasoning: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
this.sendStreamEvent(
|
||||
res,
|
||||
CLIENT_FORWARDED_EVENT_TYPES.includes(chunk.type)
|
||||
? chunk
|
||||
: { type: chunk.type },
|
||||
);
|
||||
}
|
||||
pipeUIMessageStreamToResponse({ stream, response });
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
|
||||
if (error instanceof AgentException) {
|
||||
this.logger.error(`Agent Exception Code: ${error.code}`);
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
this.setupStreamingHeaders(res);
|
||||
}
|
||||
|
||||
const errorChunk = {
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
};
|
||||
|
||||
rawStreamString += JSON.stringify(errorChunk) + '\n';
|
||||
|
||||
this.sendStreamEvent(res, errorChunk);
|
||||
this.logger.error(error.message);
|
||||
response.end();
|
||||
}
|
||||
|
||||
await this.agentChatService.addMessage({
|
||||
threadId,
|
||||
role: AgentChatMessageRole.USER,
|
||||
rawContent: userMessage,
|
||||
fileIds,
|
||||
});
|
||||
|
||||
await this.agentChatService.addMessage({
|
||||
threadId,
|
||||
role: AgentChatMessageRole.ASSISTANT,
|
||||
rawContent: rawStreamString.trim() || null,
|
||||
});
|
||||
|
||||
res.end();
|
||||
}
|
||||
|
||||
private sendStreamEvent(res: Response, event: object): void {
|
||||
res.write(JSON.stringify(event) + '\n');
|
||||
}
|
||||
|
||||
private setupStreamingHeaders(res: Response): void {
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.setHeader('Transfer-Encoding', 'chunked');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/wor
|
|||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { WorkflowToolsModule } from 'src/modules/workflow/workflow-tools/workflow-tools.module';
|
||||
|
||||
import { AgentChatMessagePartEntity } from './agent-chat-message-part.entity';
|
||||
import { AgentChatMessageEntity } from './agent-chat-message.entity';
|
||||
import { AgentChatThreadEntity } from './agent-chat-thread.entity';
|
||||
import { AgentChatResolver } from './agent-chat.resolver';
|
||||
|
|
@ -45,6 +46,7 @@ import { AgentService } from './agent.service';
|
|||
RoleEntity,
|
||||
RoleTargetsEntity,
|
||||
AgentChatMessageEntity,
|
||||
AgentChatMessagePartEntity,
|
||||
AgentChatThreadEntity,
|
||||
FileEntity,
|
||||
UserWorkspace,
|
||||
|
|
@ -89,6 +91,7 @@ import { AgentService } from './agent.service';
|
|||
TypeOrmModule.forFeature([
|
||||
AgentEntity,
|
||||
AgentChatMessageEntity,
|
||||
AgentChatMessagePartEntity,
|
||||
AgentChatThreadEntity,
|
||||
]),
|
||||
AgentHandoffExecutorService,
|
||||
|
|
|
|||
|
|
@ -76,7 +76,18 @@ export const AGENT_HANDOFF_SCHEMA = z.object({
|
|||
}),
|
||||
z.object({
|
||||
role: z.literal('tool'),
|
||||
content: z.string(),
|
||||
content: z.union([
|
||||
z.string(),
|
||||
z.array(
|
||||
z.object({
|
||||
type: z.literal('tool-result'),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
result: z.unknown(),
|
||||
isError: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
]),
|
||||
toolCallId: z.string(),
|
||||
}),
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { JSONValue } from 'ai';
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType('AgentChatMessagePart')
|
||||
export class AgentChatMessagePartDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Field(() => UUIDScalarType)
|
||||
messageId: string;
|
||||
|
||||
@Field(() => Int)
|
||||
orderIndex: number;
|
||||
|
||||
@Field()
|
||||
type: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
textContent?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
reasoningContent?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
toolName?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
toolCallId?: string;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
toolInput?: Record<string, unknown>;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
toolOutput?: Record<string, unknown>;
|
||||
|
||||
@Field({ nullable: true })
|
||||
state?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
errorMessage?: string;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
errorDetails?: Record<string, unknown>;
|
||||
|
||||
@Field({ nullable: true })
|
||||
sourceUrlSourceId?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
sourceUrlUrl?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
sourceUrlTitle?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
sourceDocumentSourceId?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
sourceDocumentMediaType?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
sourceDocumentTitle?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
sourceDocumentFilename?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
fileMediaType?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
fileFilename?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
fileUrl?: string;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
providerMetadata?: Record<string, Record<string, JSONValue>>;
|
||||
|
||||
@Field()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
|||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
|
||||
import { AgentChatMessagePartDTO } from './agent-chat-message-part.dto';
|
||||
|
||||
@ObjectType('AgentChatMessage')
|
||||
export class AgentChatMessageDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
|
|
@ -14,8 +16,8 @@ export class AgentChatMessageDTO {
|
|||
@Field()
|
||||
role: 'user' | 'assistant';
|
||||
|
||||
@Field({ nullable: true })
|
||||
rawContent: string;
|
||||
@Field(() => [AgentChatMessagePartDTO])
|
||||
parts: AgentChatMessagePartDTO[];
|
||||
|
||||
@Field(() => [FileDTO])
|
||||
files: FileDTO[];
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
import { type ReasoningPart } from '@ai-sdk/provider-utils';
|
||||
import { type TextPart } from 'ai';
|
||||
import {
|
||||
parseStreamLine,
|
||||
splitStreamIntoLines,
|
||||
type TextBlock,
|
||||
} from 'twenty-shared/ai';
|
||||
|
||||
export const constructAssistantMessageContentFromStream = (
|
||||
rawContent: string,
|
||||
) => {
|
||||
const lines = splitStreamIntoLines(rawContent);
|
||||
|
||||
const output: Array<TextPart | ReasoningPart> = [];
|
||||
let currentTextBlock: TextBlock = null;
|
||||
|
||||
const flushTextBlock = () => {
|
||||
if (currentTextBlock) {
|
||||
if (currentTextBlock.type === 'reasoning') {
|
||||
output.push({
|
||||
type: 'reasoning',
|
||||
text: currentTextBlock.content,
|
||||
});
|
||||
} else {
|
||||
output.push({
|
||||
type: 'text',
|
||||
text: currentTextBlock.content,
|
||||
});
|
||||
}
|
||||
currentTextBlock = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const event = parseStreamLine(line);
|
||||
|
||||
if (!event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'reasoning-start':
|
||||
flushTextBlock();
|
||||
currentTextBlock = {
|
||||
type: 'reasoning',
|
||||
content: '',
|
||||
isThinking: true,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'reasoning-delta':
|
||||
if (!currentTextBlock || currentTextBlock.type !== 'reasoning') {
|
||||
flushTextBlock();
|
||||
currentTextBlock = {
|
||||
type: 'reasoning',
|
||||
content: '',
|
||||
isThinking: true,
|
||||
};
|
||||
}
|
||||
currentTextBlock.content += event.text || '';
|
||||
break;
|
||||
|
||||
case 'reasoning-end':
|
||||
if (currentTextBlock?.type === 'reasoning') {
|
||||
currentTextBlock.isThinking = false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text-delta':
|
||||
if (!currentTextBlock || currentTextBlock.type !== 'text') {
|
||||
flushTextBlock();
|
||||
currentTextBlock = { type: 'text', content: '' };
|
||||
}
|
||||
currentTextBlock.content += event.text || '';
|
||||
break;
|
||||
|
||||
case 'step-finish':
|
||||
if (currentTextBlock?.type === 'reasoning') {
|
||||
currentTextBlock.isThinking = false;
|
||||
}
|
||||
flushTextBlock();
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
flushTextBlock();
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
flushTextBlock();
|
||||
|
||||
return output;
|
||||
};
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import {
|
||||
type ToolUIPart,
|
||||
type UIDataTypes,
|
||||
type UIMessagePart,
|
||||
type UITools,
|
||||
} from 'ai';
|
||||
|
||||
import { type AgentChatMessagePartEntity } from 'src/engine/metadata-modules/agent/agent-chat-message-part.entity';
|
||||
|
||||
const isToolPart = (
|
||||
part: UIMessagePart<UIDataTypes, UITools>,
|
||||
): part is ToolUIPart => {
|
||||
return part.type.includes('tool-') && 'toolCallId' in part;
|
||||
};
|
||||
|
||||
export const mapUIMessagePartsToDBParts = (
|
||||
uiMessageParts: UIMessagePart<UIDataTypes, UITools>[],
|
||||
messageId: string,
|
||||
): Partial<AgentChatMessagePartEntity>[] => {
|
||||
return uiMessageParts.map((part, index) => {
|
||||
const basePart: Partial<AgentChatMessagePartEntity> = {
|
||||
messageId,
|
||||
orderIndex: index,
|
||||
type: part.type,
|
||||
};
|
||||
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return {
|
||||
...basePart,
|
||||
textContent: part.text,
|
||||
};
|
||||
case 'reasoning':
|
||||
return {
|
||||
...basePart,
|
||||
reasoningContent: part.text,
|
||||
};
|
||||
case 'file':
|
||||
return {
|
||||
...basePart,
|
||||
fileMediaType: part.mediaType,
|
||||
fileFilename: part.filename,
|
||||
fileUrl: part.url,
|
||||
};
|
||||
case 'source-url':
|
||||
return {
|
||||
...basePart,
|
||||
sourceUrlSourceId: part.sourceId,
|
||||
sourceUrlUrl: part.url,
|
||||
sourceUrlTitle: part.title,
|
||||
providerMetadata: part.providerMetadata ?? null,
|
||||
};
|
||||
case 'source-document':
|
||||
return {
|
||||
...basePart,
|
||||
sourceDocumentSourceId: part.sourceId,
|
||||
sourceDocumentMediaType: part.mediaType,
|
||||
sourceDocumentTitle: part.title,
|
||||
sourceDocumentFilename: part.filename,
|
||||
providerMetadata: part.providerMetadata ?? null,
|
||||
};
|
||||
case 'step-start':
|
||||
return basePart;
|
||||
default:
|
||||
{
|
||||
if (isToolPart(part)) {
|
||||
const { toolCallId, input, output, errorText } = part;
|
||||
|
||||
return {
|
||||
...basePart,
|
||||
toolCallId: toolCallId,
|
||||
toolInput: input,
|
||||
toolOutput: output,
|
||||
errorMessage: errorText,
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new Error(`Unsupported part type: ${part.type}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
|||
|
||||
import isEmpty from 'lodash.isempty';
|
||||
import { DataSource, type EntityManager, Repository } from 'typeorm';
|
||||
import { type DeepPartial } from 'typeorm/common/DeepPartial';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { ForeignDataWrapperServerQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/foreign-data-wrapper-server-query.factory';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { type DeepPartial } from 'typeorm';
|
||||
|
||||
import {
|
||||
type ForeignDataWrapperOptions,
|
||||
type RemoteServerEntity,
|
||||
|
|
@ -9,10 +11,6 @@ import {
|
|||
} from 'src/engine/metadata-modules/remote-server/remote-server.exception';
|
||||
import { type UserMappingOptions } from 'src/engine/metadata-modules/remote-server/types/user-mapping-options';
|
||||
|
||||
export type DeepPartial<T> = {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
};
|
||||
|
||||
export const buildUpdateRemoteServerRawQuery = (
|
||||
remoteServerToUpdate: DeepPartial<RemoteServerEntity<RemoteServerType>> &
|
||||
Pick<RemoteServerEntity<RemoteServerType>, 'workspaceId' | 'id'>,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const agentTableName = 'agent';
|
|||
const workspaceTableName = 'workspace';
|
||||
const agentChatThreadTableName = 'agentChatThread';
|
||||
const agentChatMessageTableName = 'agentChatMessage';
|
||||
const agentChatMessagePartTableName = 'agentChatMessagePart';
|
||||
|
||||
export const AGENT_DATA_SEED_IDS = {
|
||||
APPLE_DEFAULT_AGENT: '20202020-0000-4000-8000-000000000001',
|
||||
|
|
@ -33,6 +34,17 @@ export const AGENT_CHAT_MESSAGE_DATA_SEED_IDS = {
|
|||
YCOMBINATOR_MESSAGE_4: '20202020-0000-4000-8000-000000000034',
|
||||
};
|
||||
|
||||
export const AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS = {
|
||||
APPLE_MESSAGE_1_PART_1: '20202020-0000-4000-8000-000000000041',
|
||||
APPLE_MESSAGE_2_PART_1: '20202020-0000-4000-8000-000000000042',
|
||||
APPLE_MESSAGE_3_PART_1: '20202020-0000-4000-8000-000000000043',
|
||||
APPLE_MESSAGE_4_PART_1: '20202020-0000-4000-8000-000000000044',
|
||||
YCOMBINATOR_MESSAGE_1_PART_1: '20202020-0000-4000-8000-000000000051',
|
||||
YCOMBINATOR_MESSAGE_2_PART_1: '20202020-0000-4000-8000-000000000052',
|
||||
YCOMBINATOR_MESSAGE_3_PART_1: '20202020-0000-4000-8000-000000000053',
|
||||
YCOMBINATOR_MESSAGE_4_PART_1: '20202020-0000-4000-8000-000000000054',
|
||||
};
|
||||
|
||||
const seedAgentChatThreads = async (
|
||||
dataSource: DataSource,
|
||||
schemaName: string,
|
||||
|
|
@ -88,16 +100,24 @@ const seedAgentChatMessages = async (
|
|||
threadId: string,
|
||||
) => {
|
||||
let messageIds: string[];
|
||||
let partIds: string[];
|
||||
let messages: Array<{
|
||||
id: string;
|
||||
threadId: string;
|
||||
role: AgentChatMessageRole;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
}>;
|
||||
let messageParts: Array<{
|
||||
id: string;
|
||||
messageId: string;
|
||||
orderIndex: number;
|
||||
type: string;
|
||||
textContent: string;
|
||||
createdAt: Date;
|
||||
}>;
|
||||
|
||||
const now = new Date();
|
||||
const baseTime = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24 hours ago
|
||||
const baseTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
if (workspaceId === SEED_APPLE_WORKSPACE_ID) {
|
||||
messageIds = [
|
||||
|
|
@ -106,38 +126,74 @@ const seedAgentChatMessages = async (
|
|||
AGENT_CHAT_MESSAGE_DATA_SEED_IDS.APPLE_MESSAGE_3,
|
||||
AGENT_CHAT_MESSAGE_DATA_SEED_IDS.APPLE_MESSAGE_4,
|
||||
];
|
||||
partIds = [
|
||||
AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS.APPLE_MESSAGE_1_PART_1,
|
||||
AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS.APPLE_MESSAGE_2_PART_1,
|
||||
AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS.APPLE_MESSAGE_3_PART_1,
|
||||
AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS.APPLE_MESSAGE_4_PART_1,
|
||||
];
|
||||
messages = [
|
||||
{
|
||||
id: messageIds[0],
|
||||
threadId,
|
||||
role: AgentChatMessageRole.USER,
|
||||
content:
|
||||
'Hello! Can you help me understand our current product roadmap and key metrics?',
|
||||
createdAt: new Date(baseTime.getTime()),
|
||||
},
|
||||
{
|
||||
id: messageIds[1],
|
||||
threadId,
|
||||
role: AgentChatMessageRole.ASSISTANT,
|
||||
content:
|
||||
"Hello! I'd be happy to help you understand Apple's product roadmap and metrics. Based on your workspace data, I can see you have various projects and initiatives tracked. What specific aspect would you like to explore - product development timelines, user engagement metrics, or revenue targets?",
|
||||
createdAt: new Date(baseTime.getTime() + 5 * 60 * 1000), // 5 minutes later
|
||||
createdAt: new Date(baseTime.getTime() + 5 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
id: messageIds[2],
|
||||
threadId,
|
||||
role: AgentChatMessageRole.USER,
|
||||
content:
|
||||
"I'd like to focus on our user engagement metrics and how they're trending over the last quarter.",
|
||||
createdAt: new Date(baseTime.getTime() + 10 * 60 * 1000), // 10 minutes later
|
||||
createdAt: new Date(baseTime.getTime() + 10 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
id: messageIds[3],
|
||||
threadId,
|
||||
role: AgentChatMessageRole.ASSISTANT,
|
||||
content:
|
||||
createdAt: new Date(baseTime.getTime() + 15 * 60 * 1000),
|
||||
},
|
||||
];
|
||||
messageParts = [
|
||||
{
|
||||
id: partIds[0],
|
||||
messageId: messageIds[0],
|
||||
orderIndex: 0,
|
||||
type: 'text',
|
||||
textContent:
|
||||
'Hello! Can you help me understand our current product roadmap and key metrics?',
|
||||
createdAt: new Date(baseTime.getTime()),
|
||||
},
|
||||
{
|
||||
id: partIds[1],
|
||||
messageId: messageIds[1],
|
||||
orderIndex: 0,
|
||||
type: 'text',
|
||||
textContent:
|
||||
"Hello! I'd be happy to help you understand Apple's product roadmap and metrics. Based on your workspace data, I can see you have various projects and initiatives tracked. What specific aspect would you like to explore - product development timelines, user engagement metrics, or revenue targets?",
|
||||
createdAt: new Date(baseTime.getTime() + 5 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
id: partIds[2],
|
||||
messageId: messageIds[2],
|
||||
orderIndex: 0,
|
||||
type: 'text',
|
||||
textContent:
|
||||
"I'd like to focus on our user engagement metrics and how they're trending over the last quarter.",
|
||||
createdAt: new Date(baseTime.getTime() + 10 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
id: partIds[3],
|
||||
messageId: messageIds[3],
|
||||
orderIndex: 0,
|
||||
type: 'text',
|
||||
textContent:
|
||||
'Great! Looking at your user engagement data, I can see several key trends from the last quarter. Your active user base has grown by 15%, with particularly strong engagement in the mobile app. Daily active users are averaging 2.3 million, and session duration has increased by 8%. Would you like me to dive deeper into any specific engagement metrics or create a detailed report?',
|
||||
createdAt: new Date(baseTime.getTime() + 15 * 60 * 1000), // 15 minutes later
|
||||
createdAt: new Date(baseTime.getTime() + 15 * 60 * 1000),
|
||||
},
|
||||
];
|
||||
} else if (workspaceId === SEED_YCOMBINATOR_WORKSPACE_ID) {
|
||||
|
|
@ -147,38 +203,74 @@ const seedAgentChatMessages = async (
|
|||
AGENT_CHAT_MESSAGE_DATA_SEED_IDS.YCOMBINATOR_MESSAGE_3,
|
||||
AGENT_CHAT_MESSAGE_DATA_SEED_IDS.YCOMBINATOR_MESSAGE_4,
|
||||
];
|
||||
partIds = [
|
||||
AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS.YCOMBINATOR_MESSAGE_1_PART_1,
|
||||
AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS.YCOMBINATOR_MESSAGE_2_PART_1,
|
||||
AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS.YCOMBINATOR_MESSAGE_3_PART_1,
|
||||
AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS.YCOMBINATOR_MESSAGE_4_PART_1,
|
||||
];
|
||||
messages = [
|
||||
{
|
||||
id: messageIds[0],
|
||||
threadId,
|
||||
role: AgentChatMessageRole.USER,
|
||||
content:
|
||||
'What are the current startup trends and which companies in our portfolio are performing best?',
|
||||
createdAt: new Date(baseTime.getTime()),
|
||||
},
|
||||
{
|
||||
id: messageIds[1],
|
||||
threadId,
|
||||
role: AgentChatMessageRole.ASSISTANT,
|
||||
content:
|
||||
'Hello! I can help you analyze startup trends and portfolio performance. From your YCombinator workspace data, I can see strong performance in AI/ML startups, particularly in the B2B SaaS space. Several companies are showing 40%+ month-over-month growth. Would you like me to provide specific company performance metrics or focus on broader industry trends?',
|
||||
createdAt: new Date(baseTime.getTime() + 3 * 60 * 1000), // 3 minutes later
|
||||
createdAt: new Date(baseTime.getTime() + 3 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
id: messageIds[2],
|
||||
threadId,
|
||||
role: AgentChatMessageRole.USER,
|
||||
content:
|
||||
'Please focus on our top 5 performing companies and their key metrics.',
|
||||
createdAt: new Date(baseTime.getTime() + 8 * 60 * 1000), // 8 minutes later
|
||||
createdAt: new Date(baseTime.getTime() + 8 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
id: messageIds[3],
|
||||
threadId,
|
||||
role: AgentChatMessageRole.ASSISTANT,
|
||||
content:
|
||||
createdAt: new Date(baseTime.getTime() + 12 * 60 * 1000),
|
||||
},
|
||||
];
|
||||
messageParts = [
|
||||
{
|
||||
id: partIds[0],
|
||||
messageId: messageIds[0],
|
||||
orderIndex: 0,
|
||||
type: 'text',
|
||||
textContent:
|
||||
'What are the current startup trends and which companies in our portfolio are performing best?',
|
||||
createdAt: new Date(baseTime.getTime()),
|
||||
},
|
||||
{
|
||||
id: partIds[1],
|
||||
messageId: messageIds[1],
|
||||
orderIndex: 0,
|
||||
type: 'text',
|
||||
textContent:
|
||||
'Hello! I can help you analyze startup trends and portfolio performance. From your YCombinator workspace data, I can see strong performance in AI/ML startups, particularly in the B2B SaaS space. Several companies are showing 40%+ month-over-month growth. Would you like me to provide specific company performance metrics or focus on broader industry trends?',
|
||||
createdAt: new Date(baseTime.getTime() + 3 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
id: partIds[2],
|
||||
messageId: messageIds[2],
|
||||
orderIndex: 0,
|
||||
type: 'text',
|
||||
textContent:
|
||||
'Please focus on our top 5 performing companies and their key metrics.',
|
||||
createdAt: new Date(baseTime.getTime() + 8 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
id: partIds[3],
|
||||
messageId: messageIds[3],
|
||||
orderIndex: 0,
|
||||
type: 'text',
|
||||
textContent:
|
||||
'Here are your top 5 performing portfolio companies: 1) TechFlow AI - 45% MoM growth, $2M ARR, 2) DataSync Pro - 38% MoM growth, $1.5M ARR, 3) CloudOps Solutions - 35% MoM growth, $3.2M ARR, 4) SecureNet - 32% MoM growth, $1.8M ARR, 5) HealthTech Plus - 28% MoM growth, $2.5M ARR. All are showing strong customer retention (>95%) and expanding market share. Would you like detailed breakdowns for any specific company?',
|
||||
createdAt: new Date(baseTime.getTime() + 12 * 60 * 1000), // 12 minutes later
|
||||
createdAt: new Date(baseTime.getTime() + 12 * 60 * 1000),
|
||||
},
|
||||
];
|
||||
} else {
|
||||
|
|
@ -194,12 +286,26 @@ const seedAgentChatMessages = async (
|
|||
'id',
|
||||
'threadId',
|
||||
'role',
|
||||
'content',
|
||||
'createdAt',
|
||||
])
|
||||
.orIgnore()
|
||||
.values(messages)
|
||||
.execute();
|
||||
|
||||
await dataSource
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(`${schemaName}.${agentChatMessagePartTableName}`, [
|
||||
'id',
|
||||
'messageId',
|
||||
'orderIndex',
|
||||
'type',
|
||||
'textContent',
|
||||
'createdAt',
|
||||
])
|
||||
.orIgnore()
|
||||
.values(messageParts)
|
||||
.execute();
|
||||
};
|
||||
|
||||
export const seedAgents = async (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { type FieldMetadataType } from 'twenty-shared/types';
|
||||
import {
|
||||
type ExcludeFunctions,
|
||||
type FieldMetadataType,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { type WorkspaceDynamicRelationMetadataArgsFactory } from 'src/engine/twenty-orm/interfaces/workspace-dynamic-relation-metadata-args.interface';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { type DeepPartial } from 'typeorm/common/DeepPartial';
|
||||
|
||||
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
|
||||
|
|
|
|||
|
|
@ -6,14 +6,3 @@
|
|||
* |_| \_/\_/ \___|_| |_|\__|\__, |
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export type { ErrorEvent } from './types/ErrorEvent';
|
||||
export type { ReasoningDeltaEvent } from './types/ReasoningDeltaEvent';
|
||||
export type { StreamEvent } from './types/StreamEvent';
|
||||
export type { TextBlock } from './types/TextBlock';
|
||||
export type { TextDeltaEvent } from './types/TextDeltaEvent';
|
||||
export type { ToolCallEvent } from './types/ToolCallEvent';
|
||||
export type { ToolEvent } from './types/ToolEvent';
|
||||
export type { ToolResultEvent } from './types/ToolResultEvent';
|
||||
export { parseStreamLine } from './utils/parseStreamLine';
|
||||
export { splitStreamIntoLines } from './utils/splitStreamIntoLines';
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
export type ErrorEvent = {
|
||||
type: 'error';
|
||||
message: string;
|
||||
error?: unknown;
|
||||
};
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export type ReasoningDeltaEvent = {
|
||||
type: 'reasoning-delta';
|
||||
text: string;
|
||||
};
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import type { ErrorEvent } from './ErrorEvent';
|
||||
import type { ReasoningDeltaEvent } from './ReasoningDeltaEvent';
|
||||
import type { TextDeltaEvent } from './TextDeltaEvent';
|
||||
import type { ToolCallEvent } from './ToolCallEvent';
|
||||
import type { ToolResultEvent } from './ToolResultEvent';
|
||||
|
||||
export type StreamEvent =
|
||||
| ToolCallEvent
|
||||
| ToolResultEvent
|
||||
| {
|
||||
type: 'reasoning-start';
|
||||
}
|
||||
| ReasoningDeltaEvent
|
||||
| {
|
||||
type: 'reasoning-end';
|
||||
}
|
||||
| TextDeltaEvent
|
||||
| {
|
||||
type: 'step-finish';
|
||||
}
|
||||
| ErrorEvent;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export type TextBlock =
|
||||
| { type: 'reasoning'; content: string; isThinking: boolean }
|
||||
| { type: 'text'; content: string }
|
||||
| null;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export type TextDeltaEvent = {
|
||||
type: 'text-delta';
|
||||
text: string;
|
||||
};
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
export type ToolCallEvent = {
|
||||
type: 'tool-call';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input: {
|
||||
loadingMessage: string;
|
||||
input: unknown;
|
||||
};
|
||||
};
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import type { ToolCallEvent } from './ToolCallEvent';
|
||||
import type { ToolResultEvent } from './ToolResultEvent';
|
||||
|
||||
export type ToolEvent = ToolCallEvent | ToolResultEvent;
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
export type ToolResultEvent = {
|
||||
type: 'tool-result';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
output: {
|
||||
success: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import type { StreamEvent } from '../types/StreamEvent';
|
||||
|
||||
export const parseStreamLine = (line: string): StreamEvent | null => {
|
||||
try {
|
||||
return JSON.parse(line) as StreamEvent;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export const splitStreamIntoLines = (streamText: string): string[] => {
|
||||
return streamText.trim().split('\n');
|
||||
};
|
||||
1
packages/twenty-shared/src/types/ExcludeFunctions.ts
Normal file
1
packages/twenty-shared/src/types/ExcludeFunctions.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type ExcludeFunctions<T> = T extends Function ? never : T;
|
||||
|
|
@ -14,6 +14,7 @@ export { AppPath } from './AppPath';
|
|||
export type { ConfigVariableValue } from './ConfigVariableValue';
|
||||
export { ConnectedAccountProvider } from './ConnectedAccountProvider';
|
||||
export type { EnumFieldMetadataType } from './EnumFieldMetadataType';
|
||||
export type { ExcludeFunctions } from './ExcludeFunctions';
|
||||
export { FieldMetadataType } from './FieldMetadataType';
|
||||
export type { FromTo } from './FromToType';
|
||||
export type { IsExactly } from './IsExactly';
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
*/
|
||||
|
||||
export { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from './constants/CaptureAllVariableTagInnerRegex';
|
||||
export { CONTENT_TYPE_VALUES_HTTP_REQUEST } from './constants/contentTypeValuesHttpRequest';
|
||||
export { CONTENT_TYPE_VALUES_HTTP_REQUEST } from './constants/ContentTypeValuesHttpRequest';
|
||||
export { TRIGGER_STEP_ID } from './constants/TriggerStepId';
|
||||
export { workflowAiAgentActionSchema } from './schemas/ai-agent-action-schema';
|
||||
export { workflowAiAgentActionSettingsSchema } from './schemas/ai-agent-action-settings-schema';
|
||||
|
|
|
|||
91
yarn.lock
91
yarn.lock
|
|
@ -48,6 +48,30 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/gateway@npm:1.0.26":
|
||||
version: 1.0.26
|
||||
resolution: "@ai-sdk/gateway@npm:1.0.26"
|
||||
dependencies:
|
||||
"@ai-sdk/provider": "npm:2.0.0"
|
||||
"@ai-sdk/provider-utils": "npm:3.0.9"
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4
|
||||
checksum: 10c0/bb61342d1827838139b81c8c2cb21e455f422b438cd0791d3d3cf30f58726c853644d9e5b85bc9faa7104bce4816b878c09ea0af65c69e13871ff08ae40a56ec
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/gateway@npm:1.0.28":
|
||||
version: 1.0.28
|
||||
resolution: "@ai-sdk/gateway@npm:1.0.28"
|
||||
dependencies:
|
||||
"@ai-sdk/provider": "npm:2.0.0"
|
||||
"@ai-sdk/provider-utils": "npm:3.0.9"
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4
|
||||
checksum: 10c0/3c5d0c1e0a957353fc848ae498e136302bddce4b99ef66b68f29566fdc31e0f7f98bb182cfd546998a3b08651e97a28c9ec1cfc4da31fffca35618ae2c13b323
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/openai-compatible@npm:1.0.18":
|
||||
version: 1.0.18
|
||||
resolution: "@ai-sdk/openai-compatible@npm:1.0.18"
|
||||
|
|
@ -94,6 +118,24 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/react@npm:^2.0.51":
|
||||
version: 2.0.51
|
||||
resolution: "@ai-sdk/react@npm:2.0.51"
|
||||
dependencies:
|
||||
"@ai-sdk/provider-utils": "npm:3.0.9"
|
||||
ai: "npm:5.0.51"
|
||||
swr: "npm:^2.2.5"
|
||||
throttleit: "npm:2.1.0"
|
||||
peerDependencies:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
zod: ^3.25.76 || ^4
|
||||
peerDependenciesMeta:
|
||||
zod:
|
||||
optional: true
|
||||
checksum: 10c0/2ae48e67ecd9efcbd725f4d84850601b58ab93f1fda67ca282cccf7e734b465d3aad702a047cedf88ae0a697f99a5e593f2a6efac372123ae832ef8bcc1980a1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/xai@npm:^2.0.19":
|
||||
version: 2.0.19
|
||||
resolution: "@ai-sdk/xai@npm:2.0.19"
|
||||
|
|
@ -23889,6 +23931,20 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ai@npm:5.0.51":
|
||||
version: 5.0.51
|
||||
resolution: "ai@npm:5.0.51"
|
||||
dependencies:
|
||||
"@ai-sdk/gateway": "npm:1.0.28"
|
||||
"@ai-sdk/provider": "npm:2.0.0"
|
||||
"@ai-sdk/provider-utils": "npm:3.0.9"
|
||||
"@opentelemetry/api": "npm:1.9.0"
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4
|
||||
checksum: 10c0/47bf8b5195d5175810e7311e670f40da233d3fba8117949c51dcb8b5214a926bccb98d1ed0e0da98d572f9818349026427adcc4259ecbfcd4d4d1d19992d1cb5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ai@npm:^5.0.44":
|
||||
version: 5.0.44
|
||||
resolution: "ai@npm:5.0.44"
|
||||
|
|
@ -23903,6 +23959,20 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ai@npm:^5.0.49":
|
||||
version: 5.0.49
|
||||
resolution: "ai@npm:5.0.49"
|
||||
dependencies:
|
||||
"@ai-sdk/gateway": "npm:1.0.26"
|
||||
"@ai-sdk/provider": "npm:2.0.0"
|
||||
"@ai-sdk/provider-utils": "npm:3.0.9"
|
||||
"@opentelemetry/api": "npm:1.9.0"
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4
|
||||
checksum: 10c0/781393542d263f3f9ecd26798253746c4d46743440fb8ebe129239134fbf5e789d599762b8b8a0a05c25194b5be686b912a947db8c013c2840f0d1e3a27888b8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ajv-draft-04@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "ajv-draft-04@npm:1.0.0"
|
||||
|
|
@ -51291,6 +51361,18 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"swr@npm:^2.2.5":
|
||||
version: 2.3.6
|
||||
resolution: "swr@npm:2.3.6"
|
||||
dependencies:
|
||||
dequal: "npm:^2.0.3"
|
||||
use-sync-external-store: "npm:^1.4.0"
|
||||
peerDependencies:
|
||||
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: 10c0/9534f350982e36a3ae0a13da8c0f7da7011fc979e77f306e60c4e5db0f9b84f17172c44f973441ba56bb684b69b0d9838ab40011a6b6b3e32d0cd7f3d5405f99
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"symbol-observable@npm:4.0.0, symbol-observable@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "symbol-observable@npm:4.0.0"
|
||||
|
|
@ -51541,6 +51623,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"throttleit@npm:2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "throttleit@npm:2.1.0"
|
||||
checksum: 10c0/1696ae849522cea6ba4f4f3beac1f6655d335e51b42d99215e196a718adced0069e48deaaf77f7e89f526ab31de5b5c91016027da182438e6f9280be2f3d5265
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"through2@npm:4.0.2, through2@npm:^4.0.2":
|
||||
version: 4.0.2
|
||||
resolution: "through2@npm:4.0.2"
|
||||
|
|
@ -52400,6 +52489,7 @@ __metadata:
|
|||
version: 0.0.0-use.local
|
||||
resolution: "twenty-front@workspace:packages/twenty-front"
|
||||
dependencies:
|
||||
"@ai-sdk/react": "npm:^2.0.51"
|
||||
"@apollo/client": "npm:^3.7.17"
|
||||
"@blocknote/mantine": "npm:^0.31.1"
|
||||
"@blocknote/react": "npm:^0.31.1"
|
||||
|
|
@ -52448,6 +52538,7 @@ __metadata:
|
|||
"@typescript-eslint/parser": "npm:^8.39.0"
|
||||
"@typescript-eslint/utils": "npm:^8.39.0"
|
||||
"@xyflow/react": "npm:^12.4.2"
|
||||
ai: "npm:^5.0.49"
|
||||
apollo-link-rest: "npm:^0.9.0"
|
||||
apollo-upload-client: "npm:^17.0.0"
|
||||
buffer: "npm:^6.0.3"
|
||||
|
|
|
|||
Loading…
Reference in a new issue