diff --git a/packages/twenty-front/codegen-metadata.cjs b/packages/twenty-front/codegen-metadata.cjs index ee6da2070..d66643135 100644 --- a/packages/twenty-front/codegen-metadata.cjs +++ b/packages/twenty-front/codegen-metadata.cjs @@ -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}', diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 946747c9b..518b7672e 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -62,6 +62,25 @@ export type Agent = { updatedAt: Scalars['DateTime']; }; +export type AgentChatMessage = { + __typename?: 'AgentChatMessage'; + content: Scalars['String']; + createdAt: Scalars['DateTime']; + files: Array; + 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; + updatedAt: Scalars['DateTime']; +}; + export type AgentIdInput = { /** The id of the agent. */ id: Scalars['UUID']; @@ -443,6 +462,10 @@ export type ConnectionParametersOutput = { username?: Maybe; }; +export type CreateAgentChatThreadInput = { + agentId: Scalars['UUID']; +}; + export type CreateApiKeyDto = { expiresAt: Scalars['String']; name: Scalars['String']; @@ -1070,6 +1093,7 @@ export type Mutation = { checkCustomDomainValidRecords?: Maybe; 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; + agentChatThread: AgentChatThread; + agentChatThreads: Array; apiKey?: Maybe; apiKeys: Array; 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; @@ -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; + +/** + * __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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateAgentChatThreadDocument, options); + } +export type CreateAgentChatThreadMutationHookResult = ReturnType; +export type CreateAgentChatThreadMutationResult = Apollo.MutationResult; +export type CreateAgentChatThreadMutationOptions = Apollo.BaseMutationOptions; +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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetAgentChatMessagesDocument, options); + } +export function useGetAgentChatMessagesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetAgentChatMessagesDocument, options); + } +export type GetAgentChatMessagesQueryHookResult = ReturnType; +export type GetAgentChatMessagesLazyQueryHookResult = ReturnType; +export type GetAgentChatMessagesQueryResult = Apollo.QueryResult; +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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetAgentChatThreadsDocument, options); + } +export function useGetAgentChatThreadsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetAgentChatThreadsDocument, options); + } +export type GetAgentChatThreadsQueryHookResult = ReturnType; +export type GetAgentChatThreadsLazyQueryHookResult = ReturnType; +export type GetAgentChatThreadsQueryResult = Apollo.QueryResult; export const TrackAnalyticsDocument = gql` mutation TrackAnalytics($type: AnalyticsType!, $event: String, $name: String, $properties: JSON) { trackAnalytics(type: $type, event: $event, name: $name, properties: $properties) { diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index afcffd49a..72e911bc8 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -62,6 +62,25 @@ export type Agent = { updatedAt: Scalars['DateTime']; }; +export type AgentChatMessage = { + __typename?: 'AgentChatMessage'; + content: Scalars['String']; + createdAt: Scalars['DateTime']; + files: Array; + 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; + updatedAt: Scalars['DateTime']; +}; + export type AgentIdInput = { /** The id of the agent. */ id: Scalars['UUID']; @@ -443,6 +462,10 @@ export type ConnectionParametersOutput = { username?: Maybe; }; +export type CreateAgentChatThreadInput = { + agentId: Scalars['UUID']; +}; + export type CreateApiKeyDto = { expiresAt: Scalars['String']; name: Scalars['String']; @@ -1027,6 +1050,7 @@ export type Mutation = { checkCustomDomainValidRecords?: Maybe; 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; + agentChatThread: AgentChatThread; + agentChatThreads: Array; apiKey?: Maybe; apiKeys: Array; 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; }; diff --git a/packages/twenty-front/src/modules/action-menu/actions/components/ActionOpenSidePanelPage.tsx b/packages/twenty-front/src/modules/action-menu/actions/components/ActionOpenSidePanelPage.tsx index 7ee55d9ab..3e47b159d 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/components/ActionOpenSidePanelPage.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/components/ActionOpenSidePanelPage.tsx @@ -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, }); diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/constants/RecordAgnosticActionsConfig.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/constants/RecordAgnosticActionsConfig.tsx index 1ae0368e1..97714be36 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/constants/RecordAgnosticActionsConfig.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/constants/RecordAgnosticActionsConfig.tsx @@ -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 = { [RecordAgnosticActionsKeys.SEARCH_RECORDS]: { @@ -22,7 +22,7 @@ export const RECORD_AGNOSTIC_ACTIONS_CONFIG: Record = { component: ( @@ -43,7 +43,7 @@ export const RECORD_AGNOSTIC_ACTIONS_CONFIG: Record = { component: ( ), @@ -63,11 +63,30 @@ export const RECORD_AGNOSTIC_ACTIONS_CONFIG: Record = { component: ( ), 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: ( + + ), + shouldBeRegistered: () => true, + }, }; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/useRecordAgnosticActions.ts b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/useRecordAgnosticActions.ts index c3ab8ac9c..7f61ec20e 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/useRecordAgnosticActions.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/useRecordAgnosticActions.ts @@ -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; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/types/RecordAgnosticActionsKeys.ts b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/types/RecordAgnosticActionsKeys.ts index 56c7e74bb..4ddfe046c 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/types/RecordAgnosticActionsKeys.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/types/RecordAgnosticActionsKeys.ts @@ -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', } diff --git a/packages/twenty-front/src/modules/ai/components/AIChatThreadGroup.tsx b/packages/twenty-front/src/modules/ai/components/AIChatThreadGroup.tsx new file mode 100644 index 000000000..cd9d1c236 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/AIChatThreadGroup.tsx @@ -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 ( + + {title} + + {threads.map((thread) => ( + { + setCurrentThreadId(thread.id); + openAskAIPage(thread.title); + }} + key={thread.id} + > + + + + + + {thread.title || t`Untitled`} + + + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/ai/components/AIChatThreadsList.tsx b/packages/twenty-front/src/modules/ai/components/AIChatThreadsList.tsx new file mode 100644 index 000000000..89f89dcd2 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/AIChatThreadsList.tsx @@ -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 ; + } + + return ( + <> + + + + {Object.entries(groupedThreads).map(([title, threads]) => ( + + ))} + + +