Feat: Agent chat multi thread support (#13216)

Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: MD Readul Islam <99027968+readul-islam@users.noreply.github.com>
Co-authored-by: readul-islam <developer.readul@gamil.com>
Co-authored-by: Thomas des Francs <tdesfrancs@gmail.com>
Co-authored-by: Guillim <guillim@users.noreply.github.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Jean-Baptiste Ronssin <65334819+jbronssin@users.noreply.github.com>
Co-authored-by: kahkashan shaik <93042682+kahkashanshaik@users.noreply.github.com>
Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
Co-authored-by: Naifer <161821705+omarNaifer12@users.noreply.github.com>
Co-authored-by: Marie Stoppa <marie.stoppa@essec.edu>
This commit is contained in:
Abdul Rahman
2025-07-16 12:56:40 +05:30
committed by GitHub
parent ffcbfa6215
commit 8edf59a521
40 changed files with 944 additions and 79 deletions

View File

@ -7,6 +7,7 @@ module.exports = {
documents: [
'./src/modules/auth/graphql/**/*.{ts,tsx}',
'./src/modules/users/graphql/**/*.{ts,tsx}',
'./src/modules/ai/graphql/**/*.{ts,tsx}',
'./src/modules/workspace/graphql/**/*.{ts,tsx}',
'./src/modules/workspace-member/graphql/**/*.{ts,tsx}',

View File

@ -62,6 +62,25 @@ export type Agent = {
updatedAt: Scalars['DateTime'];
};
export type AgentChatMessage = {
__typename?: 'AgentChatMessage';
content: Scalars['String'];
createdAt: Scalars['DateTime'];
files: Array<File>;
id: Scalars['UUID'];
role: Scalars['String'];
threadId: Scalars['UUID'];
};
export type AgentChatThread = {
__typename?: 'AgentChatThread';
agentId: Scalars['UUID'];
createdAt: Scalars['DateTime'];
id: Scalars['UUID'];
title?: Maybe<Scalars['String']>;
updatedAt: Scalars['DateTime'];
};
export type AgentIdInput = {
/** The id of the agent. */
id: Scalars['UUID'];
@ -443,6 +462,10 @@ export type ConnectionParametersOutput = {
username?: Maybe<Scalars['String']>;
};
export type CreateAgentChatThreadInput = {
agentId: Scalars['UUID'];
};
export type CreateApiKeyDto = {
expiresAt: Scalars['String'];
name: Scalars['String'];
@ -1070,6 +1093,7 @@ export type Mutation = {
checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>;
checkoutSession: BillingSessionOutput;
computeStepOutputSchema: Scalars['JSON'];
createAgentChatThread: AgentChatThread;
createApiKey: ApiKey;
createApprovedAccessDomain: ApprovedAccessDomain;
createDatabaseConfigVariable: Scalars['Boolean'];
@ -1200,6 +1224,11 @@ export type MutationComputeStepOutputSchemaArgs = {
};
export type MutationCreateAgentChatThreadArgs = {
input: CreateAgentChatThreadInput;
};
export type MutationCreateApiKeyArgs = {
input: CreateApiKeyDto;
};
@ -1846,6 +1875,9 @@ export type PublishServerlessFunctionInput = {
export type Query = {
__typename?: 'Query';
agentChatMessages: Array<AgentChatMessage>;
agentChatThread: AgentChatThread;
agentChatThreads: Array<AgentChatThread>;
apiKey?: Maybe<ApiKey>;
apiKeys: Array<ApiKey>;
billingPortalSession: BillingSessionOutput;
@ -1894,6 +1926,21 @@ export type Query = {
};
export type QueryAgentChatMessagesArgs = {
threadId: Scalars['String'];
};
export type QueryAgentChatThreadArgs = {
id: Scalars['String'];
};
export type QueryAgentChatThreadsArgs = {
agentId: Scalars['String'];
};
export type QueryApiKeyArgs = {
input: GetApiKeyDto;
};
@ -2921,6 +2968,27 @@ export type WorkspaceUrlsAndId = {
workspaceUrls: WorkspaceUrls;
};
export type CreateAgentChatThreadMutationVariables = Exact<{
input: CreateAgentChatThreadInput;
}>;
export type CreateAgentChatThreadMutation = { __typename?: 'Mutation', createAgentChatThread: { __typename?: 'AgentChatThread', id: any, agentId: any, title?: string | null, createdAt: string, updatedAt: string } };
export type GetAgentChatMessagesQueryVariables = Exact<{
threadId: Scalars['String'];
}>;
export type GetAgentChatMessagesQuery = { __typename?: 'Query', agentChatMessages: Array<{ __typename?: 'AgentChatMessage', id: any, threadId: any, role: string, content: string, createdAt: string, files: Array<{ __typename?: 'File', id: string, name: string, fullPath: string, size: number, type: string, createdAt: string }> }> };
export type GetAgentChatThreadsQueryVariables = Exact<{
agentId: Scalars['String'];
}>;
export type GetAgentChatThreadsQuery = { __typename?: 'Query', agentChatThreads: Array<{ __typename?: 'AgentChatThread', id: any, agentId: any, title?: string | null, createdAt: string, updatedAt: string }> };
export type TrackAnalyticsMutationVariables = Exact<{
type: AnalyticsType;
event?: InputMaybe<Scalars['String']>;
@ -4098,6 +4166,129 @@ ${ObjectPermissionFragmentFragmentDoc}
${WorkspaceUrlsFragmentFragmentDoc}
${RoleFragmentFragmentDoc}
${AvailableWorkspacesFragmentFragmentDoc}`;
export const CreateAgentChatThreadDocument = gql`
mutation CreateAgentChatThread($input: CreateAgentChatThreadInput!) {
createAgentChatThread(input: $input) {
id
agentId
title
createdAt
updatedAt
}
}
`;
export type CreateAgentChatThreadMutationFn = Apollo.MutationFunction<CreateAgentChatThreadMutation, CreateAgentChatThreadMutationVariables>;
/**
* __useCreateAgentChatThreadMutation__
*
* To run a mutation, you first call `useCreateAgentChatThreadMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateAgentChatThreadMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [createAgentChatThreadMutation, { data, loading, error }] = useCreateAgentChatThreadMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useCreateAgentChatThreadMutation(baseOptions?: Apollo.MutationHookOptions<CreateAgentChatThreadMutation, CreateAgentChatThreadMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateAgentChatThreadMutation, CreateAgentChatThreadMutationVariables>(CreateAgentChatThreadDocument, options);
}
export type CreateAgentChatThreadMutationHookResult = ReturnType<typeof useCreateAgentChatThreadMutation>;
export type CreateAgentChatThreadMutationResult = Apollo.MutationResult<CreateAgentChatThreadMutation>;
export type CreateAgentChatThreadMutationOptions = Apollo.BaseMutationOptions<CreateAgentChatThreadMutation, CreateAgentChatThreadMutationVariables>;
export const GetAgentChatMessagesDocument = gql`
query GetAgentChatMessages($threadId: String!) {
agentChatMessages(threadId: $threadId) {
id
threadId
role
content
createdAt
files {
id
name
fullPath
size
type
createdAt
}
}
}
`;
/**
* __useGetAgentChatMessagesQuery__
*
* To run a query within a React component, call `useGetAgentChatMessagesQuery` and pass it any options that fit your needs.
* When your component renders, `useGetAgentChatMessagesQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetAgentChatMessagesQuery({
* variables: {
* threadId: // value for 'threadId'
* },
* });
*/
export function useGetAgentChatMessagesQuery(baseOptions: Apollo.QueryHookOptions<GetAgentChatMessagesQuery, GetAgentChatMessagesQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetAgentChatMessagesQuery, GetAgentChatMessagesQueryVariables>(GetAgentChatMessagesDocument, options);
}
export function useGetAgentChatMessagesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetAgentChatMessagesQuery, GetAgentChatMessagesQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetAgentChatMessagesQuery, GetAgentChatMessagesQueryVariables>(GetAgentChatMessagesDocument, options);
}
export type GetAgentChatMessagesQueryHookResult = ReturnType<typeof useGetAgentChatMessagesQuery>;
export type GetAgentChatMessagesLazyQueryHookResult = ReturnType<typeof useGetAgentChatMessagesLazyQuery>;
export type GetAgentChatMessagesQueryResult = Apollo.QueryResult<GetAgentChatMessagesQuery, GetAgentChatMessagesQueryVariables>;
export const GetAgentChatThreadsDocument = gql`
query GetAgentChatThreads($agentId: String!) {
agentChatThreads(agentId: $agentId) {
id
agentId
title
createdAt
updatedAt
}
}
`;
/**
* __useGetAgentChatThreadsQuery__
*
* To run a query within a React component, call `useGetAgentChatThreadsQuery` and pass it any options that fit your needs.
* When your component renders, `useGetAgentChatThreadsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetAgentChatThreadsQuery({
* variables: {
* agentId: // value for 'agentId'
* },
* });
*/
export function useGetAgentChatThreadsQuery(baseOptions: Apollo.QueryHookOptions<GetAgentChatThreadsQuery, GetAgentChatThreadsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetAgentChatThreadsQuery, GetAgentChatThreadsQueryVariables>(GetAgentChatThreadsDocument, options);
}
export function useGetAgentChatThreadsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetAgentChatThreadsQuery, GetAgentChatThreadsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetAgentChatThreadsQuery, GetAgentChatThreadsQueryVariables>(GetAgentChatThreadsDocument, options);
}
export type GetAgentChatThreadsQueryHookResult = ReturnType<typeof useGetAgentChatThreadsQuery>;
export type GetAgentChatThreadsLazyQueryHookResult = ReturnType<typeof useGetAgentChatThreadsLazyQuery>;
export type GetAgentChatThreadsQueryResult = Apollo.QueryResult<GetAgentChatThreadsQuery, GetAgentChatThreadsQueryVariables>;
export const TrackAnalyticsDocument = gql`
mutation TrackAnalytics($type: AnalyticsType!, $event: String, $name: String, $properties: JSON) {
trackAnalytics(type: $type, event: $event, name: $name, properties: $properties) {

View File

@ -62,6 +62,25 @@ export type Agent = {
updatedAt: Scalars['DateTime'];
};
export type AgentChatMessage = {
__typename?: 'AgentChatMessage';
content: Scalars['String'];
createdAt: Scalars['DateTime'];
files: Array<File>;
id: Scalars['UUID'];
role: Scalars['String'];
threadId: Scalars['UUID'];
};
export type AgentChatThread = {
__typename?: 'AgentChatThread';
agentId: Scalars['UUID'];
createdAt: Scalars['DateTime'];
id: Scalars['UUID'];
title?: Maybe<Scalars['String']>;
updatedAt: Scalars['DateTime'];
};
export type AgentIdInput = {
/** The id of the agent. */
id: Scalars['UUID'];
@ -443,6 +462,10 @@ export type ConnectionParametersOutput = {
username?: Maybe<Scalars['String']>;
};
export type CreateAgentChatThreadInput = {
agentId: Scalars['UUID'];
};
export type CreateApiKeyDto = {
expiresAt: Scalars['String'];
name: Scalars['String'];
@ -1027,6 +1050,7 @@ export type Mutation = {
checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>;
checkoutSession: BillingSessionOutput;
computeStepOutputSchema: Scalars['JSON'];
createAgentChatThread: AgentChatThread;
createApiKey: ApiKey;
createApprovedAccessDomain: ApprovedAccessDomain;
createDatabaseConfigVariable: Scalars['Boolean'];
@ -1151,6 +1175,11 @@ export type MutationComputeStepOutputSchemaArgs = {
};
export type MutationCreateAgentChatThreadArgs = {
input: CreateAgentChatThreadInput;
};
export type MutationCreateApiKeyArgs = {
input: CreateApiKeyDto;
};
@ -1757,6 +1786,9 @@ export type PublishServerlessFunctionInput = {
export type Query = {
__typename?: 'Query';
agentChatMessages: Array<AgentChatMessage>;
agentChatThread: AgentChatThread;
agentChatThreads: Array<AgentChatThread>;
apiKey?: Maybe<ApiKey>;
apiKeys: Array<ApiKey>;
billingPortalSession: BillingSessionOutput;
@ -1802,6 +1834,21 @@ export type Query = {
};
export type QueryAgentChatMessagesArgs = {
threadId: Scalars['String'];
};
export type QueryAgentChatThreadArgs = {
id: Scalars['String'];
};
export type QueryAgentChatThreadsArgs = {
agentId: Scalars['String'];
};
export type QueryApiKeyArgs = {
input: GetApiKeyDto;
};

View File

@ -3,6 +3,8 @@ import { ActionConfigContext } from '@/action-menu/contexts/ActionConfigContext'
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { MessageDescriptor } from '@lingui/core';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { IconComponent } from 'twenty-ui/display';
@ -15,7 +17,7 @@ export const ActionOpenSidePanelPage = ({
shouldResetSearchState = false,
}: {
page: CommandMenuPages;
pageTitle: string;
pageTitle: MessageDescriptor;
pageIcon: IconComponent;
onClick?: () => void;
shouldResetSearchState?: boolean;
@ -35,7 +37,7 @@ export const ActionOpenSidePanelPage = ({
navigateCommandMenu({
page,
pageTitle,
pageTitle: t(pageTitle),
pageIcon,
});

View File

@ -6,7 +6,7 @@ import { ActionType } from '@/action-menu/actions/types/ActionType';
import { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { msg } from '@lingui/core/macro';
import { IconSearch, IconSparkles } from 'twenty-ui/display';
import { IconHistory, IconSearch, IconSparkles } from 'twenty-ui/display';
export const RECORD_AGNOSTIC_ACTIONS_CONFIG: Record<string, ActionConfig> = {
[RecordAgnosticActionsKeys.SEARCH_RECORDS]: {
@ -22,7 +22,7 @@ export const RECORD_AGNOSTIC_ACTIONS_CONFIG: Record<string, ActionConfig> = {
component: (
<ActionOpenSidePanelPage
page={CommandMenuPages.SearchRecords}
pageTitle="Search"
pageTitle={msg`Search`}
pageIcon={IconSearch}
shouldResetSearchState={true}
/>
@ -43,7 +43,7 @@ export const RECORD_AGNOSTIC_ACTIONS_CONFIG: Record<string, ActionConfig> = {
component: (
<ActionOpenSidePanelPage
page={CommandMenuPages.SearchRecords}
pageTitle="Search"
pageTitle={msg`Search`}
pageIcon={IconSearch}
/>
),
@ -63,11 +63,30 @@ export const RECORD_AGNOSTIC_ACTIONS_CONFIG: Record<string, ActionConfig> = {
component: (
<ActionOpenSidePanelPage
page={CommandMenuPages.AskAI}
pageTitle="Ask AI"
pageTitle={msg`Ask AI`}
pageIcon={IconSparkles}
/>
),
hotKeys: ['@'],
shouldBeRegistered: () => true,
},
[RecordAgnosticActionsKeys.VIEW_PREVIOUS_AI_CHATS]: {
type: ActionType.Standard,
scope: ActionScope.Global,
key: RecordAgnosticActionsKeys.VIEW_PREVIOUS_AI_CHATS,
label: msg`View Previous AI Chats`,
shortLabel: msg`Previous AI Chats`,
position: 3,
isPinned: false,
Icon: IconHistory,
availableOn: [ActionViewType.GLOBAL],
component: (
<ActionOpenSidePanelPage
page={CommandMenuPages.ViewPreviousAIChats}
pageTitle={msg`View Previous AI Chats`}
pageIcon={IconSparkles}
/>
),
shouldBeRegistered: () => true,
},
};

View File

@ -19,6 +19,10 @@ export const useRecordAgnosticActions = () => {
if (isAiEnabled) {
actions[RecordAgnosticActionsKeys.ASK_AI] =
RECORD_AGNOSTIC_ACTIONS_CONFIG[RecordAgnosticActionsKeys.ASK_AI];
actions[RecordAgnosticActionsKeys.VIEW_PREVIOUS_AI_CHATS] =
RECORD_AGNOSTIC_ACTIONS_CONFIG[
RecordAgnosticActionsKeys.VIEW_PREVIOUS_AI_CHATS
];
}
return actions;

View File

@ -2,4 +2,5 @@ export enum RecordAgnosticActionsKeys {
SEARCH_RECORDS = 'search-records',
SEARCH_RECORDS_FALLBACK = 'search-records-fallback',
ASK_AI = 'ask-ai',
VIEW_PREVIOUS_AI_CHATS = 'view-previous-ai-chats',
}

View File

@ -0,0 +1,118 @@
import { currentAIChatThreadComponentState } from '@/ai/states/currentAIChatThreadComponentState';
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { IconSparkles } from 'twenty-ui/display';
import { AgentChatThread } from '~/generated-metadata/graphql';
const StyledThreadsList = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledDateGroup = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledDateHeader = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: 600;
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
const StyledThreadItem = styled.div<{ isSelected?: boolean }>`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.spacing(2)};
border-radius: ${({ theme }) => theme.border.radius.sm};
transition: all 0.2s ease;
margin-bottom: ${({ theme }) => theme.spacing(1)};
border-left: 3px solid transparent;
cursor: pointer;
padding: ${({ theme }) => theme.spacing(1, 0.25)};
right: ${({ theme }) => theme.spacing(0.75)};
position: relative;
width: calc(100% + ${({ theme }) => theme.spacing(0.25)});
&:hover {
background: ${({ theme }) => theme.background.transparent.light};
}
`;
const StyledSparkleIcon = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.blue};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
padding: ${({ theme }) => theme.spacing(1)};
justify-content: center;
`;
const StyledThreadContent = styled.div`
flex: 1;
min-width: 0;
`;
const StyledThreadTitle = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const AIChatThreadGroup = ({
threads,
title,
agentId,
}: {
threads: AgentChatThread[];
agentId: string;
title: string;
}) => {
const { t } = useLingui();
const theme = useTheme();
const [, setCurrentThreadId] = useRecoilComponentStateV2(
currentAIChatThreadComponentState,
agentId,
);
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
if (threads.length === 0) {
return null;
}
return (
<StyledDateGroup>
<StyledDateHeader>{title}</StyledDateHeader>
<StyledThreadsList>
{threads.map((thread) => (
<StyledThreadItem
onClick={() => {
setCurrentThreadId(thread.id);
openAskAIPage(thread.title);
}}
key={thread.id}
>
<StyledSparkleIcon>
<IconSparkles
size={theme.icon.size.md}
color={theme.color.blue}
/>
</StyledSparkleIcon>
<StyledThreadContent>
<StyledThreadTitle>
{thread.title || t`Untitled`}
</StyledThreadTitle>
</StyledThreadContent>
</StyledThreadItem>
))}
</StyledThreadsList>
</StyledDateGroup>
);
};

View File

@ -0,0 +1,86 @@
import styled from '@emotion/styled';
import { AIChatThreadGroup } from '@/ai/components/AIChatThreadGroup';
import { AIChatThreadsListEffect } from '@/ai/components/AIChatThreadsListEffect';
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
import { groupThreadsByDate } from '@/ai/utils/groupThreadsByDate';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { AIChatSkeletonLoader } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatSkeletonLoader';
import { Key } from 'ts-key-enum';
import { capitalize } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
import { getOsControlSymbol } from 'twenty-ui/utilities';
import { useGetAgentChatThreadsQuery } from '~/generated-metadata/graphql';
const StyledContainer = styled.div`
background: ${({ theme }) => theme.background.secondary};
border-right: 1px solid ${({ theme }) => theme.border.color.light};
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledThreadsContainer = styled.div`
flex: 1;
overflow-y: auto;
padding: ${({ theme }) => theme.spacing(3)};
`;
const StyledButtonsContainer = styled.div`
display: flex;
padding: ${({ theme }) => theme.spacing(2, 2.5)};
justify-content: flex-end;
border-top: 1px solid ${({ theme }) => theme.border.color.medium};
`;
export const AIChatThreadsList = ({ agentId }: { agentId: string }) => {
const { createAgentChatThread } = useCreateNewAIChatThread({ agentId });
const focusId = `${agentId}-threads-list`;
useHotkeysOnFocusedElement({
keys: [`${Key.Control}+${Key.Enter}`, `${Key.Meta}+${Key.Enter}`],
callback: () => createAgentChatThread(),
focusId,
dependencies: [createAgentChatThread, agentId],
});
const { data: { agentChatThreads = [] } = {}, loading } =
useGetAgentChatThreadsQuery({
variables: { agentId },
});
const groupedThreads = groupThreadsByDate(agentChatThreads);
if (loading === true) {
return <AIChatSkeletonLoader />;
}
return (
<>
<AIChatThreadsListEffect focusId={focusId} />
<StyledContainer>
<StyledThreadsContainer>
{Object.entries(groupedThreads).map(([title, threads]) => (
<AIChatThreadGroup
key={title}
title={capitalize(title)}
agentId={agentId}
threads={threads}
/>
))}
</StyledThreadsContainer>
<StyledButtonsContainer>
<Button
variant="primary"
accent="blue"
size="medium"
title="New chat"
onClick={() => createAgentChatThread()}
hotkeys={[getOsControlSymbol(), '⏎']}
/>
</StyledButtonsContainer>
</StyledContainer>
</>
);
};

View File

@ -0,0 +1,26 @@
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useEffect } from 'react';
export const AIChatThreadsListEffect = ({ focusId }: { focusId: string }) => {
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
useEffect(() => {
pushFocusItemToFocusStack({
focusId,
component: {
type: FocusComponentType.SIDE_PANEL,
instanceId: focusId,
},
});
return () => {
removeFocusItemFromFocusStackById({ focusId });
};
}, [pushFocusItemToFocusStack, removeFocusItemFromFocusStackById, focusId]);
return <></>;
};

View File

@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const CREATE_AGENT_CHAT_THREAD = gql`
mutation CreateAgentChatThread($input: CreateAgentChatThreadInput!) {
createAgentChatThread(input: $input) {
id
agentId
title
createdAt
updatedAt
}
}
`;

View File

@ -0,0 +1,21 @@
import { gql } from '@apollo/client';
export const GET_AGENT_CHAT_MESSAGES = gql`
query GetAgentChatMessages($threadId: String!) {
agentChatMessages(threadId: $threadId) {
id
threadId
role
content
createdAt
files {
id
name
fullPath
size
type
createdAt
}
}
}
`;

View File

@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const GET_AGENT_CHAT_THREADS = gql`
query GetAgentChatThreads($agentId: String!) {
agentChatThreads(agentId: $agentId) {
id
agentId
title
createdAt
updatedAt
}
}
`;

View File

@ -0,0 +1,22 @@
import { currentAIChatThreadComponentState } from '@/ai/states/currentAIChatThreadComponentState';
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useCreateAgentChatThreadMutation } from '~/generated-metadata/graphql';
export const useCreateNewAIChatThread = ({ agentId }: { agentId: string }) => {
const [, setCurrentThreadId] = useRecoilComponentStateV2(
currentAIChatThreadComponentState,
agentId,
);
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
const [createAgentChatThread] = useCreateAgentChatThreadMutation({
variables: { input: { agentId } },
onCompleted: (data) => {
setCurrentThreadId(data.createAgentChatThread.id);
openAskAIPage();
},
});
return { createAgentChatThread };
};

View File

@ -0,0 +1,10 @@
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const currentAIChatThreadComponentState = createComponentStateV2<
string | null
>({
key: 'currentAIChatThreadComponentState',
defaultValue: null,
componentInstanceContext: CommandMenuPageComponentInstanceContext,
});

View File

@ -0,0 +1,29 @@
import { AgentChatThread } from '~/generated-metadata/graphql';
export const groupThreadsByDate = (threads: AgentChatThread[]) => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
return threads.reduce<{
today: AgentChatThread[];
yesterday: AgentChatThread[];
older: AgentChatThread[];
}>(
(acc, thread) => {
const threadDate = new Date(thread.createdAt);
const threadDateString = threadDate.toDateString();
if (threadDateString === today.toDateString()) {
acc.today.push(thread);
} else if (threadDateString === yesterday.toDateString()) {
acc.yesterday.push(thread);
} else {
acc.older.push(thread);
}
return acc;
},
{ today: [], yesterday: [], older: [] },
);
};

View File

@ -76,7 +76,7 @@ export const CommandMenuContextChip = ({
<Fragment key={index}>{Icon}</Fragment>
))}
</StyledIconsContainer>
{text?.trim() ? (
{text?.trim?.() ? (
<OverflowingTextWithTooltip text={text} />
) : !forceEmptyText ? (
<StyledEmptyText>Untitled</StyledEmptyText>

View File

@ -1,4 +1,5 @@
import { CommandMenu } from '@/command-menu/components/CommandMenu';
import { CommandMenuAIChatThreadsPage } from '@/command-menu/pages/AIChatThreads/components/CommandMenuAIChatThreadsPage';
import { CommandMenuAskAIPage } from '@/command-menu/pages/ask-ai/components/CommandMenuAskAIPage';
import { CommandMenuCalendarEventPage } from '@/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage';
import { CommandMenuMessageThreadPage } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadPage';
@ -34,4 +35,5 @@ export const COMMAND_MENU_PAGES_CONFIG = new Map<
[CommandMenuPages.WorkflowRunStepView, <CommandMenuWorkflowRunViewStep />],
[CommandMenuPages.SearchRecords, <CommandMenuSearchRecordsPage />],
[CommandMenuPages.AskAI, <CommandMenuAskAIPage />],
[CommandMenuPages.ViewPreviousAIChats, <CommandMenuAIChatThreadsPage />],
]);

View File

@ -7,10 +7,10 @@ import { v4 } from 'uuid';
export const useOpenAskAIPageInCommandMenu = () => {
const { navigateCommandMenu } = useCommandMenu();
const openAskAIPage = () => {
const openAskAIPage = (pageTitle?: string | null) => {
navigateCommandMenu({
page: CommandMenuPages.AskAI,
pageTitle: t`Ask AI`,
pageTitle: pageTitle ?? t`Ask AI`,
pageIcon: IconSparkles,
pageId: v4(),
});

View File

@ -0,0 +1,37 @@
import { AIChatThreadsList } from '@/ai/components/AIChatThreadsList';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
const StyledContainer = styled.div`
height: 100%;
width: 100%;
`;
const StyledEmptyState = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
font-size: ${({ theme }) => theme.font.size.md};
height: 100%;
justify-content: center;
`;
export const CommandMenuAIChatThreadsPage = () => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const agentId = currentWorkspace?.defaultAgent?.id;
if (!agentId) {
return (
<StyledContainer>
<StyledEmptyState>No AI Agent found.</StyledEmptyState>
</StyledContainer>
);
}
return (
<StyledContainer>
<AIChatThreadsList agentId={agentId} />
</StyledContainer>
);
};

View File

@ -12,4 +12,5 @@ export enum CommandMenuPages {
WorkflowRunStepView = 'workflow-run-step-view',
SearchRecords = 'search-records',
AskAI = 'ask-ai',
ViewPreviousAIChats = 'view-previous-ai-chats',
}

View File

@ -49,7 +49,7 @@ export const MainNavigationDrawerFixedItems = () => {
<NavigationDrawerItem
label={t`Ask AI`}
Icon={IconSparkles}
onClick={openAskAIPage}
onClick={() => openAskAIPage()}
keyboard={['@']}
mouseUpNavigation={true}
/>

View File

@ -9,6 +9,7 @@ export const GET_AGENT_CHAT_THREADS = gql`
) {
id
agentId
title
createdAt
updatedAt
}

View File

@ -1,18 +1,28 @@
import { TextArea } from '@/ui/input/components/TextArea';
import { keyframes, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Avatar, IconDotsVertical, IconSparkles } from 'twenty-ui/display';
import {
Avatar,
IconDotsVertical,
IconHistory,
IconMessageCirclePlus,
IconSparkles,
} from 'twenty-ui/display';
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { AgentChatFilePreview } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFilePreview';
import { AgentChatFileUpload } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFileUpload';
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { t } from '@lingui/core/macro';
import { Button } from 'twenty-ui/input';
import { AgentChatMessage } from '~/generated/graphql';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { useAgentChat } from '../hooks/useAgentChat';
import { AgentChatMessage } from '../hooks/useAgentChatMessages';
import { AIChatSkeletonLoader } from './AIChatSkeletonLoader';
import { AgentChatSelectedFilesPreview } from './AgentChatSelectedFilesPreview';
@ -188,7 +198,13 @@ const StyledFilesContainer = styled.div`
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const AIChatTab = ({ agentId }: { agentId: string }) => {
export const AIChatTab = ({
agentId,
isWorkflowAgentNodeChat,
}: {
agentId: string;
isWorkflowAgentNodeChat?: boolean;
}) => {
const theme = useTheme();
const {
@ -201,6 +217,9 @@ export const AIChatTab = ({ agentId }: { agentId: string }) => {
scrollWrapperId,
} = useAgentChat(agentId);
const { createAgentChatThread } = useCreateNewAIChatThread({ agentId });
const { navigateCommandMenu } = useCommandMenu();
const getAssistantMessageContent = (message: AgentChatMessage) => {
if (message.content !== '') {
return message.content;
@ -308,6 +327,28 @@ export const AIChatTab = ({ agentId }: { agentId: string }) => {
onChange={handleInputChange}
/>
<StyledButtonsContainer>
{!isWorkflowAgentNodeChat && (
<>
<Button
variant="secondary"
size="small"
Icon={IconHistory}
onClick={() =>
navigateCommandMenu({
page: CommandMenuPages.ViewPreviousAIChats,
pageTitle: t`View Previous AI Chats`,
pageIcon: IconHistory,
})
}
/>
<Button
variant="secondary"
size="small"
Icon={IconMessageCirclePlus}
onClick={() => createAgentChatThread()}
/>
</>
)}
<AgentChatFileUpload agentId={agentId} />
<Button
variant="primary"

View File

@ -108,7 +108,7 @@ export const WorkflowEditActionAiAgent = ({
componentInstanceId={WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID}
/>
{activeTabId === WorkflowAiAgentTabId.CHAT ? (
<AIChatTab agentId={agentId} />
<AIChatTab agentId={agentId} isWorkflowAgentNodeChat />
) : (
<>
<WorkflowStepHeader

View File

@ -4,6 +4,7 @@ import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { currentAIChatThreadComponentState } from '@/ai/states/currentAIChatThreadComponentState';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperElement';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@ -12,17 +13,21 @@ import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions
import { agentChatSelectedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatSelectedFilesComponentState';
import { agentChatUploadedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatUploadedFilesComponentState';
import { useApolloClient } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import {
useGetAgentChatMessagesQuery,
useGetAgentChatThreadsQuery,
} from '~/generated-metadata/graphql';
import { AgentChatMessage } from '~/generated/graphql';
import { agentChatInputState } from '../states/agentChatInputState';
import { agentChatMessagesComponentState } from '../states/agentChatMessagesComponentState';
import { agentStreamingMessageState } from '../states/agentStreamingMessageState';
import { parseAgentStreamingChunk } from '../utils/parseAgentStreamingChunk';
import { AgentChatMessage, useAgentChatMessages } from './useAgentChatMessages';
import { useAgentChatThreads } from './useAgentChatThreads';
interface OptimisticMessage extends AgentChatMessage {
type OptimisticMessage = AgentChatMessage & {
isPending: boolean;
}
};
export const useAgentChat = (agentId: string) => {
const apolloClient = useApolloClient();
@ -33,6 +38,10 @@ export const useAgentChat = (agentId: string) => {
agentId,
);
const [currentThreadId, setCurrentThreadId] = useRecoilComponentStateV2(
currentAIChatThreadComponentState,
agentId,
);
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
useRecoilComponentStateV2(agentChatUploadedFilesComponentState, agentId);
@ -61,26 +70,37 @@ export const useAgentChat = (agentId: string) => {
});
};
const { data: { threads = [] } = {}, loading: threadsLoading } =
useAgentChatThreads(agentId);
const currentThreadId = threads[0]?.id;
const { loading: threadsLoading } = useGetAgentChatThreadsQuery({
variables: { agentId },
skip: isDefined(currentThreadId),
onCompleted: (data) => {
if (data.agentChatThreads.length > 0) {
setCurrentThreadId(data.agentChatThreads[0].id);
}
},
});
const { loading: messagesLoading, refetch: refetchMessages } =
useAgentChatMessages(currentThreadId, ({ messages }) => {
setAgentChatMessages(messages);
scrollToBottom();
useGetAgentChatMessagesQuery({
variables: { threadId: currentThreadId as string },
skip: !isDefined(currentThreadId),
onCompleted: ({ agentChatMessages }) => {
setAgentChatMessages(agentChatMessages);
scrollToBottom();
},
});
const isLoading =
messagesLoading ||
threadsLoading ||
!currentThreadId ||
isStreaming ||
agentChatSelectedFiles.length > 0;
const createOptimisticMessages = (content: string): AgentChatMessage[] => {
const optimisticUserMessage: OptimisticMessage = {
id: v4(),
threadId: currentThreadId,
threadId: currentThreadId as string,
role: AgentChatMessageRole.USER,
content,
createdAt: new Date().toISOString(),
@ -90,7 +110,7 @@ export const useAgentChat = (agentId: string) => {
const optimisticAiMessage: OptimisticMessage = {
id: v4(),
threadId: currentThreadId,
threadId: currentThreadId as string,
role: AgentChatMessageRole.ASSISTANT,
content: '',
createdAt: new Date().toISOString(),
@ -163,7 +183,7 @@ export const useAgentChat = (agentId: string) => {
const { data } = await refetchMessages();
setAgentChatMessages(data?.messages);
setAgentChatMessages(data?.agentChatMessages);
setAgentStreamingMessage({
toolCall: '',
streamingText: '',
@ -172,7 +192,7 @@ export const useAgentChat = (agentId: string) => {
};
const handleSendMessage = async () => {
if (!agentChatInput.trim() || isLoading) {
if (agentChatInput.trim() === '' || isLoading === true) {
return;
}
const content = agentChatInput.trim();

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { AgentChatMessage } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentChatMessages';
import { AgentChatMessage } from '~/generated-metadata/graphql';
export const AgentChatMessagesComponentInstanceContext =
createComponentInstanceContext();

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTitleToAgentChatThread1752543000368
implements MigrationInterface
{
name = 'AddTitleToAgentChatThread1752543000368';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" ADD "title" character varying`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" DROP COLUMN "title"`,
);
}
}

View File

@ -42,6 +42,9 @@ export class AgentChatThreadEntity {
@JoinColumn({ name: 'userWorkspaceId' })
userWorkspace: Relation<UserWorkspace>;
@Column({ nullable: true, type: 'varchar' })
title: string;
@OneToMany(() => AgentChatMessageEntity, (message) => message.thread)
messages: Relation<AgentChatMessageEntity[]>;

View File

@ -0,0 +1,60 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import {
FeatureFlagGuard,
RequireFeatureFlag,
} from 'src/engine/guards/feature-flag.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AgentChatService } from 'src/engine/metadata-modules/agent/agent-chat.service';
import { AgentChatMessageDTO } from './dtos/agent-chat-message.dto';
import { AgentChatThreadDTO } from './dtos/agent-chat-thread.dto';
import { CreateAgentChatThreadInput } from './dtos/create-agent-chat-thread.input';
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
@Resolver()
export class AgentChatResolver {
constructor(private readonly agentChatService: AgentChatService) {}
@Query(() => [AgentChatThreadDTO])
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async agentChatThreads(
@Args('agentId') agentId: string,
@AuthUserWorkspaceId() userWorkspaceId: string,
) {
return this.agentChatService.getThreadsForAgent(agentId, userWorkspaceId);
}
@Query(() => AgentChatThreadDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async agentChatThread(
@Args('id') id: string,
@AuthUserWorkspaceId() userWorkspaceId: string,
) {
return this.agentChatService.getThreadById(id, userWorkspaceId);
}
@Query(() => [AgentChatMessageDTO])
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async agentChatMessages(
@Args('threadId') threadId: string,
@AuthUserWorkspaceId() userWorkspaceId: string,
) {
return this.agentChatService.getMessagesForThread(
threadId,
userWorkspaceId,
);
}
@Mutation(() => AgentChatThreadDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async createAgentChatThread(
@Args('input') input: CreateAgentChatThreadInput,
@AuthUserWorkspaceId() userWorkspaceId: string,
) {
return this.agentChatService.createThread(input.agentId, userWorkspaceId);
}
}

View File

@ -14,6 +14,8 @@ import {
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
import { AgentTitleGenerationService } from './agent-title-generation.service';
@Injectable()
export class AgentChatService {
constructor(
@ -23,6 +25,7 @@ export class AgentChatService {
private readonly messageRepository: Repository<AgentChatMessageEntity>,
@InjectRepository(FileEntity, 'core')
private readonly fileRepository: Repository<FileEntity>,
private readonly titleGenerationService: AgentTitleGenerationService,
) {}
async createThread(agentId: string, userWorkspaceId: string) {
@ -44,6 +47,24 @@ export class AgentChatService {
});
}
async getThreadById(threadId: string, userWorkspaceId: string) {
const thread = await this.threadRepository.findOne({
where: {
id: threadId,
userWorkspaceId,
},
});
if (!thread) {
throw new AgentException(
'Thread not found',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
return thread;
}
async addMessage({
threadId,
role,
@ -71,6 +92,8 @@ export class AgentChatService {
}
}
this.generateTitleIfNeeded(threadId, content);
return savedMessage;
}
@ -95,4 +118,23 @@ export class AgentChatService {
relations: ['files'],
});
}
private async generateTitleIfNeeded(
threadId: string,
messageContent: string,
) {
const thread = await this.threadRepository.findOne({
where: { id: threadId },
select: ['id', 'title'],
});
if (!thread || thread.title) {
return;
}
const title =
await this.titleGenerationService.generateThreadTitle(messageContent);
await this.threadRepository.update(threadId, { title });
}
}

View File

@ -0,0 +1,53 @@
import { Injectable, Logger } from '@nestjs/common';
import { generateText } from 'ai';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class AgentTitleGenerationService {
private readonly logger = new Logger(AgentTitleGenerationService.name);
constructor(
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async generateThreadTitle(messageContent: string): Promise<string> {
try {
const defaultModel = this.aiModelRegistryService.getDefaultModel();
if (!defaultModel) {
this.logger.warn('No default AI model available for title generation');
return this.generateFallbackTitle(messageContent);
}
const result = await generateText({
model: defaultModel.model,
prompt: `Generate a concise, descriptive title (maximum 60 characters) for a chat thread based on the following message. The title should capture the main topic or purpose of the conversation. Return only the title, nothing else. Message: "${messageContent}"`,
});
return this.cleanTitle(result.text);
} catch (error) {
this.logger.error('Failed to generate title with AI:', error);
return this.generateFallbackTitle(messageContent);
}
}
private generateFallbackTitle(messageContent: string): string {
const cleanContent = messageContent.trim().replace(/\s+/g, ' ');
const title = cleanContent.substring(0, 50);
return cleanContent.length > 50 ? `${title}...` : title;
}
private cleanTitle(title: string): string {
return title
.replace(/^["']|["']$/g, '')
.trim()
.replace(/\s+/g, ' ');
}
}

View File

@ -19,9 +19,11 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
import { AgentChatMessageEntity } from './agent-chat-message.entity';
import { AgentChatThreadEntity } from './agent-chat-thread.entity';
import { AgentChatResolver } from './agent-chat.resolver';
import { AgentChatService } from './agent-chat.service';
import { AgentExecutionService } from './agent-execution.service';
import { AgentStreamingService } from './agent-streaming.service';
import { AgentTitleGenerationService } from './agent-title-generation.service';
import { AgentToolService } from './agent-tool.service';
import { AgentEntity } from './agent.entity';
import { AgentResolver } from './agent.resolver';
@ -55,11 +57,13 @@ import { AgentService } from './agent.service';
controllers: [AgentChatController],
providers: [
AgentResolver,
AgentChatResolver,
AgentService,
AgentExecutionService,
AgentToolService,
AgentChatService,
AgentStreamingService,
AgentTitleGenerationService,
],
exports: [
AgentService,
@ -67,6 +71,7 @@ import { AgentService } from './agent.service';
AgentToolService,
AgentChatService,
AgentStreamingService,
AgentTitleGenerationService,
TypeOrmModule.forFeature(
[AgentEntity, AgentChatMessageEntity, AgentChatThreadEntity],
'core',

View File

@ -1,13 +1,14 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
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';
@ObjectType('AgentChatMessage')
export class AgentChatMessageDTO {
@Field(() => ID)
@Field(() => UUIDScalarType)
id: string;
@Field(() => ID)
@Field(() => UUIDScalarType)
threadId: string;
@Field()
@ -16,8 +17,8 @@ export class AgentChatMessageDTO {
@Field()
content: string;
@Field(() => [FileDTO], { nullable: true })
files?: FileDTO[];
@Field(() => [FileDTO])
files: FileDTO[];
@Field()
createdAt: Date;

View File

@ -1,13 +1,18 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('AgentChatThread')
export class AgentChatThreadDTO {
@Field(() => ID)
@Field(() => UUIDScalarType)
id: string;
@Field(() => ID)
@Field(() => UUIDScalarType)
agentId: string;
@Field({ nullable: true })
title: string;
@Field()
createdAt: Date;

View File

@ -0,0 +1,12 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class CreateAgentChatThreadInput {
@IsNotEmpty()
@Field(() => UUIDScalarType)
agentId: string;
}

View File

@ -212,6 +212,7 @@ export {
IconMap,
IconMaximize,
IconMessage,
IconMessageCirclePlus,
IconMinus,
IconMoneybag,
IconMoodSmile,

View File

@ -274,6 +274,7 @@ export {
IconMap,
IconMaximize,
IconMessage,
IconMessageCirclePlus,
IconMinus,
IconMoneybag,
IconMoodSmile,