diff --git a/packages/twenty-front/src/generated-admin/graphql.ts b/packages/twenty-front/src/generated-admin/graphql.ts index b9e68a728e..e72a841bc1 100644 --- a/packages/twenty-front/src/generated-admin/graphql.ts +++ b/packages/twenty-front/src/generated-admin/graphql.ts @@ -56,23 +56,70 @@ export type AdminChatMessage = { __typename?: 'AdminChatMessage'; createdAt: Scalars['DateTime']['output']; id: Scalars['UUID']['output']; + isHidden: Scalars['Boolean']['output']; parts: Array; role: AgentMessageRole; }; export type AdminChatMessagePart = { __typename?: 'AdminChatMessagePart'; + errorMessage?: Maybe; + orderIndex: Scalars['Int']['output']; + reasoningContent?: Maybe; + state?: Maybe; textContent?: Maybe; + toolCallId?: Maybe; + toolInput?: Maybe; toolName?: Maybe; + toolOutput?: Maybe; type: Scalars['String']['output']; }; +export type AdminChatThreadListItem = { + __typename?: 'AdminChatThreadListItem'; + createdAt: Scalars['DateTime']['output']; + deletedAt?: Maybe; + hasError: Scalars['Boolean']['output']; + id: Scalars['UUID']['output']; + isOnboardingThread: Scalars['Boolean']['output']; + messageCount: Scalars['Int']['output']; + title?: Maybe; + updatedAt: Scalars['DateTime']['output']; + userEmail?: Maybe; + userFirstName?: Maybe; + userLastName?: Maybe; + userReplyCount: Scalars['Int']['output']; + userWorkspaceId: Scalars['UUID']['output']; + workspaceDisplayName?: Maybe; + workspaceId: Scalars['UUID']['output']; +}; + export type AdminChatThreadMessages = { __typename?: 'AdminChatThreadMessages'; messages: Array; thread: AdminWorkspaceChatThread; }; +/** Scope of chat threads to list in the admin panel */ +export enum AdminChatThreadScope { + ALL = 'ALL', + ONBOARDING = 'ONBOARDING' +} + +/** Direction to sort admin chat threads */ +export enum AdminChatThreadSortDirection { + ASC = 'ASC', + DESC = 'DESC' +} + +/** Field to sort admin chat threads by */ +export enum AdminChatThreadSortField { + CREATED_AT = 'CREATED_AT', + MESSAGE_COUNT = 'MESSAGE_COUNT', + REPLY_COUNT = 'REPLY_COUNT', + UPDATED_AT = 'UPDATED_AT' +} + export type AdminPanelHealthServiceData = { __typename?: 'AdminPanelHealthServiceData'; description: Scalars['String']['output']; @@ -169,6 +216,7 @@ export type AdminWorkspaceChatThread = { conversationSize: Scalars['Int']['output']; createdAt: Scalars['DateTime']['output']; id: Scalars['UUID']['output']; + messageCount: Scalars['Int']['output']; title?: Maybe; totalInputTokens: Scalars['Int']['output']; totalOutputTokens: Scalars['Int']['output']; @@ -581,6 +629,13 @@ export type MutationUpdateWorkspaceFeatureFlagArgs = { workspaceId: Scalars['UUID']['input']; }; +export type PaginatedAdminChatThreads = { + __typename?: 'PaginatedAdminChatThreads'; + hasMore: Scalars['Boolean']['output']; + threads: Array; + totalCount: Scalars['Int']['output']; +}; + export type PaginatedApplicationRegistrations = { __typename?: 'PaginatedApplicationRegistrations'; hasMore: Scalars['Boolean']['output']; @@ -601,6 +656,7 @@ export type Query = { getAdminAiModels: AdminAiModels; getAdminAiUsageByWorkspace: Array; getAdminChatThreadMessages: AdminChatThreadMessages; + getAdminChatThreads: PaginatedAdminChatThreads; getAdminWorkspaceChatThreads: Array; getAiProviders: Scalars['JSON']['output']; getConfigVariablesGrouped: ConfigVariables; @@ -677,6 +733,18 @@ export type QueryGetAdminChatThreadMessagesArgs = { }; +export type QueryGetAdminChatThreadsArgs = { + hasErrorOnly?: InputMaybe; + limit?: InputMaybe; + offset?: InputMaybe; + scope?: InputMaybe; + searchTerm?: InputMaybe; + sortBy?: InputMaybe; + sortDirection?: InputMaybe; + userNeverEngagedOnly?: InputMaybe; +}; + + export type QueryGetAdminWorkspaceChatThreadsArgs = { workspaceId: Scalars['UUID']['input']; }; @@ -1215,14 +1283,28 @@ export type GetAdminChatThreadMessagesQueryVariables = Exact<{ }>; -export type GetAdminChatThreadMessagesQuery = { __typename?: 'Query', getAdminChatThreadMessages: { __typename?: 'AdminChatThreadMessages', thread: { __typename?: 'AdminWorkspaceChatThread', id: string, title?: string | null, totalInputTokens: number, totalOutputTokens: number, conversationSize: number, createdAt: string, updatedAt: string }, messages: Array<{ __typename?: 'AdminChatMessage', id: string, role: AgentMessageRole, createdAt: string, parts: Array<{ __typename?: 'AdminChatMessagePart', type: string, textContent?: string | null, toolName?: string | null }> }> } }; +export type GetAdminChatThreadMessagesQuery = { __typename?: 'Query', getAdminChatThreadMessages: { __typename?: 'AdminChatThreadMessages', thread: { __typename?: 'AdminWorkspaceChatThread', id: string, title?: string | null, totalInputTokens: number, totalOutputTokens: number, conversationSize: number, messageCount: number, createdAt: string, updatedAt: string }, messages: Array<{ __typename?: 'AdminChatMessage', id: string, role: AgentMessageRole, isHidden: boolean, createdAt: string, parts: Array<{ __typename?: 'AdminChatMessagePart', type: string, orderIndex: number, textContent?: string | null, reasoningContent?: string | null, toolName?: string | null, toolCallId?: string | null, toolInput?: any | null, toolOutput?: any | null, state?: string | null, errorMessage?: string | null }> }> } }; + +export type GetAdminChatThreadsQueryVariables = Exact<{ + scope?: InputMaybe; + hasErrorOnly?: InputMaybe; + userNeverEngagedOnly?: InputMaybe; + searchTerm?: InputMaybe; + sortBy?: InputMaybe; + sortDirection?: InputMaybe; + limit?: InputMaybe; + offset?: InputMaybe; +}>; + + +export type GetAdminChatThreadsQuery = { __typename?: 'Query', getAdminChatThreads: { __typename?: 'PaginatedAdminChatThreads', totalCount: number, hasMore: boolean, threads: Array<{ __typename?: 'AdminChatThreadListItem', id: string, title?: string | null, workspaceId: string, workspaceDisplayName?: string | null, userWorkspaceId: string, userEmail?: string | null, userFirstName?: string | null, userLastName?: string | null, messageCount: number, userReplyCount: number, hasError: boolean, isOnboardingThread: boolean, deletedAt?: string | null, createdAt: string, updatedAt: string }> } }; export type GetAdminWorkspaceChatThreadsQueryVariables = Exact<{ workspaceId: Scalars['UUID']['input']; }>; -export type GetAdminWorkspaceChatThreadsQuery = { __typename?: 'Query', getAdminWorkspaceChatThreads: Array<{ __typename?: 'AdminWorkspaceChatThread', id: string, title?: string | null, totalInputTokens: number, totalOutputTokens: number, conversationSize: number, createdAt: string, updatedAt: string }> }; +export type GetAdminWorkspaceChatThreadsQuery = { __typename?: 'Query', getAdminWorkspaceChatThreads: Array<{ __typename?: 'AdminWorkspaceChatThread', id: string, title?: string | null, totalInputTokens: number, totalOutputTokens: number, conversationSize: number, messageCount: number, createdAt: string, updatedAt: string }> }; export type GetServerAdminsQueryVariables = Exact<{ [key: string]: never; }>; @@ -1385,8 +1467,9 @@ export const UpdateWorkspaceFeatureFlagDocument = {"kind":"Document","definition export const AdminPanelRecentUsersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AdminPanelRecentUsers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"adminPanelRecentUsers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"searchTerm"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceLogo"}}]}}]}}]} as unknown as DocumentNode; export const AdminPanelTopWorkspacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AdminPanelTopWorkspaces"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"adminPanelTopWorkspaces"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"searchTerm"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"totalUsers"}},{"kind":"Field","name":{"kind":"Name","value":"subdomain"}}]}}]}}]} as unknown as DocumentNode; export const FindOneAdminApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneAdminApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findOneAdminApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"galleryImagesUrls"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isVetted"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; -export const GetAdminChatThreadMessagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminChatThreadMessages"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminChatThreadMessages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"thread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; -export const GetAdminWorkspaceChatThreadsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminWorkspaceChatThreads"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminWorkspaceChatThreads"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; +export const GetAdminChatThreadMessagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminChatThreadMessages"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminChatThreadMessages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"thread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"messageCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"isHidden"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"orderIndex"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"reasoningContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}},{"kind":"Field","name":{"kind":"Name","value":"toolCallId"}},{"kind":"Field","name":{"kind":"Name","value":"toolInput"}},{"kind":"Field","name":{"kind":"Name","value":"toolOutput"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetAdminChatThreadsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminChatThreads"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"scope"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"AdminChatThreadScope"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"hasErrorOnly"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userNeverEngagedOnly"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sortBy"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"AdminChatThreadSortField"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sortDirection"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"AdminChatThreadSortDirection"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminChatThreads"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"scope"},"value":{"kind":"Variable","name":{"kind":"Name","value":"scope"}}},{"kind":"Argument","name":{"kind":"Name","value":"hasErrorOnly"},"value":{"kind":"Variable","name":{"kind":"Name","value":"hasErrorOnly"}}},{"kind":"Argument","name":{"kind":"Name","value":"userNeverEngagedOnly"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userNeverEngagedOnly"}}},{"kind":"Argument","name":{"kind":"Name","value":"searchTerm"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}}},{"kind":"Argument","name":{"kind":"Name","value":"sortBy"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sortBy"}}},{"kind":"Argument","name":{"kind":"Name","value":"sortDirection"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sortDirection"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"hasMore"}},{"kind":"Field","name":{"kind":"Name","value":"threads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceDisplayName"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"userEmail"}},{"kind":"Field","name":{"kind":"Name","value":"userFirstName"}},{"kind":"Field","name":{"kind":"Name","value":"userLastName"}},{"kind":"Field","name":{"kind":"Name","value":"messageCount"}},{"kind":"Field","name":{"kind":"Name","value":"userReplyCount"}},{"kind":"Field","name":{"kind":"Name","value":"hasError"}},{"kind":"Field","name":{"kind":"Name","value":"isOnboardingThread"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetAdminWorkspaceChatThreadsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminWorkspaceChatThreads"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminWorkspaceChatThreads"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"messageCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; export const GetServerAdminsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetServerAdmins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getServerAdmins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessFullAdminPanel"}},{"kind":"Field","name":{"kind":"Name","value":"canImpersonate"}}]}}]}}]} as unknown as DocumentNode; export const GetUpgradeStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUpgradeStatus"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getUpgradeStatus"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceIds"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"inferredVersion"}},{"kind":"Field","name":{"kind":"Name","value":"health"}},{"kind":"Field","name":{"kind":"Name","value":"latestCommand"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"executedByVersion"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetVersionInfoDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetVersionInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"versionInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentVersion"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/modules/ai/components/ChatReferenceChipDisplay.tsx b/packages/twenty-front/src/modules/ai/components/ChatReferenceChipDisplay.tsx index 9d8a25046c..c6b120c069 100644 --- a/packages/twenty-front/src/modules/ai/components/ChatReferenceChipDisplay.tsx +++ b/packages/twenty-front/src/modules/ai/components/ChatReferenceChipDisplay.tsx @@ -1,8 +1,10 @@ import { t } from '@lingui/core/macro'; -import { type ReactNode } from 'react'; +import { type ReactNode, useContext } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { Chip, ChipVariant, LinkChip } from 'twenty-ui/data-display'; +import { ChatReferenceNavigationEnabledContext } from '@/ai/contexts/ChatReferenceNavigationEnabledContext'; + type ChatReferenceChipDisplayProps = { displayName: string; leftComponent: ReactNode; @@ -14,13 +16,16 @@ export const ChatReferenceChipDisplay = ({ leftComponent, to, }: ChatReferenceChipDisplayProps) => { - if (!isDefined(to)) { + const isNavigationEnabled = useContext(ChatReferenceNavigationEnabledContext); + + if (!isDefined(to) || !isNavigationEnabled) { return ( ); } diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/ChatReferenceChipDisplay.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/ChatReferenceChipDisplay.test.tsx new file mode 100644 index 0000000000..f52818945a --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/__tests__/ChatReferenceChipDisplay.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import { ChatReferenceChipDisplay } from '@/ai/components/ChatReferenceChipDisplay'; +import { ChatReferenceNavigationEnabledContext } from '@/ai/contexts/ChatReferenceNavigationEnabledContext'; + +const renderChip = ({ + isNavigationEnabled, +}: { + isNavigationEnabled: boolean; +}) => + render( + + + + + , + ); + +describe('ChatReferenceChipDisplay', () => { + it('should link to the reference when navigation is enabled', () => { + renderChip({ isNavigationEnabled: true }); + + expect(screen.getByRole('link')).toHaveAttribute( + 'href', + '/objects/companies/acme-id', + ); + }); + + it('should render a plain chip when navigation is disabled', () => { + renderChip({ isNavigationEnabled: false }); + + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(screen.getByText('Acme')).toBeInTheDocument(); + }); + + it('should not look interactive when navigation is disabled', () => { + renderChip({ isNavigationEnabled: false }); + + const chipClassName = screen.getByTestId('chip').className; + + expect(chipClassName).not.toMatch(/cursorPointer/); + expect(chipClassName).toMatch(/backgroundStatic/); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/contexts/ChatReferenceNavigationEnabledContext.ts b/packages/twenty-front/src/modules/ai/contexts/ChatReferenceNavigationEnabledContext.ts new file mode 100644 index 0000000000..7ace49ed51 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/contexts/ChatReferenceNavigationEnabledContext.ts @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const ChatReferenceNavigationEnabledContext = createContext(true); diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 4b9140c4b6..3c127a90b0 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -621,6 +621,12 @@ const SettingsAdminWorkspaceChatThread = lazy(() => ), ); +const SettingsAdminChats = lazy(() => + import('~/pages/settings/admin-panel/SettingsAdminChats').then((module) => ({ + default: module.SettingsAdminChats, + })), +); + const SettingsCommunity = lazy(() => import('~/pages/settings/community/SettingsCommunity').then((module) => ({ default: module.SettingsCommunity, @@ -1094,6 +1100,10 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => ( path={SettingsPath.AdminPanelWorkspaceChatThread} element={} /> + } + /> )} diff --git a/packages/twenty-front/src/modules/settings/admin-panel/ai/components/SettingsAdminAI.tsx b/packages/twenty-front/src/modules/settings/admin-panel/ai/components/SettingsAdminAI.tsx index 30eb405ba1..09658b35d5 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/ai/components/SettingsAdminAI.tsx +++ b/packages/twenty-front/src/modules/settings/admin-panel/ai/components/SettingsAdminAI.tsx @@ -4,7 +4,9 @@ import { useMutation, useQuery } from '@apollo/client/react'; import { t } from '@lingui/core/macro'; import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; -import { IconBolt, IconRobot } from 'twenty-ui/icon'; +import { IconBolt, IconMessage, IconRobot } from 'twenty-ui/icon'; +import { Button } from 'twenty-ui/input'; +import { UndecoratedLink } from 'twenty-ui/navigation'; import { H2Title } from 'twenty-ui/typography'; import { Section } from 'twenty-ui/layout'; import { Card } from 'twenty-ui/surfaces'; @@ -294,6 +296,21 @@ export const SettingsAdminAI = () => { )} +
+ + +
+
{ + if (isDefined(error)) { + return ( + {t`Failed to load chats. Please try again.`} + ); + } + + if (loading && !isNonEmptyArray(threads)) { + return ( + {t`Loading chats...`} + ); + } + + if (!isNonEmptyArray(threads)) { + return ( + {t`No chats found`} + ); + } + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/chats/components/SettingsAdminChatsFilterDropdown.tsx b/packages/twenty-front/src/modules/settings/admin-panel/chats/components/SettingsAdminChatsFilterDropdown.tsx new file mode 100644 index 0000000000..af485d9e09 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/chats/components/SettingsAdminChatsFilterDropdown.tsx @@ -0,0 +1,73 @@ +import { t } from '@lingui/core/macro'; +import { type ReactNode } from 'react'; + +import { IconAlertTriangle, IconMessage, IconSparkles } from 'twenty-ui/icon'; +import { MenuItemToggle } from 'twenty-ui/navigation'; + +import { type AdminChatsFilterState } from '@/settings/admin-panel/chats/types/AdminChatsFilterState'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; + +type SettingsAdminChatsFilterDropdownProps = { + filterButton: ReactNode; + filters: AdminChatsFilterState; + onFiltersChange: (filters: AdminChatsFilterState) => void; +}; + +export const SettingsAdminChatsFilterDropdown = ({ + filterButton, + filters, + onFiltersChange, +}: SettingsAdminChatsFilterDropdownProps) => { + return ( + + + + onFiltersChange({ + ...filters, + onboardingOnly: !filters.onboardingOnly, + }) + } + toggled={filters.onboardingOnly} + text={t`Onboarding only`} + toggleSize="small" + /> + + onFiltersChange({ + ...filters, + hasErrorOnly: !filters.hasErrorOnly, + }) + } + toggled={filters.hasErrorOnly} + text={t`Has error`} + toggleSize="small" + /> + + onFiltersChange({ + ...filters, + userNeverEngagedOnly: !filters.userNeverEngagedOnly, + }) + } + toggled={filters.userNeverEngagedOnly} + text={t`No user reply`} + toggleSize="small" + /> + + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/chats/components/SettingsAdminChatsTable.tsx b/packages/twenty-front/src/modules/settings/admin-panel/chats/components/SettingsAdminChatsTable.tsx new file mode 100644 index 0000000000..07f5e2cf4c --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/chats/components/SettingsAdminChatsTable.tsx @@ -0,0 +1,58 @@ +import { t } from '@lingui/core/macro'; + +import { SETTINGS_ADMIN_CHATS_TABLE_GRID } from '@/settings/admin-panel/chats/constants/SettingsAdminChatsTableGrid'; +import { SETTINGS_ADMIN_CHATS_TABLE_ID } from '@/settings/admin-panel/chats/constants/SettingsAdminChatsTableId'; +import { SettingsAdminChatsTableRow } from '@/settings/admin-panel/chats/components/SettingsAdminChatsTableRow'; +import { type AdminChatThreadListItem } from '@/settings/admin-panel/chats/types/AdminChatThreadListItem'; +import { SortableTableHeader } from '@/ui/layout/table/components/SortableTableHeader'; +import { Table } from '@/ui/layout/table/components/Table'; +import { TableBody } from '@/ui/layout/table/components/TableBody'; +import { TableHeader } from '@/ui/layout/table/components/TableHeader'; +import { TableRow } from '@/ui/layout/table/components/TableRow'; +import { AdminChatThreadSortField } from '~/generated-admin/graphql'; + +type SettingsAdminChatsTableProps = { + threads: AdminChatThreadListItem[]; +}; + +export const SettingsAdminChatsTable = ({ + threads, +}: SettingsAdminChatsTableProps) => { + return ( + + + {t`Workspace`} + {t`User`} + {t`Title`} + + + {t`Flags`} + + + + {threads.map((thread) => ( + + ))} + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/chats/components/SettingsAdminChatsTableRow.tsx b/packages/twenty-front/src/modules/settings/admin-panel/chats/components/SettingsAdminChatsTableRow.tsx new file mode 100644 index 0000000000..dffd5e4c4f --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/chats/components/SettingsAdminChatsTableRow.tsx @@ -0,0 +1,89 @@ +import { t } from '@lingui/core/macro'; +import { styled } from '@linaria/react'; + +import { isNonEmptyString } from '@sniptt/guards'; +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath, isDefined } from 'twenty-shared/utils'; +import { Tag } from 'twenty-ui/data-display'; +import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { SETTINGS_ADMIN_CHATS_TABLE_GRID } from '@/settings/admin-panel/chats/constants/SettingsAdminChatsTableGrid'; +import { type AdminChatThreadListItem } from '@/settings/admin-panel/chats/types/AdminChatThreadListItem'; +import { TableCell } from '@/ui/layout/table/components/TableCell'; +import { TableRow } from '@/ui/layout/table/components/TableRow'; + +type SettingsAdminChatsTableRowProps = { + thread: AdminChatThreadListItem; +}; + +const StyledFlagsContainer = styled.div` + display: flex; + gap: ${themeCssVariables.spacing[1]}; + overflow: hidden; +`; + +const StyledZeroReplies = styled.span` + color: ${themeCssVariables.color.red}; +`; + +export const SettingsAdminChatsTableRow = ({ + thread, +}: SettingsAdminChatsTableRowProps) => { + return ( + + + + + + + + + + + {thread.messageCount} + + {thread.userReplyCount === 0 ? ( + {thread.userReplyCount} + ) : ( + thread.userReplyCount + )} + + + + {thread.hasError && } + {isDefined(thread.deletedAt) && ( + + )} + {thread.isOnboardingThread && ( + + )} + + + + {new Date(thread.createdAt).toLocaleDateString()} + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/chats/constants/DefaultAdminChatsFilterState.ts b/packages/twenty-front/src/modules/settings/admin-panel/chats/constants/DefaultAdminChatsFilterState.ts new file mode 100644 index 0000000000..b584d1c27d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/chats/constants/DefaultAdminChatsFilterState.ts @@ -0,0 +1,7 @@ +import { type AdminChatsFilterState } from '@/settings/admin-panel/chats/types/AdminChatsFilterState'; + +export const DEFAULT_ADMIN_CHATS_FILTER_STATE: AdminChatsFilterState = { + onboardingOnly: false, + hasErrorOnly: false, + userNeverEngagedOnly: false, +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/chats/constants/SettingsAdminChatsTableGrid.ts b/packages/twenty-front/src/modules/settings/admin-panel/chats/constants/SettingsAdminChatsTableGrid.ts new file mode 100644 index 0000000000..cc743f0c5e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/chats/constants/SettingsAdminChatsTableGrid.ts @@ -0,0 +1,2 @@ +export const SETTINGS_ADMIN_CHATS_TABLE_GRID = + '1fr 1fr 1fr 80px 80px 110px 90px'; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/chats/constants/SettingsAdminChatsTableId.ts b/packages/twenty-front/src/modules/settings/admin-panel/chats/constants/SettingsAdminChatsTableId.ts new file mode 100644 index 0000000000..9a53f944cf --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/chats/constants/SettingsAdminChatsTableId.ts @@ -0,0 +1 @@ +export const SETTINGS_ADMIN_CHATS_TABLE_ID = 'settings-admin-chats-table'; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/chats/hooks/useAdminChatThreads.ts b/packages/twenty-front/src/modules/settings/admin-panel/chats/hooks/useAdminChatThreads.ts new file mode 100644 index 0000000000..c0d63c8afe --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/chats/hooks/useAdminChatThreads.ts @@ -0,0 +1,120 @@ +import { useQuery } from '@apollo/client/react'; +import { t } from '@lingui/core/macro'; +import { useState } from 'react'; +import { useDebounce } from 'use-debounce'; + +import { isDefined } from 'twenty-shared/utils'; + +import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient'; +import { DEFAULT_ADMIN_CHATS_FILTER_STATE } from '@/settings/admin-panel/chats/constants/DefaultAdminChatsFilterState'; +import { SETTINGS_ADMIN_CHATS_TABLE_ID } from '@/settings/admin-panel/chats/constants/SettingsAdminChatsTableId'; +import { type AdminChatsFilterState } from '@/settings/admin-panel/chats/types/AdminChatsFilterState'; +import { getAdminChatsSortVariables } from '@/settings/admin-panel/chats/utils/getAdminChatsSortVariables'; +import { GET_ADMIN_CHAT_THREADS } from '@/settings/admin-panel/graphql/queries/getAdminChatThreads'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { sortedFieldByTableFamilyState } from '@/ui/layout/table/states/sortedFieldByTableFamilyState'; +import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue'; +import { + AdminChatThreadScope, + type GetAdminChatThreadsQuery, + type GetAdminChatThreadsQueryVariables, +} from '~/generated-admin/graphql'; + +const PAGE_SIZE = 25; + +export const useAdminChatThreads = () => { + const apolloAdminClient = useApolloAdminClient(); + const { enqueueErrorSnackBar } = useSnackBar(); + const [searchQuery, setSearchQuery] = useState(''); + const [debouncedSearchQuery] = useDebounce(searchQuery, 300); + const [filters, setFilters] = useState( + DEFAULT_ADMIN_CHATS_FILTER_STATE, + ); + + const sortedFieldByTable = useAtomFamilyStateValue( + sortedFieldByTableFamilyState, + { + tableId: SETTINGS_ADMIN_CHATS_TABLE_ID, + }, + ); + const { sortBy, sortDirection } = + getAdminChatsSortVariables(sortedFieldByTable); + + const { data, loading, error, fetchMore } = useQuery< + GetAdminChatThreadsQuery, + GetAdminChatThreadsQueryVariables + >(GET_ADMIN_CHAT_THREADS, { + client: apolloAdminClient, + notifyOnNetworkStatusChange: true, + variables: { + limit: PAGE_SIZE, + offset: 0, + searchTerm: debouncedSearchQuery, + scope: filters.onboardingOnly + ? AdminChatThreadScope.ONBOARDING + : AdminChatThreadScope.ALL, + hasErrorOnly: filters.hasErrorOnly, + userNeverEngagedOnly: filters.userNeverEngagedOnly, + sortBy, + sortDirection, + }, + }); + + const threads = data?.getAdminChatThreads.threads ?? []; + const totalCount = data?.getAdminChatThreads.totalCount ?? 0; + const hasMore = data?.getAdminChatThreads.hasMore ?? false; + + const isShowMoreDisabled = loading || searchQuery !== debouncedSearchQuery; + + const handleShowMore = async () => { + if (isShowMoreDisabled) { + return; + } + + try { + await fetchMore({ + variables: { + limit: PAGE_SIZE, + offset: threads.length, + }, + updateQuery: (previousData, { fetchMoreResult }) => { + if (!isDefined(fetchMoreResult)) { + return previousData; + } + + const previousThreadIds = new Set( + previousData.getAdminChatThreads.threads.map((thread) => thread.id), + ); + + return { + getAdminChatThreads: { + ...fetchMoreResult.getAdminChatThreads, + threads: [ + ...previousData.getAdminChatThreads.threads, + ...fetchMoreResult.getAdminChatThreads.threads.filter( + (thread) => !previousThreadIds.has(thread.id), + ), + ], + }, + }; + }, + }); + } catch { + enqueueErrorSnackBar({ message: t`Failed to load more chats.` }); + } + }; + + return { + searchQuery, + setSearchQuery, + filters, + setFilters, + threads, + totalCount, + hasMore, + loading, + isShowMoreDisabled, + error, + handleShowMore, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/chats/types/AdminChatThreadListItem.ts b/packages/twenty-front/src/modules/settings/admin-panel/chats/types/AdminChatThreadListItem.ts new file mode 100644 index 0000000000..8b12a6ddad --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/chats/types/AdminChatThreadListItem.ts @@ -0,0 +1,5 @@ +import { type GetAdminChatThreadsQuery } from '~/generated-admin/graphql'; + +export type AdminChatThreadListItem = NonNullable< + GetAdminChatThreadsQuery['getAdminChatThreads'] +>['threads'][number]; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/chats/types/AdminChatsFilterState.ts b/packages/twenty-front/src/modules/settings/admin-panel/chats/types/AdminChatsFilterState.ts new file mode 100644 index 0000000000..0defabc36b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/chats/types/AdminChatsFilterState.ts @@ -0,0 +1,5 @@ +export type AdminChatsFilterState = { + onboardingOnly: boolean; + hasErrorOnly: boolean; + userNeverEngagedOnly: boolean; +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/chats/utils/__tests__/getAdminChatsSortVariables.test.ts b/packages/twenty-front/src/modules/settings/admin-panel/chats/utils/__tests__/getAdminChatsSortVariables.test.ts new file mode 100644 index 0000000000..d54d4fb79a --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/chats/utils/__tests__/getAdminChatsSortVariables.test.ts @@ -0,0 +1,50 @@ +import { getAdminChatsSortVariables } from '@/settings/admin-panel/chats/utils/getAdminChatsSortVariables'; +import { + AdminChatThreadSortDirection, + AdminChatThreadSortField, +} from '~/generated-admin/graphql'; + +describe('getAdminChatsSortVariables', () => { + it('should default to created at descending when no sort is set', () => { + expect(getAdminChatsSortVariables(null)).toEqual({ + sortBy: AdminChatThreadSortField.CREATED_AT, + sortDirection: AdminChatThreadSortDirection.DESC, + }); + }); + + it('should default when the sorted field is not a server sort field', () => { + expect( + getAdminChatsSortVariables({ + fieldName: 'unknownField', + orderBy: 'AscNullsLast', + }), + ).toEqual({ + sortBy: AdminChatThreadSortField.CREATED_AT, + sortDirection: AdminChatThreadSortDirection.DESC, + }); + }); + + it('should map an ascending message count sort', () => { + expect( + getAdminChatsSortVariables({ + fieldName: AdminChatThreadSortField.MESSAGE_COUNT, + orderBy: 'AscNullsLast', + }), + ).toEqual({ + sortBy: AdminChatThreadSortField.MESSAGE_COUNT, + sortDirection: AdminChatThreadSortDirection.ASC, + }); + }); + + it('should map a descending updated at sort', () => { + expect( + getAdminChatsSortVariables({ + fieldName: AdminChatThreadSortField.UPDATED_AT, + orderBy: 'DescNullsLast', + }), + ).toEqual({ + sortBy: AdminChatThreadSortField.UPDATED_AT, + sortDirection: AdminChatThreadSortDirection.DESC, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/admin-panel/chats/utils/getAdminChatsSortVariables.ts b/packages/twenty-front/src/modules/settings/admin-panel/chats/utils/getAdminChatsSortVariables.ts new file mode 100644 index 0000000000..28389cb133 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/chats/utils/getAdminChatsSortVariables.ts @@ -0,0 +1,40 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type TableSortValue } from '@/ui/layout/table/types/TableSortValue'; +import { + AdminChatThreadSortDirection, + AdminChatThreadSortField, +} from '~/generated-admin/graphql'; + +type AdminChatsSortVariables = { + sortBy: AdminChatThreadSortField; + sortDirection: AdminChatThreadSortDirection; +}; + +const isAdminChatThreadSortField = ( + fieldName: string, +): fieldName is AdminChatThreadSortField => + Object.values(AdminChatThreadSortField).includes( + fieldName as AdminChatThreadSortField, + ); + +export const getAdminChatsSortVariables = ( + sortValue: TableSortValue | null, +): AdminChatsSortVariables => { + if ( + !isDefined(sortValue) || + !isAdminChatThreadSortField(sortValue.fieldName) + ) { + return { + sortBy: AdminChatThreadSortField.CREATED_AT, + sortDirection: AdminChatThreadSortDirection.DESC, + }; + } + + return { + sortBy: sortValue.fieldName, + sortDirection: sortValue.orderBy.startsWith('Asc') + ? AdminChatThreadSortDirection.ASC + : AdminChatThreadSortDirection.DESC, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminChatCollapsibleSection.tsx b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminChatCollapsibleSection.tsx new file mode 100644 index 0000000000..bb434fd4cd --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminChatCollapsibleSection.tsx @@ -0,0 +1,62 @@ +import { styled } from '@linaria/react'; +import { type ReactNode, useState } from 'react'; + +import { IconChevronDown, IconChevronUp } from 'twenty-ui/icon'; +import { AnimatedExpandableContainer } from 'twenty-ui/layout'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +type SettingsAdminChatCollapsibleSectionProps = { + label: string; + defaultExpanded?: boolean; + children: ReactNode; +}; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[1]}; + width: 100%; +`; + +const StyledToggleButton = styled.button` + align-items: center; + background: none; + border: none; + color: ${themeCssVariables.font.color.tertiary}; + cursor: pointer; + display: flex; + font-size: ${themeCssVariables.font.size.sm}; + font-weight: ${themeCssVariables.font.weight.medium}; + gap: ${themeCssVariables.spacing[1]}; + padding: 0; + width: fit-content; +`; + +export const SettingsAdminChatCollapsibleSection = ({ + label, + defaultExpanded = false, + children, +}: SettingsAdminChatCollapsibleSectionProps) => { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + return ( + + + setIsExpanded((previousIsExpanded) => !previousIsExpanded) + } + > + {label} + {isExpanded ? ( + + ) : ( + + )} + + + {children} + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminChatMessage.tsx b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminChatMessage.tsx new file mode 100644 index 0000000000..db015ab5c9 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminChatMessage.tsx @@ -0,0 +1,70 @@ +import { t } from '@lingui/core/macro'; +import { styled } from '@linaria/react'; + +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { SettingsAdminChatCollapsibleSection } from '@/settings/admin-panel/components/SettingsAdminChatCollapsibleSection'; +import { SettingsAdminChatMessagePartRenderer } from '@/settings/admin-panel/components/SettingsAdminChatMessagePartRenderer'; +import { type AdminChatThreadMessage } from '@/settings/admin-panel/types/AdminChatThreadMessage'; +import { isRenderableAdminChatMessagePart } from '@/settings/admin-panel/utils/isRenderableAdminChatMessagePart'; +import { AgentMessageRole } from '~/generated-admin/graphql'; + +type SettingsAdminChatMessageProps = { + message: AdminChatThreadMessage; +}; + +const StyledMessageBubble = styled.div<{ isUser?: boolean }>` + align-items: ${({ isUser }) => (isUser ? 'flex-end' : 'flex-start')}; + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[2]}; + width: 100%; +`; + +const StyledRoleLabel = styled.span` + color: ${themeCssVariables.font.color.light}; + font-size: ${themeCssVariables.font.size.xs}; + font-weight: ${themeCssVariables.font.weight.medium}; + text-transform: capitalize; +`; + +const StyledTimestamp = styled.span` + color: ${themeCssVariables.font.color.light}; + font-size: ${themeCssVariables.font.size.xs}; +`; + +export const SettingsAdminChatMessage = ({ + message, +}: SettingsAdminChatMessageProps) => { + const isUser = message.role === AgentMessageRole.USER; + + const renderableParts = message.parts + .filter(isRenderableAdminChatMessagePart) + .sort((a, b) => a.orderIndex - b.orderIndex); + + const messageBody = ( + + {!message.isHidden && {message.role}} + {renderableParts.map((part) => ( + + ))} + + {new Date(message.createdAt).toLocaleString()} + + + ); + + if (message.isHidden) { + return ( + + {messageBody} + + ); + } + + return messageBody; +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminChatMessagePartRenderer.tsx b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminChatMessagePartRenderer.tsx new file mode 100644 index 0000000000..0a23504178 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminChatMessagePartRenderer.tsx @@ -0,0 +1,73 @@ +import { t } from '@lingui/core/macro'; +import { styled } from '@linaria/react'; + +import { isNonEmptyString } from '@sniptt/guards'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer'; +import { SettingsAdminChatCollapsibleSection } from '@/settings/admin-panel/components/SettingsAdminChatCollapsibleSection'; +import { SettingsAdminChatToolCallPart } from '@/settings/admin-panel/components/SettingsAdminChatToolCallPart'; +import { type AdminChatThreadMessagePart } from '@/settings/admin-panel/types/AdminChatThreadMessagePart'; + +type SettingsAdminChatMessagePartRendererProps = { + part: AdminChatThreadMessagePart; + isUserMessage: boolean; +}; + +const StyledTextContent = styled.div<{ isUser?: boolean }>` + background: ${({ isUser }) => + isUser ? themeCssVariables.background.tertiary : 'transparent'}; + border-radius: ${themeCssVariables.border.radius.sm}; + color: ${({ isUser }) => + isUser + ? themeCssVariables.font.color.secondary + : themeCssVariables.font.color.primary}; + font-weight: ${({ isUser }) => + isUser + ? themeCssVariables.font.weight.medium + : themeCssVariables.font.weight.regular}; + line-height: 1.4em; + max-width: 100%; + overflow-wrap: break-word; + padding: ${({ isUser }) => + isUser ? `0 ${themeCssVariables.spacing[2]}` : '0'}; + white-space: ${({ isUser }) => (isUser ? 'pre-wrap' : 'normal')}; + width: ${({ isUser }) => (isUser ? 'fit-content' : '100%')}; +`; + +const StyledReasoningContent = styled.div` + background: ${themeCssVariables.background.transparent.lighter}; + border: 1px solid ${themeCssVariables.border.color.light}; + border-radius: ${themeCssVariables.border.radius.sm}; + color: ${themeCssVariables.font.color.secondary}; + font-size: ${themeCssVariables.font.size.sm}; + padding: ${themeCssVariables.spacing[3]}; + white-space: pre-wrap; +`; + +export const SettingsAdminChatMessagePartRenderer = ({ + part, + isUserMessage, +}: SettingsAdminChatMessagePartRendererProps) => { + if (part.type === 'text' && isNonEmptyString(part.textContent)) { + return ( + + {isUserMessage ? ( + part.textContent + ) : ( + + )} + + ); + } + + if (part.type === 'reasoning' && isNonEmptyString(part.reasoningContent)) { + return ( + + {part.reasoningContent} + + ); + } + + return ; +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminChatThreadMessageList.tsx b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminChatThreadMessageList.tsx index 1666f736ac..874b8f3c47 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminChatThreadMessageList.tsx +++ b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminChatThreadMessageList.tsx @@ -1,22 +1,20 @@ import { t } from '@lingui/core/macro'; import { styled } from '@linaria/react'; -import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer'; import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableRow } from '@/ui/layout/table/components/TableRow'; +import { isNonEmptyArray } from 'twenty-shared/utils'; import { Card } from 'twenty-ui/surfaces'; import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { - AgentMessageRole, - type GetAdminChatThreadMessagesQuery, -} from '~/generated-admin/graphql'; +import { AgentMessageRole } from '~/generated-admin/graphql'; -type ChatMessage = NonNullable< - GetAdminChatThreadMessagesQuery['getAdminChatThreadMessages'] ->['messages'][number]; +import { ChatReferenceNavigationEnabledContext } from '@/ai/contexts/ChatReferenceNavigationEnabledContext'; +import { SettingsAdminChatMessage } from '@/settings/admin-panel/components/SettingsAdminChatMessage'; +import { type AdminChatThreadMessage } from '@/settings/admin-panel/types/AdminChatThreadMessage'; +import { isRenderableAdminChatMessagePart } from '@/settings/admin-panel/utils/isRenderableAdminChatMessagePart'; type SettingsAdminChatThreadMessageListProps = { - messages: ChatMessage[]; + messages: AdminChatThreadMessage[]; }; const StyledMessagesContainer = styled.div` @@ -25,53 +23,16 @@ const StyledMessagesContainer = styled.div` gap: ${themeCssVariables.spacing[4]}; `; -const StyledMessageBubble = styled.div<{ isUser?: boolean }>` - align-items: ${({ isUser }) => (isUser ? 'flex-end' : 'flex-start')}; - display: flex; - flex-direction: column; - width: 100%; -`; - -const StyledMessageContent = styled.div<{ isUser?: boolean }>` - background: ${({ isUser }) => - isUser ? themeCssVariables.background.tertiary : 'transparent'}; - border-radius: ${themeCssVariables.border.radius.sm}; - color: ${({ isUser }) => - isUser - ? themeCssVariables.font.color.secondary - : themeCssVariables.font.color.primary}; - font-weight: ${({ isUser }) => (isUser ? 500 : 400)}; - line-height: 1.4em; - max-width: 100%; - overflow-wrap: break-word; - padding: ${({ isUser }) => - isUser ? `0 ${themeCssVariables.spacing[2]}` : '0'}; - white-space: ${({ isUser }) => (isUser ? 'pre-wrap' : 'normal')}; - width: ${({ isUser }) => (isUser ? 'fit-content' : '100%')}; -`; - -const StyledRoleLabel = styled.span` - color: ${themeCssVariables.font.color.light}; - font-size: ${themeCssVariables.font.size.xs}; - font-weight: ${themeCssVariables.font.weight.medium}; - margin-bottom: ${themeCssVariables.spacing[1]}; - text-transform: capitalize; -`; - -const StyledTimestamp = styled.span` - color: ${themeCssVariables.font.color.light}; - font-size: ${themeCssVariables.font.size.xs}; - margin-top: ${themeCssVariables.spacing[1]}; -`; - export const SettingsAdminChatThreadMessageList = ({ messages, }: SettingsAdminChatThreadMessageListProps) => { const visibleMessages = messages.filter( - (message) => message.role !== AgentMessageRole.SYSTEM, + (message) => + message.role !== AgentMessageRole.SYSTEM && + message.parts.some(isRenderableAdminChatMessagePart), ); - if (visibleMessages.length === 0) { + if (!isNonEmptyArray(visibleMessages)) { return ( @@ -87,41 +48,12 @@ export const SettingsAdminChatThreadMessageList = ({ } return ( - - {visibleMessages.map((message) => { - const isUser = message.role === AgentMessageRole.USER; - const textParts = message.parts - .filter((part) => part.type === 'text' && part.textContent !== null) - .map((part) => part.textContent) - .join('\n'); - - const toolParts = message.parts.filter( - (part) => part.type === 'tool-call' && part.toolName !== null, - ); - - if (textParts.length === 0 && toolParts.length === 0) { - return null; - } - - return ( - - {message.role} - {textParts.length > 0 && ( - - {isUser ? textParts : } - - )} - {toolParts.map((part, index) => ( - - {t`Tool call: ${part.toolName ?? ''}`} - - ))} - - {new Date(message.createdAt).toLocaleString()} - - - ); - })} - + + + {visibleMessages.map((message) => ( + + ))} + + ); }; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminChatToolCallPart.tsx b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminChatToolCallPart.tsx new file mode 100644 index 0000000000..a1e425240d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminChatToolCallPart.tsx @@ -0,0 +1,178 @@ +import { t } from '@lingui/core/macro'; +import { styled } from '@linaria/react'; +import { useState } from 'react'; + +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; +import { Tag } from 'twenty-ui/data-display'; +import { IconChevronDown, IconChevronUp, IconTool } from 'twenty-ui/icon'; +import { JsonTree } from 'twenty-ui/json-visualizer'; +import { AnimatedExpandableContainer } from 'twenty-ui/layout'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { type AdminChatThreadMessagePart } from '@/settings/admin-panel/types/AdminChatThreadMessagePart'; +import { getAdminToolDisplayName } from '@/settings/admin-panel/utils/getAdminToolDisplayName'; +import { parseAdminToolJson } from '@/settings/admin-panel/utils/parseAdminToolJson'; +import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; + +type SettingsAdminChatToolCallPartProps = { + part: AdminChatThreadMessagePart; +}; + +const StyledContainer = styled.div` + border: 1px solid ${themeCssVariables.border.color.light}; + border-radius: ${themeCssVariables.border.radius.sm}; + display: flex; + flex-direction: column; + padding: ${themeCssVariables.spacing[2]}; + width: 100%; +`; + +const StyledToggleRow = styled.button` + align-items: center; + background: none; + border: none; + cursor: pointer; + display: flex; + gap: ${themeCssVariables.spacing[2]}; + justify-content: space-between; + padding: 0; + width: 100%; +`; + +const StyledToolLabel = styled.span` + align-items: center; + color: ${themeCssVariables.font.color.secondary}; + display: flex; + font-family: ${themeCssVariables.font.family}; + font-size: ${themeCssVariables.font.size.sm}; + font-weight: ${themeCssVariables.font.weight.medium}; + gap: ${themeCssVariables.spacing[1]}; +`; + +const StyledRightContent = styled.span` + align-items: center; + color: ${themeCssVariables.font.color.tertiary}; + display: flex; + gap: ${themeCssVariables.spacing[1]}; +`; + +const StyledTabContainer = styled.div` + display: flex; + gap: ${themeCssVariables.spacing[2]}; + margin-top: ${themeCssVariables.spacing[2]}; +`; + +const StyledTab = styled.button<{ isActive: boolean }>` + background: none; + border: none; + border-bottom: 1px solid + ${({ isActive }) => + isActive ? themeCssVariables.font.color.primary : 'transparent'}; + color: ${({ isActive }) => + isActive + ? themeCssVariables.font.color.primary + : themeCssVariables.font.color.tertiary}; + cursor: pointer; + font-size: ${themeCssVariables.font.size.sm}; + padding: ${themeCssVariables.spacing[1]} 0; +`; + +const StyledJsonTreeContainer = styled.div` + margin-top: ${themeCssVariables.spacing[2]}; + max-height: 300px; + overflow: auto; +`; + +const StyledErrorMessage = styled.div` + color: ${themeCssVariables.color.red}; + font-size: ${themeCssVariables.font.size.sm}; + margin-top: ${themeCssVariables.spacing[2]}; + white-space: pre-wrap; +`; + +const StyledEmptyTabLabel = styled.div` + color: ${themeCssVariables.font.color.tertiary}; + font-size: ${themeCssVariables.font.size.sm}; + margin-top: ${themeCssVariables.spacing[2]}; +`; + +export const SettingsAdminChatToolCallPart = ({ + part, +}: SettingsAdminChatToolCallPartProps) => { + const [isExpanded, setIsExpanded] = useState(false); + const [activeTab, setActiveTab] = useState<'output' | 'input'>( + isDefined(part.toolOutput) ? 'output' : 'input', + ); + const { copyToClipboard } = useCopyToClipboard(); + + const toolName = getAdminToolDisplayName(part); + + const hasToolError = + part.state === 'output-error' || isNonEmptyString(part.errorMessage); + + const activeJsonValue = parseAdminToolJson( + activeTab === 'output' ? part.toolOutput : part.toolInput, + ); + + return ( + + + setIsExpanded((previousIsExpanded) => !previousIsExpanded) + } + > + + + {toolName} + {hasToolError && } + + + {isExpanded ? ( + + ) : ( + + )} + + + + + setActiveTab('output')} + > + {t`Output`} + + setActiveTab('input')} + > + {t`Input`} + + + {isDefined(activeJsonValue) ? ( + + false} + emptyArrayLabel={t`Empty Array`} + emptyObjectLabel={t`Empty Object`} + emptyStringLabel={t`[empty string]`} + arrowButtonCollapsedLabel={t`Expand`} + arrowButtonExpandedLabel={t`Collapse`} + onNodeValueClick={copyToClipboard} + /> + + ) : ( + + {activeTab === 'output' ? t`No output` : t`No input`} + + )} + {isNonEmptyString(part.errorMessage) && ( + {part.errorMessage} + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getAdminChatThreadMessages.ts b/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getAdminChatThreadMessages.ts index 2bbada7287..4ac8bf2e6b 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getAdminChatThreadMessages.ts +++ b/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getAdminChatThreadMessages.ts @@ -9,16 +9,25 @@ export const GET_ADMIN_CHAT_THREAD_MESSAGES = gql` totalInputTokens totalOutputTokens conversationSize + messageCount createdAt updatedAt } messages { id role + isHidden parts { type + orderIndex textContent + reasoningContent toolName + toolCallId + toolInput + toolOutput + state + errorMessage } createdAt } diff --git a/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getAdminChatThreads.ts b/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getAdminChatThreads.ts new file mode 100644 index 0000000000..c7e5ea8195 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getAdminChatThreads.ts @@ -0,0 +1,45 @@ +import { gql } from '@apollo/client'; + +export const GET_ADMIN_CHAT_THREADS = gql` + query GetAdminChatThreads( + $scope: AdminChatThreadScope + $hasErrorOnly: Boolean + $userNeverEngagedOnly: Boolean + $searchTerm: String + $sortBy: AdminChatThreadSortField + $sortDirection: AdminChatThreadSortDirection + $limit: Int + $offset: Int + ) { + getAdminChatThreads( + scope: $scope + hasErrorOnly: $hasErrorOnly + userNeverEngagedOnly: $userNeverEngagedOnly + searchTerm: $searchTerm + sortBy: $sortBy + sortDirection: $sortDirection + limit: $limit + offset: $offset + ) { + totalCount + hasMore + threads { + id + title + workspaceId + workspaceDisplayName + userWorkspaceId + userEmail + userFirstName + userLastName + messageCount + userReplyCount + hasError + isOnboardingThread + deletedAt + createdAt + updatedAt + } + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getAdminWorkspaceChatThreads.ts b/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getAdminWorkspaceChatThreads.ts index fe8cce8b90..d385e69884 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getAdminWorkspaceChatThreads.ts +++ b/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getAdminWorkspaceChatThreads.ts @@ -8,6 +8,7 @@ export const GET_ADMIN_WORKSPACE_CHAT_THREADS = gql` totalInputTokens totalOutputTokens conversationSize + messageCount createdAt updatedAt } diff --git a/packages/twenty-front/src/modules/settings/admin-panel/types/AdminChatThreadMessage.ts b/packages/twenty-front/src/modules/settings/admin-panel/types/AdminChatThreadMessage.ts new file mode 100644 index 0000000000..8737a2630e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/types/AdminChatThreadMessage.ts @@ -0,0 +1,5 @@ +import { type GetAdminChatThreadMessagesQuery } from '~/generated-admin/graphql'; + +export type AdminChatThreadMessage = NonNullable< + GetAdminChatThreadMessagesQuery['getAdminChatThreadMessages'] +>['messages'][number]; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/types/AdminChatThreadMessagePart.ts b/packages/twenty-front/src/modules/settings/admin-panel/types/AdminChatThreadMessagePart.ts new file mode 100644 index 0000000000..22abace264 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/types/AdminChatThreadMessagePart.ts @@ -0,0 +1,4 @@ +import { type AdminChatThreadMessage } from '@/settings/admin-panel/types/AdminChatThreadMessage'; + +export type AdminChatThreadMessagePart = + AdminChatThreadMessage['parts'][number]; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/utils/__tests__/getAdminToolDisplayName.test.ts b/packages/twenty-front/src/modules/settings/admin-panel/utils/__tests__/getAdminToolDisplayName.test.ts new file mode 100644 index 0000000000..01f92252f2 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/utils/__tests__/getAdminToolDisplayName.test.ts @@ -0,0 +1,29 @@ +import { type AdminChatThreadMessagePart } from '@/settings/admin-panel/types/AdminChatThreadMessagePart'; +import { getAdminToolDisplayName } from '@/settings/admin-panel/utils/getAdminToolDisplayName'; + +const buildPart = ( + part: Partial, +): AdminChatThreadMessagePart => + ({ + type: 'tool-ask_questions', + toolName: null, + ...part, + }) as AdminChatThreadMessagePart; + +describe('getAdminToolDisplayName', () => { + it('should prefer the persisted tool name', () => { + expect( + getAdminToolDisplayName(buildPart({ toolName: 'ask_questions' })), + ).toBe('ask_questions'); + }); + + it('should strip the tool prefix from the part type', () => { + expect(getAdminToolDisplayName(buildPart({}))).toBe('ask_questions'); + }); + + it('should fall back to the raw type', () => { + expect(getAdminToolDisplayName(buildPart({ type: 'dynamic-tool' }))).toBe( + 'dynamic-tool', + ); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/admin-panel/utils/__tests__/parseAdminToolJson.test.ts b/packages/twenty-front/src/modules/settings/admin-panel/utils/__tests__/parseAdminToolJson.test.ts new file mode 100644 index 0000000000..affc5c1e71 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/utils/__tests__/parseAdminToolJson.test.ts @@ -0,0 +1,22 @@ +import { parseAdminToolJson } from '@/settings/admin-panel/utils/parseAdminToolJson'; + +describe('parseAdminToolJson', () => { + it('should return null for null or undefined', () => { + expect(parseAdminToolJson(null)).toBeNull(); + expect(parseAdminToolJson(undefined)).toBeNull(); + }); + + it('should parse a JSON string', () => { + expect(parseAdminToolJson('{"success":true}')).toEqual({ success: true }); + }); + + it('should return the raw string when it is not valid JSON', () => { + expect(parseAdminToolJson('not json')).toBe('not json'); + }); + + it('should pass objects through unchanged', () => { + expect(parseAdminToolJson({ objects: [1, 2] })).toEqual({ + objects: [1, 2], + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/admin-panel/utils/getAdminToolDisplayName.ts b/packages/twenty-front/src/modules/settings/admin-panel/utils/getAdminToolDisplayName.ts new file mode 100644 index 0000000000..659919d893 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/utils/getAdminToolDisplayName.ts @@ -0,0 +1,19 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { type AdminChatThreadMessagePart } from '@/settings/admin-panel/types/AdminChatThreadMessagePart'; + +const TOOL_PART_TYPE_PREFIX = 'tool-'; + +export const getAdminToolDisplayName = ( + part: AdminChatThreadMessagePart, +): string => { + if (isNonEmptyString(part.toolName)) { + return part.toolName; + } + + if (part.type.startsWith(TOOL_PART_TYPE_PREFIX)) { + return part.type.slice(TOOL_PART_TYPE_PREFIX.length); + } + + return part.type; +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/utils/isRenderableAdminChatMessagePart.ts b/packages/twenty-front/src/modules/settings/admin-panel/utils/isRenderableAdminChatMessagePart.ts new file mode 100644 index 0000000000..5d0275df81 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/utils/isRenderableAdminChatMessagePart.ts @@ -0,0 +1,11 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { type AdminChatThreadMessagePart } from '@/settings/admin-panel/types/AdminChatThreadMessagePart'; + +export const isRenderableAdminChatMessagePart = ( + part: AdminChatThreadMessagePart, +): boolean => + (part.type === 'text' && isNonEmptyString(part.textContent)) || + (part.type === 'reasoning' && isNonEmptyString(part.reasoningContent)) || + part.type.startsWith('tool-') || + part.type === 'dynamic-tool'; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/utils/parseAdminToolJson.ts b/packages/twenty-front/src/modules/settings/admin-panel/utils/parseAdminToolJson.ts new file mode 100644 index 0000000000..84f614bae9 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/utils/parseAdminToolJson.ts @@ -0,0 +1,21 @@ +import { isString } from '@sniptt/guards'; +import { type JsonValue } from 'type-fest'; +import { isDefined } from 'twenty-shared/utils'; + +export const parseAdminToolJson = ( + value: JsonValue | null | undefined, +): JsonValue | null => { + if (!isDefined(value)) { + return null; + } + + if (isString(value)) { + try { + return JSON.parse(value); + } catch { + return value; + } + } + + return value; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/table/components/SortableTableHeader.tsx b/packages/twenty-front/src/modules/ui/layout/table/components/SortableTableHeader.tsx index 9dee91703d..3077220250 100644 --- a/packages/twenty-front/src/modules/ui/layout/table/components/SortableTableHeader.tsx +++ b/packages/twenty-front/src/modules/ui/layout/table/components/SortableTableHeader.tsx @@ -1,3 +1,5 @@ +import { styled } from '@linaria/react'; + import { TableHeader } from '@/ui/layout/table/components/TableHeader'; import { TableHeaderText } from '@/ui/layout/table/components/TableHeaderText'; import { sortedFieldByTableFamilyState } from '@/ui/layout/table/states/sortedFieldByTableFamilyState'; @@ -6,6 +8,12 @@ import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAto import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState'; import { IconArrowDown, IconArrowUp, type IconComponent } from 'twenty-ui/icon'; +const StyledSortIconContainer = styled.span` + align-items: center; + display: flex; + flex-shrink: 0; +`; + export const SortableTableHeader = ({ tableId, fieldName, @@ -59,20 +67,16 @@ export const SortableTableHeader = ({ return ( {isSortActive && align === 'right' ? ( - isAsc ? ( - - ) : ( - - ) + + {isAsc ? : } + ) : null} {Icon && } {label} {isSortActive && align === 'left' ? ( - isAsc ? ( - - ) : ( - - ) + + {isAsc ? : } + ) : null} ); diff --git a/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminChats.tsx b/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminChats.tsx new file mode 100644 index 0000000000..79f6445be4 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminChats.tsx @@ -0,0 +1,96 @@ +import { t } from '@lingui/core/macro'; +import { styled } from '@linaria/react'; +import { type ReactNode } from 'react'; + +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath, isDefined } from 'twenty-shared/utils'; +import { IconDotsVertical } from 'twenty-ui/icon'; +import { Button, SearchInput } from 'twenty-ui/input'; +import { Section } from 'twenty-ui/layout'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { H2Title } from 'twenty-ui/typography'; + +import { AI_ADMIN_PATH } from '@/settings/admin-panel/ai/constants/AiAdminPath'; +import { SettingsAdminChatsContent } from '@/settings/admin-panel/chats/components/SettingsAdminChatsContent'; +import { SettingsAdminChatsFilterDropdown } from '@/settings/admin-panel/chats/components/SettingsAdminChatsFilterDropdown'; +import { useAdminChatThreads } from '@/settings/admin-panel/chats/hooks/useAdminChatThreads'; +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout'; + +const StyledShowMoreContainer = styled.div` + display: flex; + justify-content: center; + margin-top: ${themeCssVariables.spacing[2]}; +`; + +export const SettingsAdminChats = () => { + const { + searchQuery, + setSearchQuery, + filters, + setFilters, + threads, + totalCount, + hasMore, + loading, + isShowMoreDisabled, + error, + handleShowMore, + } = useAdminChatThreads(); + + return ( + + +
+ + ( + + )} + /> + + {hasMore && !isDefined(error) && ( + +
+
+
+ ); +}; diff --git a/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminWorkspaceDetail.tsx b/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminWorkspaceDetail.tsx index 49646d5f4a..57021a64a6 100644 --- a/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminWorkspaceDetail.tsx +++ b/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminWorkspaceDetail.tsx @@ -415,9 +415,7 @@ export const SettingsAdminWorkspaceDetail = () => { {thread.title || t`Untitled`} - - {thread.conversationSize} - + {thread.messageCount} {new Date(thread.updatedAt).toLocaleDateString()} diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel-chat.service.spec.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel-chat.service.spec.ts new file mode 100644 index 0000000000..d5d98d439c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel-chat.service.spec.ts @@ -0,0 +1,219 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service'; +import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { + AgentMessageEntity, + AgentMessageRole, +} from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity'; +import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; + +const createMessageQueryBuilderMock = (getRawManyResult: () => unknown[]) => { + const queryBuilderMock = { + select: jest.fn(), + addSelect: jest.fn(), + where: jest.fn(), + groupBy: jest.fn(), + getRawMany: jest + .fn() + .mockImplementation(() => Promise.resolve(getRawManyResult())), + }; + + for (const method of ['select', 'addSelect', 'where', 'groupBy'] as const) { + queryBuilderMock[method].mockReturnValue(queryBuilderMock); + } + + return queryBuilderMock; +}; + +describe('AdminPanelChatService', () => { + let service: AdminPanelChatService; + let workspaceRepositoryFindOneMock: jest.Mock; + let threadRepositoryFindMock: jest.Mock; + let threadRepositoryFindOneMock: jest.Mock; + let messageRepositoryFindMock: jest.Mock; + let messageRawManyResult: unknown[]; + + beforeEach(async () => { + workspaceRepositoryFindOneMock = jest.fn(); + threadRepositoryFindMock = jest.fn(); + threadRepositoryFindOneMock = jest.fn(); + messageRepositoryFindMock = jest.fn(); + messageRawManyResult = []; + + const messageQueryBuilderMock = createMessageQueryBuilderMock( + () => messageRawManyResult, + ); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminPanelChatService, + { + provide: getRepositoryToken(WorkspaceEntity), + useValue: { findOne: workspaceRepositoryFindOneMock }, + }, + { + provide: getRepositoryToken(AgentChatThreadEntity), + useValue: { + find: threadRepositoryFindMock, + findOne: threadRepositoryFindOneMock, + }, + }, + { + provide: getWorkspaceScopedRepositoryToken(AgentMessageEntity), + useValue: { + find: messageRepositoryFindMock, + createQueryBuilder: jest.fn(() => messageQueryBuilderMock), + }, + }, + ], + }).compile(); + + service = module.get(AdminPanelChatService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getWorkspaceChatThreads', () => { + it('should merge message counts and default missing threads to zero', async () => { + workspaceRepositoryFindOneMock.mockResolvedValue({ + id: 'workspace-1', + allowImpersonation: true, + }); + threadRepositoryFindMock.mockResolvedValue([ + { id: 'thread-1', title: 'A', conversationSize: 1000 }, + { id: 'thread-2', title: 'B', conversationSize: 2000 }, + ]); + messageRawManyResult = [{ threadId: 'thread-1', messageCount: 7 }]; + + const result = await service.getWorkspaceChatThreads('workspace-1'); + + expect(result[0].messageCount).toBe(7); + expect(result[1].messageCount).toBe(0); + }); + + it('should throw when the workspace has not enabled support access', async () => { + workspaceRepositoryFindOneMock.mockResolvedValue({ + id: 'workspace-1', + allowImpersonation: false, + }); + + await expect( + service.getWorkspaceChatThreads('workspace-1'), + ).rejects.toThrow(UserInputError); + }); + }); + + describe('getChatThreadMessages', () => { + it('should return the hidden kickoff message with enriched parts', async () => { + threadRepositoryFindOneMock.mockResolvedValue({ + id: 'thread-1', + workspaceId: 'workspace-1', + title: 'Workspace setup', + totalInputTokens: 10, + totalOutputTokens: 20, + conversationSize: 1000, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-02'), + }); + workspaceRepositoryFindOneMock.mockResolvedValue({ + id: 'workspace-1', + allowImpersonation: true, + }); + messageRepositoryFindMock.mockResolvedValue([ + { + id: 'message-1', + role: AgentMessageRole.USER, + isHidden: true, + createdAt: new Date('2026-01-01'), + parts: [ + { + type: 'text', + orderIndex: 0, + textContent: 'kickoff prompt', + reasoningContent: null, + toolName: null, + toolCallId: null, + toolInput: null, + toolOutput: null, + state: null, + errorMessage: null, + }, + ], + }, + { + id: 'message-2', + role: AgentMessageRole.ASSISTANT, + isHidden: false, + createdAt: new Date('2026-01-01T01:00:00Z'), + parts: [ + { + type: 'tool-call', + orderIndex: 1, + textContent: null, + reasoningContent: null, + toolName: 'create_many_object_metadata', + toolCallId: 'call-1', + toolInput: { objects: [] }, + toolOutput: { success: true }, + state: 'output-available', + errorMessage: null, + }, + { + type: 'reasoning', + orderIndex: 0, + textContent: null, + reasoningContent: 'thinking', + toolName: null, + toolCallId: null, + toolInput: null, + toolOutput: null, + state: null, + errorMessage: null, + }, + ], + }, + ]); + + const result = await service.getChatThreadMessages('thread-1'); + + expect(messageRepositoryFindMock).toHaveBeenCalledWith( + 'workspace-1', + expect.objectContaining({ where: { threadId: 'thread-1' } }), + ); + expect(result.thread.messageCount).toBe(1); + expect(result.messages[0].isHidden).toBe(true); + expect(result.messages[1].parts.map((part) => part.orderIndex)).toEqual([ + 0, 1, + ]); + expect(result.messages[1].parts[1]).toEqual( + expect.objectContaining({ + toolName: 'create_many_object_metadata', + toolInput: { objects: [] }, + toolOutput: { success: true }, + state: 'output-available', + }), + ); + }); + + it('should throw when the thread workspace has not enabled support access', async () => { + threadRepositoryFindOneMock.mockResolvedValue({ + id: 'thread-1', + workspaceId: 'workspace-1', + }); + workspaceRepositoryFindOneMock.mockResolvedValue({ + id: 'workspace-1', + allowImpersonation: false, + }); + + await expect(service.getChatThreadMessages('thread-1')).rejects.toThrow( + UserInputError, + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel-global-chat-threads.service.spec.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel-global-chat-threads.service.spec.ts new file mode 100644 index 0000000000..ef8ceb5c20 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel-global-chat-threads.service.spec.ts @@ -0,0 +1,499 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { isString } from '@sniptt/guards'; +import { ASK_QUESTIONS_TOOL_NAME } from 'twenty-shared/ai'; +import { Brackets } from 'typeorm'; +import { AdminChatThreadScope } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-scope.enum'; +import { AdminChatThreadSortDirection } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-direction.enum'; +import { AdminChatThreadSortField } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-field.enum'; +import { AdminPanelGlobalChatThreadsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-global-chat-threads.service'; +import { + AgentMessageEntity, + AgentMessageRole, +} from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity'; +import { WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE } from 'src/engine/metadata-modules/ai/ai-chat/constants/workspace-setup-chat-thread-id-namespace.constant'; +import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; + +const QUERY_BUILDER_CHAINABLE_METHODS = [ + 'innerJoin', + 'leftJoin', + 'withDeleted', + 'setParameter', + 'where', + 'andWhere', + 'select', + 'addSelect', + 'groupBy', + 'addGroupBy', + 'orderBy', + 'addOrderBy', + 'limit', + 'offset', +] as const; + +const SUB_QUERY_BUILDER_CHAINABLE_METHODS = [ + 'select', + 'from', + 'innerJoin', + 'where', + 'andWhere', +] as const; + +type SubQueryBuilderMock = Record< + (typeof SUB_QUERY_BUILDER_CHAINABLE_METHODS)[number], + jest.Mock +> & { + getQuery: jest.Mock; +}; + +const createSubQueryBuilderMock = (): SubQueryBuilderMock => { + const subQueryBuilderMock = {} as SubQueryBuilderMock; + + for (const method of SUB_QUERY_BUILDER_CHAINABLE_METHODS) { + subQueryBuilderMock[method] = jest + .fn() + .mockReturnValue(subQueryBuilderMock); + } + + subQueryBuilderMock.getQuery = jest.fn().mockReturnValue('SUBQUERY'); + + return subQueryBuilderMock; +}; + +type QueryBuilderMock = Record< + (typeof QUERY_BUILDER_CHAINABLE_METHODS)[number], + jest.Mock +> & { + subQuery: jest.Mock; + getRawMany: jest.Mock; + getCount: jest.Mock; +}; + +const createQueryBuilderMock = ( + getRawManyResult: () => unknown[], + getCountResult: () => number, + onSubQueryCreated?: (subQueryBuilderMock: SubQueryBuilderMock) => void, +): QueryBuilderMock => { + const queryBuilderMock = {} as QueryBuilderMock; + + for (const method of QUERY_BUILDER_CHAINABLE_METHODS) { + queryBuilderMock[method] = jest.fn().mockReturnValue(queryBuilderMock); + } + + queryBuilderMock.subQuery = jest.fn().mockImplementation(() => { + const subQueryBuilderMock = createSubQueryBuilderMock(); + + onSubQueryCreated?.(subQueryBuilderMock); + + return subQueryBuilderMock; + }); + queryBuilderMock.getRawMany = jest + .fn() + .mockImplementation(() => Promise.resolve(getRawManyResult())); + queryBuilderMock.getCount = jest + .fn() + .mockImplementation(() => Promise.resolve(getCountResult())); + + return queryBuilderMock; +}; + +const findSubQueryBuilderByAlias = ( + subQueryBuilderMocks: SubQueryBuilderMock[], + alias: string, +): SubQueryBuilderMock | undefined => + subQueryBuilderMocks.find((subQueryBuilderMock) => + subQueryBuilderMock.from.mock.calls.some( + ([, fromAlias]) => fromAlias === alias, + ), + ); + +const getAndWhereConditions = (queryBuilderMock: QueryBuilderMock): string[] => + queryBuilderMock.andWhere.mock.calls + .map(([condition]) => condition) + .filter((condition): condition is string => isString(condition)); + +const RAW_ROW = { + id: 'thread-1', + title: 'Workspace setup', + workspaceId: 'workspace-1', + workspaceDisplayName: 'Acme', + userWorkspaceId: 'user-workspace-1', + userEmail: 'jane@acme.com', + userFirstName: 'Jane', + userLastName: 'Doe', + messageCount: 4, + userReplyCount: 2, + hasError: false, + isOnboardingThread: true, + deletedAt: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-02'), +}; + +const DEFAULT_ARGS = { + scope: AdminChatThreadScope.ONBOARDING, + hasErrorOnly: false, + userNeverEngagedOnly: false, + sortBy: AdminChatThreadSortField.CREATED_AT, + sortDirection: AdminChatThreadSortDirection.DESC, + limit: 25, + offset: 0, +}; + +describe('AdminPanelGlobalChatThreadsService', () => { + let service: AdminPanelGlobalChatThreadsService; + let threadQueryBuilderMocks: QueryBuilderMock[]; + let subQueryBuilderMocks: SubQueryBuilderMock[]; + let threadRawManyResult: unknown[]; + let threadCountResult: number; + + beforeEach(async () => { + threadQueryBuilderMocks = []; + subQueryBuilderMocks = []; + threadRawManyResult = []; + threadCountResult = 0; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminPanelGlobalChatThreadsService, + { + provide: getRepositoryToken(AgentChatThreadEntity), + useValue: { + createQueryBuilder: jest.fn(() => { + const queryBuilderMock = createQueryBuilderMock( + () => threadRawManyResult, + () => threadCountResult, + (subQueryBuilderMock) => + subQueryBuilderMocks.push(subQueryBuilderMock), + ); + + threadQueryBuilderMocks.push(queryBuilderMock); + + return queryBuilderMock; + }), + }, + }, + ], + }).compile(); + + service = module.get( + AdminPanelGlobalChatThreadsService, + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getGlobalChatThreads', () => { + it('should apply the onboarding fingerprint predicate only for the ONBOARDING scope', async () => { + await service.getGlobalChatThreads(DEFAULT_ARGS); + + const [listQueryBuilder, countQueryBuilder] = threadQueryBuilderMocks; + + for (const queryBuilder of [listQueryBuilder, countQueryBuilder]) { + const conditions = getAndWhereConditions(queryBuilder); + + expect( + conditions.some( + (condition) => + condition.includes('uuid_generate_v5') && + condition.includes('EXISTS (SUBQUERY)'), + ), + ).toBe(true); + expect(queryBuilder.setParameter).toHaveBeenCalledWith( + 'setupThreadNamespace', + WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE, + ); + } + + const hiddenKickoffSubQueryBuilder = subQueryBuilderMocks[0]; + + expect(hiddenKickoffSubQueryBuilder.from).toHaveBeenCalledWith( + AgentMessageEntity, + 'hiddenMessage', + ); + expect(hiddenKickoffSubQueryBuilder.andWhere).toHaveBeenCalledWith( + 'hiddenMessage.isHidden = true', + ); + }); + + it('should not apply the onboarding fingerprint predicate for the ALL scope', async () => { + await service.getGlobalChatThreads({ + ...DEFAULT_ARGS, + scope: AdminChatThreadScope.ALL, + }); + + for (const queryBuilder of threadQueryBuilderMocks) { + expect( + getAndWhereConditions(queryBuilder).some((condition) => + condition.includes('uuid_generate_v5'), + ), + ).toBe(false); + } + }); + + it('should gate both queries on live workspaces allowing impersonation', async () => { + await service.getGlobalChatThreads(DEFAULT_ARGS); + + for (const queryBuilder of threadQueryBuilderMocks) { + expect(queryBuilder.innerJoin).toHaveBeenCalledWith( + 'thread.workspace', + 'workspace', + '"workspace"."allowImpersonation" = true AND "workspace"."deletedAt" IS NULL', + ); + } + }); + + it('should keep soft-deleted user identity while joining thread owners', async () => { + await service.getGlobalChatThreads(DEFAULT_ARGS); + + for (const queryBuilder of threadQueryBuilderMocks) { + expect(queryBuilder.leftJoin).toHaveBeenCalledWith( + 'thread.userWorkspace', + 'userWorkspace', + ); + expect(queryBuilder.leftJoin).toHaveBeenCalledWith( + 'userWorkspace.user', + 'user', + ); + expect(queryBuilder.withDeleted).toHaveBeenCalled(); + } + }); + + it('should count only visible messages', async () => { + await service.getGlobalChatThreads(DEFAULT_ARGS); + + const [listQueryBuilder] = threadQueryBuilderMocks; + + expect(listQueryBuilder.leftJoin).toHaveBeenCalledWith( + 'thread.messages', + 'message', + '"message"."isHidden" = false', + ); + }); + + it('should apply error and engagement filters when requested', async () => { + await service.getGlobalChatThreads({ + ...DEFAULT_ARGS, + hasErrorOnly: true, + userNeverEngagedOnly: true, + }); + + const conditions = getAndWhereConditions(threadQueryBuilderMocks[0]); + + expect( + conditions.some((condition) => + condition.includes('"lastStreamError" IS NOT NULL'), + ), + ).toBe(true); + expect( + conditions.some((condition) => + condition.includes('NOT EXISTS (SUBQUERY)'), + ), + ).toBe(true); + expect(threadQueryBuilderMocks[0].setParameter).toHaveBeenCalledWith( + 'userMessageRole', + AgentMessageRole.USER, + ); + + const visibleUserMessageSubQueryBuilder = findSubQueryBuilderByAlias( + subQueryBuilderMocks, + 'userMessage', + ); + + expect(visibleUserMessageSubQueryBuilder?.from).toHaveBeenCalledWith( + AgentMessageEntity, + 'userMessage', + ); + expect(visibleUserMessageSubQueryBuilder?.andWhere).toHaveBeenCalledWith( + 'userMessage.role = :userMessageRole', + ); + }); + + it('should treat an answered question card as engagement', async () => { + await service.getGlobalChatThreads({ + ...DEFAULT_ARGS, + userNeverEngagedOnly: true, + }); + + for (const queryBuilder of threadQueryBuilderMocks) { + expect( + getAndWhereConditions(queryBuilder).some( + (condition) => + condition === '(NOT EXISTS (SUBQUERY) AND NOT EXISTS (SUBQUERY))', + ), + ).toBe(true); + } + + const answeredQuestionSubQueryBuilder = findSubQueryBuilderByAlias( + subQueryBuilderMocks, + 'answeredQuestionPart', + ); + + expect(answeredQuestionSubQueryBuilder?.innerJoin).toHaveBeenCalledWith( + AgentMessageEntity, + 'questionMessage', + 'questionMessage.id = answeredQuestionPart.messageId', + ); + expect(answeredQuestionSubQueryBuilder?.andWhere).toHaveBeenCalledWith( + 'answeredQuestionPart.toolName = :askQuestionsToolName', + ); + expect(answeredQuestionSubQueryBuilder?.andWhere).toHaveBeenCalledWith( + expect.stringContaining(`-> 'result' ->> 'status'`), + ); + }); + + it('should count answered question cards as user replies', async () => { + await service.getGlobalChatThreads(DEFAULT_ARGS); + + const [listQueryBuilder] = threadQueryBuilderMocks; + + const userReplyCountSelect = listQueryBuilder.addSelect.mock.calls.find( + ([, alias]) => alias === 'userReplyCount', + )?.[0]; + + expect(userReplyCountSelect).toContain( + 'FILTER (WHERE "message"."role" = :userMessageRole)', + ); + expect(userReplyCountSelect).toContain('+ (SUBQUERY)'); + expect(listQueryBuilder.setParameter).toHaveBeenCalledWith( + 'askQuestionsToolName', + ASK_QUESTIONS_TOOL_NAME, + ); + expect(listQueryBuilder.setParameter).toHaveBeenCalledWith( + 'answeredQuestionStatus', + 'answered', + ); + }); + + it('should apply the trimmed search term as an ILIKE pattern', async () => { + await service.getGlobalChatThreads({ + ...DEFAULT_ARGS, + searchTerm: ' jane@acme.com ', + }); + + expect(threadQueryBuilderMocks[0].andWhere).toHaveBeenCalledWith( + expect.any(Brackets), + { searchPattern: '%jane@acme.com%' }, + ); + }); + + it('should escape LIKE wildcards in the search term', async () => { + await service.getGlobalChatThreads({ + ...DEFAULT_ARGS, + searchTerm: 'my_workspace 100%', + }); + + expect(threadQueryBuilderMocks[0].andWhere).toHaveBeenCalledWith( + expect.any(Brackets), + { searchPattern: '%my\\_workspace 100\\%%' }, + ); + }); + + it('should sort by message count with a stable id tiebreaker', async () => { + await service.getGlobalChatThreads({ + ...DEFAULT_ARGS, + sortBy: AdminChatThreadSortField.MESSAGE_COUNT, + sortDirection: AdminChatThreadSortDirection.ASC, + }); + + const [listQueryBuilder] = threadQueryBuilderMocks; + + expect(listQueryBuilder.orderBy).toHaveBeenCalledWith( + '"messageCount"', + 'ASC', + ); + expect(listQueryBuilder.addOrderBy).toHaveBeenCalledWith( + '"thread"."id"', + 'ASC', + ); + }); + + it('should sort by the reply count alias', async () => { + await service.getGlobalChatThreads({ + ...DEFAULT_ARGS, + sortBy: AdminChatThreadSortField.REPLY_COUNT, + sortDirection: AdminChatThreadSortDirection.DESC, + }); + + expect(threadQueryBuilderMocks[0].orderBy).toHaveBeenCalledWith( + '"userReplyCount"', + 'DESC', + ); + }); + + it('should sort by thread columns for date sort fields', async () => { + await service.getGlobalChatThreads(DEFAULT_ARGS); + + expect(threadQueryBuilderMocks[0].orderBy).toHaveBeenCalledWith( + '"thread"."createdAt"', + 'DESC', + ); + }); + + it('should clamp the limit and floor the offset', async () => { + await service.getGlobalChatThreads({ + ...DEFAULT_ARGS, + limit: 500, + offset: -10, + }); + + const [listQueryBuilder] = threadQueryBuilderMocks; + + expect(listQueryBuilder.limit).toHaveBeenCalledWith(100); + expect(listQueryBuilder.offset).toHaveBeenCalledWith(0); + }); + + it('should compute hasMore from the offset, page size and total count', async () => { + threadRawManyResult = Array.from({ length: 25 }, (_, index) => ({ + ...RAW_ROW, + id: `thread-${index}`, + })); + threadCountResult = 60; + + const result = await service.getGlobalChatThreads({ + ...DEFAULT_ARGS, + offset: 25, + }); + + expect(result.totalCount).toBe(60); + expect(result.hasMore).toBe(true); + + const lastPageResult = await service.getGlobalChatThreads({ + ...DEFAULT_ARGS, + offset: 35, + }); + + expect(lastPageResult.hasMore).toBe(false); + }); + + it('should map raw rows to DTOs tolerating missing user identity', async () => { + threadRawManyResult = [ + { + ...RAW_ROW, + userEmail: null, + userFirstName: null, + userLastName: null, + hasError: true, + }, + ]; + threadCountResult = 1; + + const result = await service.getGlobalChatThreads(DEFAULT_ARGS); + + expect(result.threads).toEqual([ + expect.objectContaining({ + id: 'thread-1', + userEmail: null, + hasError: true, + isOnboardingThread: true, + messageCount: 4, + userReplyCount: 2, + }), + ]); + expect(result.hasMore).toBe(false); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts index 4feb359792..826f85ee4c 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts @@ -15,6 +15,7 @@ import { WorkerHealthIndicator } from 'src/engine/core-modules/admin-panel/indic import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service'; import { AdminPanelBillingService } from 'src/engine/core-modules/admin-panel/services/admin-panel-billing.service'; import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service'; +import { AdminPanelGlobalChatThreadsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-global-chat-threads.service'; import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service'; import { AdminPanelServerAdminService } from 'src/engine/core-modules/admin-panel/services/admin-panel-server-admin.service'; import { AdminPanelSigningKeyService } from 'src/engine/core-modules/admin-panel/services/admin-panel-signing-key.service'; @@ -93,6 +94,7 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac AdminPanelStatisticsService, AdminPanelBillingService, AdminPanelChatService, + AdminPanelGlobalChatThreadsService, AdminPanelConfigService, AdminPanelSigningKeyService, AdminPanelVersionService, @@ -112,6 +114,7 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac AdminPanelUserLookupService, AdminPanelStatisticsService, AdminPanelChatService, + AdminPanelGlobalChatThreadsService, AdminPanelConfigService, AdminPanelVersionService, MaintenanceModeService, diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts index 920fd89ca2..cf9ebd009f 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts @@ -17,6 +17,7 @@ import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/adm import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service'; import { AdminChatThreadMessagesDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-chat-thread-messages.dto'; import { AdminPanelRecentUserDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-recent-user.dto'; +import { PaginatedAdminChatThreadsDTO } from 'src/engine/core-modules/admin-panel/dtos/paginated-admin-chat-threads.dto'; import { AdminPanelTopWorkspaceDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-top-workspace.dto'; import { AdminPanelWorkspaceBillingDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-workspace-billing.dto'; import { AdminWorkspaceChatThreadDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-workspace-chat-thread.dto'; @@ -35,12 +36,16 @@ import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-p import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.dto'; import { UserLookupInput } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.input'; import { VersionInfoDTO } from 'src/engine/core-modules/admin-panel/dtos/version-info.dto'; +import { AdminChatThreadScope } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-scope.enum'; +import { AdminChatThreadSortDirection } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-direction.enum'; +import { AdminChatThreadSortField } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-field.enum'; import { HealthIndicatorId } from 'src/engine/core-modules/admin-panel/enums/health-indicator-id.enum'; import { JobStateEnum } from 'src/engine/core-modules/admin-panel/enums/job-state.enum'; import { QueueMetricsTimeRange } from 'src/engine/core-modules/admin-panel/enums/queue-metrics-time-range.enum'; import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service'; import { AdminPanelBillingService } from 'src/engine/core-modules/admin-panel/services/admin-panel-billing.service'; import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service'; +import { AdminPanelGlobalChatThreadsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-global-chat-threads.service'; import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service'; import { AdminPanelSigningKeyService } from 'src/engine/core-modules/admin-panel/services/admin-panel-signing-key.service'; import { AdminPanelServerAdminService } from 'src/engine/core-modules/admin-panel/services/admin-panel-server-admin.service'; @@ -124,6 +129,7 @@ export class AdminPanelResolver { private readonly adminStatisticsService: AdminPanelStatisticsService, private readonly adminBillingService: AdminPanelBillingService, private readonly adminChatService: AdminPanelChatService, + private readonly adminGlobalChatThreadsService: AdminPanelGlobalChatThreadsService, private readonly adminConfigService: AdminPanelConfigService, private readonly adminVersionService: AdminPanelVersionService, private readonly adminPanelHealthService: AdminPanelHealthService, @@ -797,6 +803,58 @@ export class AdminPanelResolver { return this.adminChatService.getChatThreadMessages(threadId); } + @UseGuards(ServerLevelImpersonateGuard) + @Query(() => PaginatedAdminChatThreadsDTO) + async getAdminChatThreads( + @Args('scope', { + type: () => AdminChatThreadScope, + nullable: true, + defaultValue: AdminChatThreadScope.ALL, + }) + scope: AdminChatThreadScope | null, + @Args('hasErrorOnly', { + type: () => Boolean, + nullable: true, + defaultValue: false, + }) + hasErrorOnly: boolean | null, + @Args('userNeverEngagedOnly', { + type: () => Boolean, + nullable: true, + defaultValue: false, + }) + userNeverEngagedOnly: boolean | null, + @Args('sortBy', { + type: () => AdminChatThreadSortField, + nullable: true, + defaultValue: AdminChatThreadSortField.CREATED_AT, + }) + sortBy: AdminChatThreadSortField | null, + @Args('sortDirection', { + type: () => AdminChatThreadSortDirection, + nullable: true, + defaultValue: AdminChatThreadSortDirection.DESC, + }) + sortDirection: AdminChatThreadSortDirection | null, + @Args('limit', { type: () => Int, nullable: true, defaultValue: 25 }) + limit: number | null, + @Args('offset', { type: () => Int, nullable: true, defaultValue: 0 }) + offset: number | null, + @Args('searchTerm', { type: () => String, nullable: true }) + searchTerm?: string | null, + ): Promise { + return this.adminGlobalChatThreadsService.getGlobalChatThreads({ + scope: scope ?? AdminChatThreadScope.ALL, + hasErrorOnly: hasErrorOnly ?? false, + userNeverEngagedOnly: userNeverEngagedOnly ?? false, + searchTerm: searchTerm ?? undefined, + sortBy: sortBy ?? AdminChatThreadSortField.CREATED_AT, + sortDirection: sortDirection ?? AdminChatThreadSortDirection.DESC, + limit: limit ?? 25, + offset: offset ?? 0, + }); + } + @UseGuards(AdminPanelGuard) @Query(() => ApplicationRegistrationEntity) async findOneAdminApplicationRegistration( diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/constants/admin-chat-threads-max-page-size.constant.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/constants/admin-chat-threads-max-page-size.constant.ts new file mode 100644 index 0000000000..915f54c50e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/constants/admin-chat-threads-max-page-size.constant.ts @@ -0,0 +1 @@ +export const ADMIN_CHAT_THREADS_MAX_PAGE_SIZE = 100; diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/admin-chat-message-part.dto.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/admin-chat-message-part.dto.ts index 8b1832ffdc..b67c9f594c 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/admin-chat-message-part.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/admin-chat-message-part.dto.ts @@ -1,13 +1,36 @@ -import { Field, ObjectType } from '@nestjs/graphql'; +import { Field, Int, ObjectType } from '@nestjs/graphql'; + +import GraphQLJSON from 'graphql-type-json'; @ObjectType('AdminChatMessagePart') export class AdminChatMessagePartDTO { @Field(() => String) type: string; + @Field(() => Int) + orderIndex: number; + @Field(() => String, { nullable: true }) textContent: string | null; + @Field(() => String, { nullable: true }) + reasoningContent: string | null; + @Field(() => String, { nullable: true }) toolName: string | null; + + @Field(() => String, { nullable: true }) + toolCallId: string | null; + + @Field(() => GraphQLJSON, { nullable: true }) + toolInput: unknown | null; + + @Field(() => GraphQLJSON, { nullable: true }) + toolOutput: unknown | null; + + @Field(() => String, { nullable: true }) + state: string | null; + + @Field(() => String, { nullable: true }) + errorMessage: string | null; } diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/admin-chat-message.dto.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/admin-chat-message.dto.ts index 1c46f23da4..1bac07a772 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/admin-chat-message.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/admin-chat-message.dto.ts @@ -15,6 +15,9 @@ export class AdminChatMessageDTO { @Field(() => AgentMessageRole) role: AgentMessageRole; + @Field(() => Boolean) + isHidden: boolean; + @Field(() => [AdminChatMessagePartDTO]) parts: AdminChatMessagePartDTO[]; diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/admin-chat-thread-list-item.dto.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/admin-chat-thread-list-item.dto.ts new file mode 100644 index 0000000000..5d6f959b54 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/admin-chat-thread-list-item.dto.ts @@ -0,0 +1,51 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ObjectType('AdminChatThreadListItem') +export class AdminChatThreadListItemDTO { + @Field(() => UUIDScalarType) + id: string; + + @Field(() => String, { nullable: true }) + title: string | null; + + @Field(() => UUIDScalarType) + workspaceId: string; + + @Field(() => String, { nullable: true }) + workspaceDisplayName: string | null; + + @Field(() => UUIDScalarType) + userWorkspaceId: string; + + @Field(() => String, { nullable: true }) + userEmail: string | null; + + @Field(() => String, { nullable: true }) + userFirstName: string | null; + + @Field(() => String, { nullable: true }) + userLastName: string | null; + + @Field(() => Int) + messageCount: number; + + @Field(() => Int) + userReplyCount: number; + + @Field(() => Boolean) + hasError: boolean; + + @Field(() => Boolean) + isOnboardingThread: boolean; + + @Field(() => Date, { nullable: true }) + deletedAt: Date | null; + + @Field(() => Date) + createdAt: Date; + + @Field(() => Date) + updatedAt: Date; +} diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/admin-workspace-chat-thread.dto.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/admin-workspace-chat-thread.dto.ts index 1701108c6d..fe54b907e2 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/admin-workspace-chat-thread.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/admin-workspace-chat-thread.dto.ts @@ -19,6 +19,9 @@ export class AdminWorkspaceChatThreadDTO { @Field(() => Int) conversationSize: number; + @Field(() => Int) + messageCount: number; + @Field(() => Date) createdAt: Date; diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/paginated-admin-chat-threads.dto.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/paginated-admin-chat-threads.dto.ts new file mode 100644 index 0000000000..edadcb7b40 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/paginated-admin-chat-threads.dto.ts @@ -0,0 +1,15 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; + +import { AdminChatThreadListItemDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-chat-thread-list-item.dto'; + +@ObjectType('PaginatedAdminChatThreads') +export class PaginatedAdminChatThreadsDTO { + @Field(() => [AdminChatThreadListItemDTO]) + threads: AdminChatThreadListItemDTO[]; + + @Field(() => Int) + totalCount: number; + + @Field(() => Boolean) + hasMore: boolean; +} diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/enums/admin-chat-thread-scope.enum.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/enums/admin-chat-thread-scope.enum.ts new file mode 100644 index 0000000000..13b2811cfd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/enums/admin-chat-thread-scope.enum.ts @@ -0,0 +1,11 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum AdminChatThreadScope { + ONBOARDING = 'ONBOARDING', + ALL = 'ALL', +} + +registerEnumType(AdminChatThreadScope, { + name: 'AdminChatThreadScope', + description: 'Scope of chat threads to list in the admin panel', +}); diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-direction.enum.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-direction.enum.ts new file mode 100644 index 0000000000..b22dd0f08e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-direction.enum.ts @@ -0,0 +1,11 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum AdminChatThreadSortDirection { + ASC = 'ASC', + DESC = 'DESC', +} + +registerEnumType(AdminChatThreadSortDirection, { + name: 'AdminChatThreadSortDirection', + description: 'Direction to sort admin chat threads', +}); diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-field.enum.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-field.enum.ts new file mode 100644 index 0000000000..1725f83d08 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-field.enum.ts @@ -0,0 +1,13 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum AdminChatThreadSortField { + MESSAGE_COUNT = 'MESSAGE_COUNT', + REPLY_COUNT = 'REPLY_COUNT', + CREATED_AT = 'CREATED_AT', + UPDATED_AT = 'UPDATED_AT', +} + +registerEnumType(AdminChatThreadSortField, { + name: 'AdminChatThreadSortField', + description: 'Field to sort admin chat threads by', +}); diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-chat.service.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-chat.service.ts index a9c5178b2a..f6c542838d 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-chat.service.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-chat.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; import { type AdminChatMessageDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-chat-message.dto'; @@ -11,6 +12,7 @@ import { AgentMessageEntity } from 'src/engine/metadata-modules/ai/ai-agent-exec import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; + @Injectable() export class AdminPanelChatService { constructor( @@ -33,7 +35,7 @@ export class AdminPanelChatService { select: { id: true, allowImpersonation: true }, }); - if (!workspace) { + if (!isDefined(workspace)) { throw new UserInputError('Workspace not found'); } @@ -53,17 +55,48 @@ export class AdminPanelChatService { take: 100, }); + const messageCountByThreadId = await this.getMessageCountByThreadId({ + workspaceId, + threadIds: threads.map((thread) => thread.id), + }); + return threads.map((thread) => ({ id: thread.id, title: thread.title, totalInputTokens: thread.totalInputTokens, totalOutputTokens: thread.totalOutputTokens, conversationSize: thread.conversationSize, + messageCount: messageCountByThreadId.get(thread.id) ?? 0, createdAt: thread.createdAt, updatedAt: thread.updatedAt, })); } + private async getMessageCountByThreadId({ + workspaceId, + threadIds, + }: { + workspaceId: string; + threadIds: string[]; + }): Promise> { + if (!isNonEmptyArray(threadIds)) { + return new Map(); + } + + const rows = await this.agentMessageRepository + .createQueryBuilder('message') + .select('"message"."threadId"', 'threadId') + .addSelect('COUNT(*)::int', 'messageCount') + .where( + '"message"."workspaceId" = :workspaceId AND "message"."threadId" IN (:...threadIds) AND "message"."isHidden" = false', + { workspaceId, threadIds }, + ) + .groupBy('"message"."threadId"') + .getRawMany<{ threadId: string; messageCount: number }>(); + + return new Map(rows.map((row) => [row.threadId, row.messageCount])); + } + async getChatThreadMessages(threadId: string): Promise<{ thread: AdminWorkspaceChatThreadDTO; messages: AdminChatMessageDTO[]; @@ -72,7 +105,7 @@ export class AdminPanelChatService { where: { id: threadId }, }); - if (!thread) { + if (!isDefined(thread)) { throw new UserInputError('Thread not found'); } @@ -81,7 +114,7 @@ export class AdminPanelChatService { const messages = await this.agentMessageRepository.find( thread.workspaceId, { - where: { threadId, isHidden: false }, + where: { threadId }, relations: { parts: true }, order: { createdAt: 'ASC' }, }, @@ -94,18 +127,27 @@ export class AdminPanelChatService { totalInputTokens: thread.totalInputTokens, totalOutputTokens: thread.totalOutputTokens, conversationSize: thread.conversationSize, + messageCount: messages.filter((message) => !message.isHidden).length, createdAt: thread.createdAt, updatedAt: thread.updatedAt, }, messages: messages.map((message) => ({ id: message.id, role: message.role, + isHidden: message.isHidden, parts: (message.parts ?? []) .sort((a, b) => a.orderIndex - b.orderIndex) .map((part) => ({ type: part.type, + orderIndex: part.orderIndex, textContent: part.textContent, + reasoningContent: part.reasoningContent, toolName: part.toolName, + toolCallId: part.toolCallId, + toolInput: part.toolInput, + toolOutput: part.toolOutput, + state: part.state, + errorMessage: part.errorMessage, })), createdAt: message.createdAt, })), diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-global-chat-threads.service.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-global-chat-threads.service.ts new file mode 100644 index 0000000000..bce8f618ec --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-global-chat-threads.service.ts @@ -0,0 +1,291 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { + ASK_QUESTIONS_TOOL_NAME, + type AskQuestionsToolStatus, +} from 'twenty-shared/ai'; +import { isNonEmptyString } from '@sniptt/guards'; +import { Brackets, Repository, type SelectQueryBuilder } from 'typeorm'; + +import { ADMIN_CHAT_THREADS_MAX_PAGE_SIZE } from 'src/engine/core-modules/admin-panel/constants/admin-chat-threads-max-page-size.constant'; +import { type AdminChatThreadListItemDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-chat-thread-list-item.dto'; +import { type PaginatedAdminChatThreadsDTO } from 'src/engine/core-modules/admin-panel/dtos/paginated-admin-chat-threads.dto'; +import { AdminChatThreadScope } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-scope.enum'; +import { AdminChatThreadSortDirection } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-direction.enum'; +import { AdminChatThreadSortField } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-field.enum'; +import { AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity'; +import { + AgentMessageEntity, + AgentMessageRole, +} from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity'; +import { WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE } from 'src/engine/metadata-modules/ai/ai-chat/constants/workspace-setup-chat-thread-id-namespace.constant'; +import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; + +const WORKSPACE_SETUP_THREAD_ID_EXPRESSION = `public.uuid_generate_v5( + :setupThreadNamespace::uuid, + "thread"."workspaceId"::text || ':' || "thread"."userWorkspaceId"::text +)`; + +const ANSWERED_ASK_QUESTIONS_STATUS: AskQuestionsToolStatus = 'answered'; + +const ANSWERED_ASK_QUESTIONS_PART_EXPRESSION = `"answeredQuestionPart"."toolOutput" -> 'result' ->> 'status' = :answeredQuestionStatus`; + +const ORDER_EXPRESSION_BY_SORT_FIELD: Record = + { + [AdminChatThreadSortField.MESSAGE_COUNT]: '"messageCount"', + [AdminChatThreadSortField.REPLY_COUNT]: '"userReplyCount"', + [AdminChatThreadSortField.CREATED_AT]: '"thread"."createdAt"', + [AdminChatThreadSortField.UPDATED_AT]: '"thread"."updatedAt"', + }; + +type GlobalChatThreadsArgs = { + scope: AdminChatThreadScope; + hasErrorOnly: boolean; + userNeverEngagedOnly: boolean; + searchTerm?: string; + sortBy: AdminChatThreadSortField; + sortDirection: AdminChatThreadSortDirection; + limit: number; + offset: number; +}; + +type GlobalChatThreadRawRow = { + id: string; + title: string | null; + workspaceId: string; + workspaceDisplayName: string | null; + userWorkspaceId: string; + userEmail: string | null; + userFirstName: string | null; + userLastName: string | null; + messageCount: number; + userReplyCount: number; + hasError: boolean; + isOnboardingThread: boolean; + deletedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +@Injectable() +export class AdminPanelGlobalChatThreadsService { + constructor( + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository + @InjectRepository(AgentChatThreadEntity) + private readonly agentChatThreadRepository: Repository, + ) {} + + private buildOnboardingThreadPredicate( + queryBuilder: SelectQueryBuilder, + ): string { + const hiddenKickoffMessageSubQuery = queryBuilder + .subQuery() + .select('1') + .from(AgentMessageEntity, 'hiddenMessage') + .where('hiddenMessage.threadId = thread.id') + .andWhere('hiddenMessage.isHidden = true') + .getQuery(); + + return `(EXISTS (${hiddenKickoffMessageSubQuery}) OR "thread"."id" = ${WORKSPACE_SETUP_THREAD_ID_EXPRESSION})`; + } + + private buildAnsweredQuestionSubQuery( + queryBuilder: SelectQueryBuilder, + selection: string, + ): string { + return queryBuilder + .subQuery() + .select(selection) + .from(AgentMessagePartEntity, 'answeredQuestionPart') + .innerJoin( + AgentMessageEntity, + 'questionMessage', + 'questionMessage.id = answeredQuestionPart.messageId', + ) + .where('questionMessage.threadId = thread.id') + .andWhere('questionMessage.isHidden = false') + .andWhere('answeredQuestionPart.toolName = :askQuestionsToolName') + .andWhere(ANSWERED_ASK_QUESTIONS_PART_EXPRESSION) + .getQuery(); + } + + private buildUserNeverEngagedPredicate( + queryBuilder: SelectQueryBuilder, + ): string { + const visibleUserMessageSubQuery = queryBuilder + .subQuery() + .select('1') + .from(AgentMessageEntity, 'userMessage') + .where('userMessage.threadId = thread.id') + .andWhere('userMessage.isHidden = false') + .andWhere('userMessage.role = :userMessageRole') + .getQuery(); + + const answeredQuestionSubQuery = this.buildAnsweredQuestionSubQuery( + queryBuilder, + '1', + ); + + return `(NOT EXISTS (${visibleUserMessageSubQuery}) AND NOT EXISTS (${answeredQuestionSubQuery}))`; + } + + private applyFilters( + queryBuilder: SelectQueryBuilder, + { + scope, + hasErrorOnly, + userNeverEngagedOnly, + searchTerm, + }: Pick< + GlobalChatThreadsArgs, + 'scope' | 'hasErrorOnly' | 'userNeverEngagedOnly' | 'searchTerm' + >, + ): SelectQueryBuilder { + queryBuilder + .innerJoin( + 'thread.workspace', + 'workspace', + '"workspace"."allowImpersonation" = true AND "workspace"."deletedAt" IS NULL', + ) + .leftJoin('thread.userWorkspace', 'userWorkspace') + .leftJoin('userWorkspace.user', 'user') + .withDeleted() + .setParameter( + 'setupThreadNamespace', + WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE, + ) + .setParameter('userMessageRole', AgentMessageRole.USER) + .setParameter('askQuestionsToolName', ASK_QUESTIONS_TOOL_NAME) + .setParameter('answeredQuestionStatus', ANSWERED_ASK_QUESTIONS_STATUS); + + if (scope === AdminChatThreadScope.ONBOARDING) { + queryBuilder.andWhere(this.buildOnboardingThreadPredicate(queryBuilder)); + } + + if (hasErrorOnly) { + queryBuilder.andWhere('"thread"."lastStreamError" IS NOT NULL'); + } + + if (userNeverEngagedOnly) { + queryBuilder.andWhere(this.buildUserNeverEngagedPredicate(queryBuilder)); + } + + const trimmedSearchTerm = searchTerm?.trim(); + + if (isNonEmptyString(trimmedSearchTerm)) { + const escapedSearchTerm = trimmedSearchTerm.replace(/[\\%_]/g, '\\$&'); + + queryBuilder.andWhere( + new Brackets((subQuery) => { + subQuery + .where('"workspace"."displayName" ILIKE :searchPattern') + .orWhere('"user"."email" ILIKE :searchPattern') + .orWhere('"thread"."id"::text ILIKE :searchPattern'); + }), + { searchPattern: `%${escapedSearchTerm}%` }, + ); + } + + return queryBuilder; + } + + async getGlobalChatThreads({ + scope, + hasErrorOnly, + userNeverEngagedOnly, + searchTerm, + sortBy, + sortDirection, + limit, + offset, + }: GlobalChatThreadsArgs): Promise { + const sanitizedLimit = Math.min( + Math.max(limit, 1), + ADMIN_CHAT_THREADS_MAX_PAGE_SIZE, + ); + const sanitizedOffset = Math.max(offset, 0); + + const filterArgs = { + scope, + hasErrorOnly, + userNeverEngagedOnly, + searchTerm, + }; + + const orderExpression = ORDER_EXPRESSION_BY_SORT_FIELD[sortBy]; + + const orderDirection: 'ASC' | 'DESC' = + sortDirection === AdminChatThreadSortDirection.ASC ? 'ASC' : 'DESC'; + + const listQueryBuilder = this.applyFilters( + this.agentChatThreadRepository.createQueryBuilder('thread'), + filterArgs, + ); + + const rows = await listQueryBuilder + .leftJoin('thread.messages', 'message', '"message"."isHidden" = false') + .select('thread.id', 'id') + .addSelect('thread.title', 'title') + .addSelect('thread.workspaceId', 'workspaceId') + .addSelect('thread.userWorkspaceId', 'userWorkspaceId') + .addSelect('thread.deletedAt', 'deletedAt') + .addSelect('thread.createdAt', 'createdAt') + .addSelect('thread.updatedAt', 'updatedAt') + .addSelect('workspace.displayName', 'workspaceDisplayName') + .addSelect('user.email', 'userEmail') + .addSelect('user.firstName', 'userFirstName') + .addSelect('user.lastName', 'userLastName') + .addSelect('"thread"."lastStreamError" IS NOT NULL', 'hasError') + .addSelect( + this.buildOnboardingThreadPredicate(listQueryBuilder), + 'isOnboardingThread', + ) + .addSelect('COUNT("message"."id")::int', 'messageCount') + .addSelect( + `( + (COUNT("message"."id") FILTER (WHERE "message"."role" = :userMessageRole)) + + (${this.buildAnsweredQuestionSubQuery(listQueryBuilder, 'COUNT(*)')}) + )::int`, + 'userReplyCount', + ) + .groupBy('"thread"."id"') + .addGroupBy('"workspace"."id"') + .addGroupBy('"userWorkspace"."id"') + .addGroupBy('"user"."id"') + .orderBy(orderExpression, orderDirection) + .addOrderBy('"thread"."id"', 'ASC') + .limit(sanitizedLimit) + .offset(sanitizedOffset) + .getRawMany(); + + const totalCount = await this.applyFilters( + this.agentChatThreadRepository.createQueryBuilder('thread'), + filterArgs, + ).getCount(); + + const threads: AdminChatThreadListItemDTO[] = rows.map((row) => ({ + id: row.id, + title: row.title, + workspaceId: row.workspaceId, + workspaceDisplayName: row.workspaceDisplayName, + userWorkspaceId: row.userWorkspaceId, + userEmail: row.userEmail, + userFirstName: row.userFirstName, + userLastName: row.userLastName, + messageCount: row.messageCount, + userReplyCount: row.userReplyCount, + hasError: row.hasError, + isOnboardingThread: row.isOnboardingThread, + deletedAt: row.deletedAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })); + + return { + threads, + totalCount, + hasMore: sanitizedOffset + threads.length < totalCount, + }; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/workspace-setup-chat-thread-id-namespace.constant.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/workspace-setup-chat-thread-id-namespace.constant.ts new file mode 100644 index 0000000000..07f9f0a08d --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/workspace-setup-chat-thread-id-namespace.constant.ts @@ -0,0 +1,2 @@ +export const WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE = + '1e9195f3-c26a-4bfc-961e-dc317b4badbd'; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/workspace-setup-chat.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/workspace-setup-chat.service.ts index 3c1e6a1ae2..d9eb4a3b9a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/workspace-setup-chat.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/workspace-setup-chat.service.ts @@ -13,6 +13,7 @@ import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE } from 'src/engine/metadata-modules/ai/ai-chat/constants/workspace-setup-chat-thread-id-namespace.constant'; import { type AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; import { WorkspaceSetupChatOutcome } from 'src/engine/metadata-modules/ai/ai-chat/enums/workspace-setup-chat-outcome.enum'; import { AgentChatStreamingService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service'; @@ -23,9 +24,6 @@ import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; -const WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE = - '1e9195f3-c26a-4bfc-961e-dc317b4badbd'; - const WORKSPACE_SETUP_CHAT_THREAD_TITLE = msg`Workspace setup`; type StartWorkspaceSetupChatServiceResult = diff --git a/packages/twenty-server/test/integration/graphql/suites/admin-panel/admin-chat-threads.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/admin-panel/admin-chat-threads.integration-spec.ts new file mode 100644 index 0000000000..35bdf43f3c --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/admin-panel/admin-chat-threads.integration-spec.ts @@ -0,0 +1,664 @@ +import { randomUUID } from 'crypto'; + +import { gql } from 'graphql-tag'; +import { type DataSource } from 'typeorm'; +import { v5 } from 'uuid'; + +import { makeAdminPanelAPIRequestWithGuestRole } from 'test/integration/graphql/suites/admin-panel/utils/make-admin-panel-api-request-with-guest-role.util'; +import { makeAdminPanelAPIRequest } from 'test/integration/twenty-config/utils/make-admin-panel-api-request.util'; + +import { WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE } from 'src/engine/metadata-modules/ai/ai-chat/constants/workspace-setup-chat-thread-id-namespace.constant'; +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; + +const GET_ADMIN_CHAT_THREADS = gql` + query GetAdminChatThreads( + $scope: AdminChatThreadScope + $hasErrorOnly: Boolean + $userNeverEngagedOnly: Boolean + $searchTerm: String + $sortBy: AdminChatThreadSortField + $sortDirection: AdminChatThreadSortDirection + $limit: Int + $offset: Int + ) { + getAdminChatThreads( + scope: $scope + hasErrorOnly: $hasErrorOnly + userNeverEngagedOnly: $userNeverEngagedOnly + searchTerm: $searchTerm + sortBy: $sortBy + sortDirection: $sortDirection + limit: $limit + offset: $offset + ) { + totalCount + hasMore + threads { + id + title + workspaceId + workspaceDisplayName + userWorkspaceId + userEmail + messageCount + userReplyCount + hasError + isOnboardingThread + deletedAt + createdAt + updatedAt + } + } + } +`; + +const GET_ADMIN_CHAT_THREAD_MESSAGES = gql` + query GetAdminChatThreadMessages($threadId: UUID!) { + getAdminChatThreadMessages(threadId: $threadId) { + thread { + id + messageCount + conversationSize + } + messages { + id + role + isHidden + parts { + type + orderIndex + textContent + reasoningContent + toolName + toolCallId + toolInput + toolOutput + state + errorMessage + } + } + } + } +`; + +type ThreadsResult = { + totalCount: number; + hasMore: boolean; + threads: { + id: string; + messageCount: number; + userReplyCount: number; + hasError: boolean; + isOnboardingThread: boolean; + userEmail: string | null; + }[]; +}; + +describe('Admin panel global chat threads (integration)', () => { + let dataSource: DataSource; + let userWorkspaceId: string; + let userEmail: string; + let kickoffThreadId: string; + let deterministicThreadId: string; + let regularThreadId: string; + let answeredQuestionThreadId: string; + let pendingQuestionThreadId: string; + const seededThreadIds: string[] = []; + const seededMessageIds: string[] = []; + const seededPartIds: string[] = []; + + const insertThread = async ({ + id, + title, + lastStreamError, + }: { + id: string; + title: string; + lastStreamError?: object; + }): Promise => { + await dataSource.query( + `INSERT INTO core."agentChatThread" + (id, "workspaceId", "userWorkspaceId", title, "lastStreamError") + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO UPDATE SET "lastStreamError" = EXCLUDED."lastStreamError"`, + [ + id, + SEED_APPLE_WORKSPACE_ID, + userWorkspaceId, + title, + lastStreamError ? JSON.stringify(lastStreamError) : null, + ], + ); + + seededThreadIds.push(id); + + return id; + }; + + const insertMessage = async ({ + threadId, + role, + isHidden = false, + createdAt, + }: { + threadId: string; + role: 'user' | 'assistant'; + isHidden?: boolean; + createdAt: string; + }): Promise => { + const id = randomUUID(); + + await dataSource.query( + `INSERT INTO core."agentMessage" + (id, "workspaceId", "threadId", role, "isHidden", "createdAt") + VALUES ($1, $2, $3, $4, $5, $6)`, + [id, SEED_APPLE_WORKSPACE_ID, threadId, role, isHidden, createdAt], + ); + + seededMessageIds.push(id); + + return id; + }; + + const insertPart = async ({ + messageId, + orderIndex, + type, + textContent, + reasoningContent, + toolName, + toolCallId, + toolInput, + toolOutput, + state, + }: { + messageId: string; + orderIndex: number; + type: string; + textContent?: string; + reasoningContent?: string; + toolName?: string; + toolCallId?: string; + toolInput?: object; + toolOutput?: object; + state?: string; + }): Promise => { + const id = randomUUID(); + + await dataSource.query( + `INSERT INTO core."agentMessagePart" + (id, "workspaceId", "messageId", "orderIndex", type, "textContent", + "reasoningContent", "toolName", "toolCallId", "toolInput", + "toolOutput", state) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, + [ + id, + SEED_APPLE_WORKSPACE_ID, + messageId, + orderIndex, + type, + textContent ?? null, + reasoningContent ?? null, + toolName ?? null, + toolCallId ?? null, + toolInput ? JSON.stringify(toolInput) : null, + toolOutput ? JSON.stringify(toolOutput) : null, + state ?? null, + ], + ); + + seededPartIds.push(id); + + return id; + }; + + const fetchThreads = async ( + variables: Record, + ): Promise => { + const response = await makeAdminPanelAPIRequest({ + query: GET_ADMIN_CHAT_THREADS, + variables, + }); + + expect(response.body.errors).toBeUndefined(); + + return response.body.data?.getAdminChatThreads; + }; + + beforeAll(async () => { + dataSource = global.testDataSource; + + const [firstUserWorkspace] = await dataSource.query( + `SELECT "userWorkspace".id, "user".email + FROM core."userWorkspace" "userWorkspace" + JOIN core."user" "user" ON "user".id = "userWorkspace"."userId" + WHERE "userWorkspace"."workspaceId" = $1 + AND "userWorkspace"."deletedAt" IS NULL + ORDER BY "userWorkspace"."createdAt" ASC + LIMIT 1`, + [SEED_APPLE_WORKSPACE_ID], + ); + + userWorkspaceId = firstUserWorkspace.id; + userEmail = firstUserWorkspace.email; + + kickoffThreadId = await insertThread({ + id: randomUUID(), + title: 'integration-onboarding-kickoff-thread', + }); + const hiddenKickoffMessageId = await insertMessage({ + threadId: kickoffThreadId, + role: 'user', + isHidden: true, + createdAt: '2026-01-01T00:00:00Z', + }); + + await insertPart({ + messageId: hiddenKickoffMessageId, + orderIndex: 0, + type: 'text', + textContent: 'kickoff prompt with company context', + }); + + await insertMessage({ + threadId: kickoffThreadId, + role: 'user', + createdAt: '2026-01-01T00:01:00Z', + }); + const assistantMessageId = await insertMessage({ + threadId: kickoffThreadId, + role: 'assistant', + createdAt: '2026-01-01T00:02:00Z', + }); + + await insertPart({ + messageId: assistantMessageId, + orderIndex: 1, + type: 'tool-call', + toolName: 'create_many_object_metadata', + toolCallId: 'call-1', + toolInput: { objects: [{ nameSingular: 'listing' }] }, + toolOutput: { success: true }, + state: 'output-available', + }); + await insertPart({ + messageId: assistantMessageId, + orderIndex: 0, + type: 'reasoning', + reasoningContent: 'planning the data model', + }); + + deterministicThreadId = await insertThread({ + id: v5( + `${SEED_APPLE_WORKSPACE_ID}:${userWorkspaceId}`, + WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE, + ), + title: 'integration-onboarding-deterministic-thread', + }); + + regularThreadId = await insertThread({ + id: randomUUID(), + title: 'integration-regular-thread', + lastStreamError: { message: 'stream failed' }, + }); + const regularAssistantMessageId = await insertMessage({ + threadId: regularThreadId, + role: 'assistant', + createdAt: '2026-01-01T00:03:00Z', + }); + + await insertPart({ + messageId: regularAssistantMessageId, + orderIndex: 0, + type: 'text', + textContent: 'assistant reply', + }); + + const questionItems = [ + { + header: 'Email type', + question: 'Which mailbox should we sync?', + options: [{ label: 'Work' }, { label: 'Personal' }], + }, + ]; + + answeredQuestionThreadId = await insertThread({ + id: randomUUID(), + title: 'integration-answered-question-thread', + }); + await insertMessage({ + threadId: answeredQuestionThreadId, + role: 'user', + isHidden: true, + createdAt: '2026-01-01T00:04:00Z', + }); + + const answeredQuestionMessageId = await insertMessage({ + threadId: answeredQuestionThreadId, + role: 'assistant', + createdAt: '2026-01-01T00:05:00Z', + }); + + await insertPart({ + messageId: answeredQuestionMessageId, + orderIndex: 0, + type: 'tool-ask_questions', + toolName: 'ask_questions', + toolCallId: 'call-answered-questions', + toolInput: { questions: questionItems }, + toolOutput: { + success: true, + message: 'User answered the questions.', + result: { + questions: questionItems, + status: 'answered', + answers: [{ questionIndex: 0, selectedOptionIndices: [0] }], + }, + }, + state: 'output-available', + }); + + pendingQuestionThreadId = await insertThread({ + id: randomUUID(), + title: 'integration-pending-question-thread', + }); + await insertMessage({ + threadId: pendingQuestionThreadId, + role: 'user', + isHidden: true, + createdAt: '2026-01-01T00:06:00Z', + }); + + const pendingQuestionMessageId = await insertMessage({ + threadId: pendingQuestionThreadId, + role: 'assistant', + createdAt: '2026-01-01T00:07:00Z', + }); + + await insertPart({ + messageId: pendingQuestionMessageId, + orderIndex: 0, + type: 'tool-ask_questions', + toolName: 'ask_questions', + toolCallId: 'call-pending-questions', + toolInput: { questions: questionItems }, + toolOutput: { + success: true, + message: 'Questions presented to the user; awaiting their answer.', + result: { questions: questionItems, status: 'pending' }, + }, + state: 'output-available', + }); + }); + + afterAll(async () => { + if (seededPartIds.length > 0) { + await dataSource.query( + `DELETE FROM core."agentMessagePart" WHERE id = ANY($1)`, + [seededPartIds], + ); + } + + if (seededMessageIds.length > 0) { + await dataSource.query( + `DELETE FROM core."agentMessage" WHERE id = ANY($1)`, + [seededMessageIds], + ); + } + + if (seededThreadIds.length > 0) { + await dataSource.query( + `DELETE FROM core."agentChatThread" WHERE id = ANY($1)`, + [seededThreadIds], + ); + } + + await dataSource.query( + `UPDATE core."workspace" SET "allowImpersonation" = true WHERE id = $1`, + [SEED_APPLE_WORKSPACE_ID], + ); + }); + + describe('getAdminChatThreads', () => { + it('returns only onboarding threads for the ONBOARDING scope', async () => { + const result = await fetchThreads({ scope: 'ONBOARDING', limit: 100 }); + + const threadIds = result.threads.map((thread) => thread.id); + + expect(threadIds).toContain(kickoffThreadId); + expect(threadIds).toContain(deterministicThreadId); + expect(threadIds).not.toContain(regularThreadId); + + for (const thread of result.threads) { + expect(thread.isOnboardingThread).toBe(true); + } + }); + + it('defaults to every scope when none is given', async () => { + const result = await fetchThreads({ limit: 100 }); + + const threadIds = result.threads.map((thread) => thread.id); + + expect(threadIds).toContain(regularThreadId); + expect(threadIds).toContain(kickoffThreadId); + }); + + it('returns all threads with the onboarding flag for the ALL scope', async () => { + const result = await fetchThreads({ + scope: 'ALL', + searchTerm: regularThreadId, + }); + + expect(result.totalCount).toBe(1); + expect(result.threads).toHaveLength(1); + expect(result.threads[0]).toMatchObject({ + id: regularThreadId, + isOnboardingThread: false, + hasError: true, + messageCount: 1, + userReplyCount: 0, + userEmail, + }); + }); + + it('counts only visible messages and user replies', async () => { + const result = await fetchThreads({ + scope: 'ALL', + searchTerm: kickoffThreadId, + }); + + expect(result.threads[0]).toMatchObject({ + id: kickoffThreadId, + isOnboardingThread: true, + messageCount: 2, + userReplyCount: 1, + hasError: false, + }); + }); + + it('filters threads with errors via hasErrorOnly', async () => { + const result = await fetchThreads({ + scope: 'ALL', + hasErrorOnly: true, + limit: 100, + }); + + const threadIds = result.threads.map((thread) => thread.id); + + expect(threadIds).toContain(regularThreadId); + expect(threadIds).not.toContain(kickoffThreadId); + }); + + it('filters threads without user replies via userNeverEngagedOnly', async () => { + const result = await fetchThreads({ + scope: 'ONBOARDING', + userNeverEngagedOnly: true, + limit: 100, + }); + + const threadIds = result.threads.map((thread) => thread.id); + + expect(threadIds).toContain(deterministicThreadId); + expect(threadIds).toContain(pendingQuestionThreadId); + expect(threadIds).not.toContain(kickoffThreadId); + expect(threadIds).not.toContain(answeredQuestionThreadId); + }); + + it('counts an answered question card as a user reply', async () => { + const result = await fetchThreads({ + scope: 'ALL', + searchTerm: answeredQuestionThreadId, + }); + + expect(result.threads[0]).toMatchObject({ + id: answeredQuestionThreadId, + messageCount: 1, + userReplyCount: 1, + }); + }); + + it('does not count a pending question card as a user reply', async () => { + const result = await fetchThreads({ + scope: 'ALL', + searchTerm: pendingQuestionThreadId, + }); + + expect(result.threads[0]).toMatchObject({ + id: pendingQuestionThreadId, + messageCount: 1, + userReplyCount: 0, + }); + }); + + it('sorts by message count', async () => { + const result = await fetchThreads({ + scope: 'ONBOARDING', + sortBy: 'MESSAGE_COUNT', + sortDirection: 'ASC', + limit: 100, + }); + + const threadIds = result.threads.map((thread) => thread.id); + + expect(threadIds.indexOf(deterministicThreadId)).toBeLessThan( + threadIds.indexOf(kickoffThreadId), + ); + }); + + it('sorts by reply count, counting answered question cards', async () => { + const result = await fetchThreads({ + scope: 'ALL', + sortBy: 'REPLY_COUNT', + sortDirection: 'DESC', + limit: 100, + }); + + const threadIds = result.threads.map((thread) => thread.id); + + expect(threadIds.indexOf(answeredQuestionThreadId)).toBeLessThan( + threadIds.indexOf(pendingQuestionThreadId), + ); + expect(threadIds.indexOf(kickoffThreadId)).toBeLessThan( + threadIds.indexOf(pendingQuestionThreadId), + ); + }); + + it('paginates with totalCount and hasMore', async () => { + const result = await fetchThreads({ scope: 'ONBOARDING', limit: 1 }); + + expect(result.threads).toHaveLength(1); + expect(result.totalCount).toBeGreaterThanOrEqual(2); + expect(result.hasMore).toBe(true); + }); + + it('finds threads by user email via searchTerm', async () => { + const result = await fetchThreads({ + scope: 'ALL', + searchTerm: userEmail, + limit: 100, + }); + + const threadIds = result.threads.map((thread) => thread.id); + + expect(threadIds).toContain(kickoffThreadId); + expect(threadIds).toContain(regularThreadId); + }); + + it('excludes workspaces that disabled support access', async () => { + await dataSource.query( + `UPDATE core."workspace" SET "allowImpersonation" = false WHERE id = $1`, + [SEED_APPLE_WORKSPACE_ID], + ); + + try { + const result = await fetchThreads({ + scope: 'ALL', + searchTerm: kickoffThreadId, + }); + + expect(result.totalCount).toBe(0); + expect(result.threads).toHaveLength(0); + } finally { + await dataSource.query( + `UPDATE core."workspace" SET "allowImpersonation" = true WHERE id = $1`, + [SEED_APPLE_WORKSPACE_ID], + ); + } + }); + + it('rejects a caller without the SECURITY permission flag', async () => { + const response = await makeAdminPanelAPIRequestWithGuestRole({ + query: GET_ADMIN_CHAT_THREADS, + variables: {}, + }); + + expect(response.body.errors).toBeDefined(); + expect(response.body.data?.getAdminChatThreads).toBeFalsy(); + }); + }); + + describe('getAdminChatThreadMessages', () => { + it('returns the hidden kickoff first with enriched ordered parts', async () => { + const response = await makeAdminPanelAPIRequest({ + query: GET_ADMIN_CHAT_THREAD_MESSAGES, + variables: { threadId: kickoffThreadId }, + }); + + expect(response.body.errors).toBeUndefined(); + + const result = response.body.data?.getAdminChatThreadMessages; + + expect(result.thread.messageCount).toBe(2); + expect(result.messages).toHaveLength(3); + expect(result.messages[0]).toMatchObject({ + role: 'USER', + isHidden: true, + }); + expect(result.messages[0].parts[0].textContent).toBe( + 'kickoff prompt with company context', + ); + + const assistantMessage = result.messages.find( + (message: { role: string }) => message.role === 'ASSISTANT', + ); + + expect( + assistantMessage.parts.map( + (part: { orderIndex: number }) => part.orderIndex, + ), + ).toEqual([0, 1]); + expect(assistantMessage.parts[1]).toMatchObject({ + type: 'tool-call', + toolName: 'create_many_object_metadata', + toolCallId: 'call-1', + toolInput: { objects: [{ nameSingular: 'listing' }] }, + toolOutput: { success: true }, + state: 'output-available', + }); + expect(assistantMessage.parts[0]).toMatchObject({ + type: 'reasoning', + reasoningContent: 'planning the data model', + }); + }); + }); +}); diff --git a/packages/twenty-shared/src/types/SettingsPath.ts b/packages/twenty-shared/src/types/SettingsPath.ts index 5b6a564126..b4a962fd84 100644 --- a/packages/twenty-shared/src/types/SettingsPath.ts +++ b/packages/twenty-shared/src/types/SettingsPath.ts @@ -87,6 +87,7 @@ export enum SettingsPath { AdminPanelWorkspaceDetail = 'admin-panel/workspaces/:workspaceId', AdminPanelApplicationRegistrationDetail = 'admin-panel/applications/registrations/:applicationRegistrationId', AdminPanelWorkspaceChatThread = 'admin-panel/workspaces/:workspaceId/threads/:threadId', + AdminPanelChats = 'admin-panel/chats', Roles = 'members/roles', RoleCreate = 'members/roles/create',