Add global admin panel chat list with onboarding filter and enriched transcript (#23757)
https://github.com/user-attachments/assets/ee22d0d7-6ea0-4d49-a3d2-41ce19089943 Adds a cross-workspace chat list to the admin panel (admin-panel/chats, linked from the AI tab) so we can analyze onboarding AI chats and improve the workspace-setup prompts. - Filters: onboarding only, has error, no user reply; search by workspace, user email or thread id; server-side sort by message count, replies, created or updated, with pagination. The list opens unfiltered so every chat is visible by default. - Onboarding threads are detected by fingerprint (hidden kickoff message OR deterministic uuid v5 id), so all existing setup chats are covered retroactively. The allowImpersonation gate is enforced in the query. - Replies count answered `ask_questions` cards as well as user messages: answering one writes no message row, only an in-place toolOutput update, so those chats used to look abandoned. - The admin transcript now returns the hidden kickoff prompt (collapsed in the UI) and enriched message parts: reasoning, tool input/output rendered as JSON trees, and errors. Reference chips are not navigable there since they would link into the reader's own workspace. - Fixes the workspace detail "Messages" column which displayed conversationSize (tokens) instead of the message count.
This commit is contained in:
@@ -56,23 +56,70 @@ export type AdminChatMessage = {
|
||||
__typename?: 'AdminChatMessage';
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
id: Scalars['UUID']['output'];
|
||||
isHidden: Scalars['Boolean']['output'];
|
||||
parts: Array<AdminChatMessagePart>;
|
||||
role: AgentMessageRole;
|
||||
};
|
||||
|
||||
export type AdminChatMessagePart = {
|
||||
__typename?: 'AdminChatMessagePart';
|
||||
errorMessage?: Maybe<Scalars['String']['output']>;
|
||||
orderIndex: Scalars['Int']['output'];
|
||||
reasoningContent?: Maybe<Scalars['String']['output']>;
|
||||
state?: Maybe<Scalars['String']['output']>;
|
||||
textContent?: Maybe<Scalars['String']['output']>;
|
||||
toolCallId?: Maybe<Scalars['String']['output']>;
|
||||
toolInput?: Maybe<Scalars['JSON']['output']>;
|
||||
toolName?: Maybe<Scalars['String']['output']>;
|
||||
toolOutput?: Maybe<Scalars['JSON']['output']>;
|
||||
type: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type AdminChatThreadListItem = {
|
||||
__typename?: 'AdminChatThreadListItem';
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
deletedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
hasError: Scalars['Boolean']['output'];
|
||||
id: Scalars['UUID']['output'];
|
||||
isOnboardingThread: Scalars['Boolean']['output'];
|
||||
messageCount: Scalars['Int']['output'];
|
||||
title?: Maybe<Scalars['String']['output']>;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
userEmail?: Maybe<Scalars['String']['output']>;
|
||||
userFirstName?: Maybe<Scalars['String']['output']>;
|
||||
userLastName?: Maybe<Scalars['String']['output']>;
|
||||
userReplyCount: Scalars['Int']['output'];
|
||||
userWorkspaceId: Scalars['UUID']['output'];
|
||||
workspaceDisplayName?: Maybe<Scalars['String']['output']>;
|
||||
workspaceId: Scalars['UUID']['output'];
|
||||
};
|
||||
|
||||
export type AdminChatThreadMessages = {
|
||||
__typename?: 'AdminChatThreadMessages';
|
||||
messages: Array<AdminChatMessage>;
|
||||
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<Scalars['String']['output']>;
|
||||
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<AdminChatThreadListItem>;
|
||||
totalCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type PaginatedApplicationRegistrations = {
|
||||
__typename?: 'PaginatedApplicationRegistrations';
|
||||
hasMore: Scalars['Boolean']['output'];
|
||||
@@ -601,6 +656,7 @@ export type Query = {
|
||||
getAdminAiModels: AdminAiModels;
|
||||
getAdminAiUsageByWorkspace: Array<UsageBreakdownItem>;
|
||||
getAdminChatThreadMessages: AdminChatThreadMessages;
|
||||
getAdminChatThreads: PaginatedAdminChatThreads;
|
||||
getAdminWorkspaceChatThreads: Array<AdminWorkspaceChatThread>;
|
||||
getAiProviders: Scalars['JSON']['output'];
|
||||
getConfigVariablesGrouped: ConfigVariables;
|
||||
@@ -677,6 +733,18 @@ export type QueryGetAdminChatThreadMessagesArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryGetAdminChatThreadsArgs = {
|
||||
hasErrorOnly?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
offset?: InputMaybe<Scalars['Int']['input']>;
|
||||
scope?: InputMaybe<AdminChatThreadScope>;
|
||||
searchTerm?: InputMaybe<Scalars['String']['input']>;
|
||||
sortBy?: InputMaybe<AdminChatThreadSortField>;
|
||||
sortDirection?: InputMaybe<AdminChatThreadSortDirection>;
|
||||
userNeverEngagedOnly?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
};
|
||||
|
||||
|
||||
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<AdminChatThreadScope>;
|
||||
hasErrorOnly?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
userNeverEngagedOnly?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
searchTerm?: InputMaybe<Scalars['String']['input']>;
|
||||
sortBy?: InputMaybe<AdminChatThreadSortField>;
|
||||
sortDirection?: InputMaybe<AdminChatThreadSortDirection>;
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
offset?: InputMaybe<Scalars['Int']['input']>;
|
||||
}>;
|
||||
|
||||
|
||||
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<AdminPanelRecentUsersQuery, AdminPanelRecentUsersQueryVariables>;
|
||||
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<AdminPanelTopWorkspacesQuery, AdminPanelTopWorkspacesQueryVariables>;
|
||||
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<FindOneAdminApplicationRegistrationQuery, FindOneAdminApplicationRegistrationQueryVariables>;
|
||||
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<GetAdminChatThreadMessagesQuery, GetAdminChatThreadMessagesQueryVariables>;
|
||||
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<GetAdminWorkspaceChatThreadsQuery, GetAdminWorkspaceChatThreadsQueryVariables>;
|
||||
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<GetAdminChatThreadMessagesQuery, GetAdminChatThreadMessagesQueryVariables>;
|
||||
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<GetAdminChatThreadsQuery, GetAdminChatThreadsQueryVariables>;
|
||||
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<GetAdminWorkspaceChatThreadsQuery, GetAdminWorkspaceChatThreadsQueryVariables>;
|
||||
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<GetServerAdminsQuery, GetServerAdminsQueryVariables>;
|
||||
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<GetUpgradeStatusQuery, GetUpgradeStatusQueryVariables>;
|
||||
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<GetVersionInfoQuery, GetVersionInfoQueryVariables>;
|
||||
|
||||
@@ -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 (
|
||||
<Chip
|
||||
label={displayName}
|
||||
emptyLabel={t`Untitled`}
|
||||
variant={ChipVariant.Highlighted}
|
||||
variant={ChipVariant.Static}
|
||||
leftComponent={leftComponent}
|
||||
clickable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+51
@@ -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(
|
||||
<MemoryRouter>
|
||||
<ChatReferenceNavigationEnabledContext.Provider
|
||||
value={isNavigationEnabled}
|
||||
>
|
||||
<ChatReferenceChipDisplay
|
||||
displayName="Acme"
|
||||
leftComponent={null}
|
||||
to="/objects/companies/acme-id"
|
||||
/>
|
||||
</ChatReferenceNavigationEnabledContext.Provider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const ChatReferenceNavigationEnabledContext = createContext(true);
|
||||
@@ -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={<SettingsAdminWorkspaceChatThread />}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.AdminPanelChats}
|
||||
element={<SettingsAdminChats />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
+18
-1
@@ -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 = () => {
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Chats`}
|
||||
description={t`Browse AI chat threads across all workspaces, including onboarding chats`}
|
||||
/>
|
||||
<UndecoratedLink to={getSettingsPath(SettingsPath.AdminPanelChats)}>
|
||||
<Button
|
||||
Icon={IconMessage}
|
||||
title={t`View all chats`}
|
||||
size="small"
|
||||
variant="secondary"
|
||||
/>
|
||||
</UndecoratedLink>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`AI Usage by Workspace`}
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { SettingsAdminChatsTable } from '@/settings/admin-panel/chats/components/SettingsAdminChatsTable';
|
||||
import { type AdminChatThreadListItem } from '@/settings/admin-panel/chats/types/AdminChatThreadListItem';
|
||||
import { SettingsEmptyPlaceholder } from '@/settings/components/SettingsEmptyPlaceholder';
|
||||
|
||||
type SettingsAdminChatsContentProps = {
|
||||
threads: AdminChatThreadListItem[];
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
const StyledTableContainer = styled.div`
|
||||
border-bottom: 1px solid ${themeCssVariables.border.color.light};
|
||||
margin-top: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
export const SettingsAdminChatsContent = ({
|
||||
threads,
|
||||
loading,
|
||||
error,
|
||||
}: SettingsAdminChatsContentProps) => {
|
||||
if (isDefined(error)) {
|
||||
return (
|
||||
<SettingsEmptyPlaceholder>{t`Failed to load chats. Please try again.`}</SettingsEmptyPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && !isNonEmptyArray(threads)) {
|
||||
return (
|
||||
<SettingsEmptyPlaceholder>{t`Loading chats...`}</SettingsEmptyPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNonEmptyArray(threads)) {
|
||||
return (
|
||||
<SettingsEmptyPlaceholder>{t`No chats found`}</SettingsEmptyPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledTableContainer>
|
||||
<SettingsAdminChatsTable threads={threads} />
|
||||
</StyledTableContainer>
|
||||
);
|
||||
};
|
||||
+73
@@ -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 (
|
||||
<Dropdown
|
||||
dropdownId="settings-admin-chats-filter-dropdown"
|
||||
dropdownPlacement="bottom-end"
|
||||
dropdownOffset={{ x: 0, y: 8 }}
|
||||
clickableComponent={filterButton}
|
||||
dropdownComponents={
|
||||
<DropdownContent>
|
||||
<DropdownMenuItemsContainer>
|
||||
<MenuItemToggle
|
||||
LeftIcon={IconSparkles}
|
||||
onToggleChange={() =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
onboardingOnly: !filters.onboardingOnly,
|
||||
})
|
||||
}
|
||||
toggled={filters.onboardingOnly}
|
||||
text={t`Onboarding only`}
|
||||
toggleSize="small"
|
||||
/>
|
||||
<MenuItemToggle
|
||||
LeftIcon={IconAlertTriangle}
|
||||
onToggleChange={() =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
hasErrorOnly: !filters.hasErrorOnly,
|
||||
})
|
||||
}
|
||||
toggled={filters.hasErrorOnly}
|
||||
text={t`Has error`}
|
||||
toggleSize="small"
|
||||
/>
|
||||
<MenuItemToggle
|
||||
LeftIcon={IconMessage}
|
||||
onToggleChange={() =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
userNeverEngagedOnly: !filters.userNeverEngagedOnly,
|
||||
})
|
||||
}
|
||||
toggled={filters.userNeverEngagedOnly}
|
||||
text={t`No user reply`}
|
||||
toggleSize="small"
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+58
@@ -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 (
|
||||
<Table>
|
||||
<TableRow gridAutoColumns={SETTINGS_ADMIN_CHATS_TABLE_GRID}>
|
||||
<TableHeader>{t`Workspace`}</TableHeader>
|
||||
<TableHeader>{t`User`}</TableHeader>
|
||||
<TableHeader>{t`Title`}</TableHeader>
|
||||
<SortableTableHeader
|
||||
tableId={SETTINGS_ADMIN_CHATS_TABLE_ID}
|
||||
fieldName={AdminChatThreadSortField.MESSAGE_COUNT}
|
||||
label={t`Msgs`}
|
||||
align="right"
|
||||
/>
|
||||
<SortableTableHeader
|
||||
tableId={SETTINGS_ADMIN_CHATS_TABLE_ID}
|
||||
fieldName={AdminChatThreadSortField.REPLY_COUNT}
|
||||
label={t`Replies`}
|
||||
align="right"
|
||||
/>
|
||||
<TableHeader>{t`Flags`}</TableHeader>
|
||||
<SortableTableHeader
|
||||
tableId={SETTINGS_ADMIN_CHATS_TABLE_ID}
|
||||
fieldName={AdminChatThreadSortField.CREATED_AT}
|
||||
label={t`Created`}
|
||||
align="right"
|
||||
initialSort={{
|
||||
fieldName: AdminChatThreadSortField.CREATED_AT,
|
||||
orderBy: 'DescNullsLast',
|
||||
}}
|
||||
/>
|
||||
</TableRow>
|
||||
<TableBody>
|
||||
{threads.map((thread) => (
|
||||
<SettingsAdminChatsTableRow key={thread.id} thread={thread} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
+89
@@ -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 (
|
||||
<TableRow
|
||||
to={getSettingsPath(SettingsPath.AdminPanelWorkspaceChatThread, {
|
||||
workspaceId: thread.workspaceId,
|
||||
threadId: thread.id,
|
||||
})}
|
||||
gridAutoColumns={SETTINGS_ADMIN_CHATS_TABLE_GRID}
|
||||
isClickable
|
||||
>
|
||||
<TableCell minWidth="0" overflow="hidden">
|
||||
<OverflowingTextWithTooltip
|
||||
text={
|
||||
isNonEmptyString(thread.workspaceDisplayName)
|
||||
? thread.workspaceDisplayName
|
||||
: thread.workspaceId
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell minWidth="0" overflow="hidden">
|
||||
<OverflowingTextWithTooltip
|
||||
text={isNonEmptyString(thread.userEmail) ? thread.userEmail : '-'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
color={themeCssVariables.font.color.primary}
|
||||
minWidth="0"
|
||||
overflow="hidden"
|
||||
>
|
||||
<OverflowingTextWithTooltip
|
||||
text={isNonEmptyString(thread.title) ? thread.title : t`Untitled`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">{thread.messageCount}</TableCell>
|
||||
<TableCell align="right">
|
||||
{thread.userReplyCount === 0 ? (
|
||||
<StyledZeroReplies>{thread.userReplyCount}</StyledZeroReplies>
|
||||
) : (
|
||||
thread.userReplyCount
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell minWidth="0" overflow="hidden">
|
||||
<StyledFlagsContainer>
|
||||
{thread.hasError && <Tag color="red" text={t`Error`} />}
|
||||
{isDefined(thread.deletedAt) && (
|
||||
<Tag color="gray" text={t`Archived`} />
|
||||
)}
|
||||
{thread.isOnboardingThread && (
|
||||
<Tag color="blue" text={t`Onboarding`} />
|
||||
)}
|
||||
</StyledFlagsContainer>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{new Date(thread.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
+7
@@ -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,
|
||||
};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const SETTINGS_ADMIN_CHATS_TABLE_GRID =
|
||||
'1fr 1fr 1fr 80px 80px 110px 90px';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const SETTINGS_ADMIN_CHATS_TABLE_ID = 'settings-admin-chats-table';
|
||||
+120
@@ -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<AdminChatsFilterState>(
|
||||
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,
|
||||
};
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { type GetAdminChatThreadsQuery } from '~/generated-admin/graphql';
|
||||
|
||||
export type AdminChatThreadListItem = NonNullable<
|
||||
GetAdminChatThreadsQuery['getAdminChatThreads']
|
||||
>['threads'][number];
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type AdminChatsFilterState = {
|
||||
onboardingOnly: boolean;
|
||||
hasErrorOnly: boolean;
|
||||
userNeverEngagedOnly: boolean;
|
||||
};
|
||||
+50
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+40
@@ -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,
|
||||
};
|
||||
};
|
||||
+62
@@ -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 (
|
||||
<StyledContainer>
|
||||
<StyledToggleButton
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() =>
|
||||
setIsExpanded((previousIsExpanded) => !previousIsExpanded)
|
||||
}
|
||||
>
|
||||
{label}
|
||||
{isExpanded ? (
|
||||
<IconChevronUp size={14} />
|
||||
) : (
|
||||
<IconChevronDown size={14} />
|
||||
)}
|
||||
</StyledToggleButton>
|
||||
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
|
||||
{children}
|
||||
</AnimatedExpandableContainer>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+70
@@ -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 = (
|
||||
<StyledMessageBubble isUser={isUser && !message.isHidden}>
|
||||
{!message.isHidden && <StyledRoleLabel>{message.role}</StyledRoleLabel>}
|
||||
{renderableParts.map((part) => (
|
||||
<SettingsAdminChatMessagePartRenderer
|
||||
key={part.orderIndex}
|
||||
part={part}
|
||||
isUserMessage={isUser}
|
||||
/>
|
||||
))}
|
||||
<StyledTimestamp>
|
||||
{new Date(message.createdAt).toLocaleString()}
|
||||
</StyledTimestamp>
|
||||
</StyledMessageBubble>
|
||||
);
|
||||
|
||||
if (message.isHidden) {
|
||||
return (
|
||||
<SettingsAdminChatCollapsibleSection label={t`Kickoff prompt`}>
|
||||
{messageBody}
|
||||
</SettingsAdminChatCollapsibleSection>
|
||||
);
|
||||
}
|
||||
|
||||
return messageBody;
|
||||
};
|
||||
+73
@@ -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 (
|
||||
<StyledTextContent isUser={isUserMessage}>
|
||||
{isUserMessage ? (
|
||||
part.textContent
|
||||
) : (
|
||||
<LazyMarkdownRenderer text={part.textContent} />
|
||||
)}
|
||||
</StyledTextContent>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === 'reasoning' && isNonEmptyString(part.reasoningContent)) {
|
||||
return (
|
||||
<SettingsAdminChatCollapsibleSection label={t`Reasoning`}>
|
||||
<StyledReasoningContent>{part.reasoningContent}</StyledReasoningContent>
|
||||
</SettingsAdminChatCollapsibleSection>
|
||||
);
|
||||
}
|
||||
|
||||
return <SettingsAdminChatToolCallPart part={part} />;
|
||||
};
|
||||
+18
-86
@@ -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 (
|
||||
<Card rounded>
|
||||
<TableRow gridTemplateColumns="1fr">
|
||||
@@ -87,41 +48,12 @@ export const SettingsAdminChatThreadMessageList = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledMessagesContainer>
|
||||
{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 (
|
||||
<StyledMessageBubble key={message.id} isUser={isUser}>
|
||||
<StyledRoleLabel>{message.role}</StyledRoleLabel>
|
||||
{textParts.length > 0 && (
|
||||
<StyledMessageContent isUser={isUser}>
|
||||
{isUser ? textParts : <LazyMarkdownRenderer text={textParts} />}
|
||||
</StyledMessageContent>
|
||||
)}
|
||||
{toolParts.map((part, index) => (
|
||||
<StyledMessageContent key={index} isUser={false}>
|
||||
{t`Tool call: ${part.toolName ?? ''}`}
|
||||
</StyledMessageContent>
|
||||
))}
|
||||
<StyledTimestamp>
|
||||
{new Date(message.createdAt).toLocaleString()}
|
||||
</StyledTimestamp>
|
||||
</StyledMessageBubble>
|
||||
);
|
||||
})}
|
||||
</StyledMessagesContainer>
|
||||
<ChatReferenceNavigationEnabledContext.Provider value={false}>
|
||||
<StyledMessagesContainer>
|
||||
{visibleMessages.map((message) => (
|
||||
<SettingsAdminChatMessage key={message.id} message={message} />
|
||||
))}
|
||||
</StyledMessagesContainer>
|
||||
</ChatReferenceNavigationEnabledContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
+178
@@ -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 (
|
||||
<StyledContainer>
|
||||
<StyledToggleRow
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() =>
|
||||
setIsExpanded((previousIsExpanded) => !previousIsExpanded)
|
||||
}
|
||||
>
|
||||
<StyledToolLabel>
|
||||
<IconTool size={14} />
|
||||
{toolName}
|
||||
{hasToolError && <Tag color="red" text={t`Failed`} />}
|
||||
</StyledToolLabel>
|
||||
<StyledRightContent>
|
||||
{isExpanded ? (
|
||||
<IconChevronUp size={14} />
|
||||
) : (
|
||||
<IconChevronDown size={14} />
|
||||
)}
|
||||
</StyledRightContent>
|
||||
</StyledToggleRow>
|
||||
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
|
||||
<StyledTabContainer>
|
||||
<StyledTab
|
||||
isActive={activeTab === 'output'}
|
||||
onClick={() => setActiveTab('output')}
|
||||
>
|
||||
{t`Output`}
|
||||
</StyledTab>
|
||||
<StyledTab
|
||||
isActive={activeTab === 'input'}
|
||||
onClick={() => setActiveTab('input')}
|
||||
>
|
||||
{t`Input`}
|
||||
</StyledTab>
|
||||
</StyledTabContainer>
|
||||
{isDefined(activeJsonValue) ? (
|
||||
<StyledJsonTreeContainer>
|
||||
<JsonTree
|
||||
value={activeJsonValue}
|
||||
shouldExpandNodeInitially={() => false}
|
||||
emptyArrayLabel={t`Empty Array`}
|
||||
emptyObjectLabel={t`Empty Object`}
|
||||
emptyStringLabel={t`[empty string]`}
|
||||
arrowButtonCollapsedLabel={t`Expand`}
|
||||
arrowButtonExpandedLabel={t`Collapse`}
|
||||
onNodeValueClick={copyToClipboard}
|
||||
/>
|
||||
</StyledJsonTreeContainer>
|
||||
) : (
|
||||
<StyledEmptyTabLabel>
|
||||
{activeTab === 'output' ? t`No output` : t`No input`}
|
||||
</StyledEmptyTabLabel>
|
||||
)}
|
||||
{isNonEmptyString(part.errorMessage) && (
|
||||
<StyledErrorMessage>{part.errorMessage}</StyledErrorMessage>
|
||||
)}
|
||||
</AnimatedExpandableContainer>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+9
@@ -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
|
||||
}
|
||||
|
||||
+45
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+1
@@ -8,6 +8,7 @@ export const GET_ADMIN_WORKSPACE_CHAT_THREADS = gql`
|
||||
totalInputTokens
|
||||
totalOutputTokens
|
||||
conversationSize
|
||||
messageCount
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { type GetAdminChatThreadMessagesQuery } from '~/generated-admin/graphql';
|
||||
|
||||
export type AdminChatThreadMessage = NonNullable<
|
||||
GetAdminChatThreadMessagesQuery['getAdminChatThreadMessages']
|
||||
>['messages'][number];
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { type AdminChatThreadMessage } from '@/settings/admin-panel/types/AdminChatThreadMessage';
|
||||
|
||||
export type AdminChatThreadMessagePart =
|
||||
AdminChatThreadMessage['parts'][number];
|
||||
+29
@@ -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>,
|
||||
): 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+22
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
+19
@@ -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;
|
||||
};
|
||||
+11
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
+14
-10
@@ -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 (
|
||||
<TableHeader align={align} onClick={handleClick}>
|
||||
{isSortActive && align === 'right' ? (
|
||||
isAsc ? (
|
||||
<IconArrowUp size="14" />
|
||||
) : (
|
||||
<IconArrowDown size="14" />
|
||||
)
|
||||
<StyledSortIconContainer>
|
||||
{isAsc ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||
</StyledSortIconContainer>
|
||||
) : null}
|
||||
{Icon && <Icon size={14} />}
|
||||
<TableHeaderText>{label}</TableHeaderText>
|
||||
{isSortActive && align === 'left' ? (
|
||||
isAsc ? (
|
||||
<IconArrowUp size="14" />
|
||||
) : (
|
||||
<IconArrowDown size="14" />
|
||||
)
|
||||
<StyledSortIconContainer>
|
||||
{isAsc ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||
</StyledSortIconContainer>
|
||||
) : null}
|
||||
</TableHeader>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<SettingsPageLayout
|
||||
links={[
|
||||
{
|
||||
children: t`Other`,
|
||||
href: getSettingsPath(SettingsPath.AdminPanel),
|
||||
},
|
||||
{
|
||||
children: t`Admin Panel - AI`,
|
||||
href: AI_ADMIN_PATH,
|
||||
},
|
||||
{
|
||||
children: t`Chats`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Chats`}
|
||||
description={t`Browse AI chat threads across all workspaces (${totalCount} matching)`}
|
||||
/>
|
||||
<SearchInput
|
||||
placeholder={t`Search by workspace, user email or thread id...`}
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
filterDropdown={(filterButton: ReactNode) => (
|
||||
<SettingsAdminChatsFilterDropdown
|
||||
filterButton={filterButton}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SettingsAdminChatsContent
|
||||
threads={threads}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
{hasMore && !isDefined(error) && (
|
||||
<StyledShowMoreContainer>
|
||||
<Button
|
||||
title={t`Show more`}
|
||||
Icon={IconDotsVertical}
|
||||
onClick={handleShowMore}
|
||||
disabled={isShowMoreDisabled}
|
||||
size="small"
|
||||
variant="secondary"
|
||||
/>
|
||||
</StyledShowMoreContainer>
|
||||
)}
|
||||
</Section>
|
||||
</SettingsPageContainer>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
};
|
||||
+1
-3
@@ -415,9 +415,7 @@ export const SettingsAdminWorkspaceDetail = () => {
|
||||
<TableCell color={themeCssVariables.font.color.primary}>
|
||||
{thread.title || t`Untitled`}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{thread.conversationSize}
|
||||
</TableCell>
|
||||
<TableCell align="right">{thread.messageCount}</TableCell>
|
||||
<TableCell align="right">
|
||||
{new Date(thread.updatedAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
|
||||
+219
@@ -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>(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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+499
@@ -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>(
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PaginatedAdminChatThreadsDTO> {
|
||||
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(
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const ADMIN_CHAT_THREADS_MAX_PAGE_SIZE = 100;
|
||||
+24
-1
@@ -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;
|
||||
}
|
||||
|
||||
+3
@@ -15,6 +15,9 @@ export class AdminChatMessageDTO {
|
||||
@Field(() => AgentMessageRole)
|
||||
role: AgentMessageRole;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isHidden: boolean;
|
||||
|
||||
@Field(() => [AdminChatMessagePartDTO])
|
||||
parts: AdminChatMessagePartDTO[];
|
||||
|
||||
|
||||
+51
@@ -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;
|
||||
}
|
||||
+3
@@ -19,6 +19,9 @@ export class AdminWorkspaceChatThreadDTO {
|
||||
@Field(() => Int)
|
||||
conversationSize: number;
|
||||
|
||||
@Field(() => Int)
|
||||
messageCount: number;
|
||||
|
||||
@Field(() => Date)
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
+15
@@ -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;
|
||||
}
|
||||
+11
@@ -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',
|
||||
});
|
||||
+11
@@ -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',
|
||||
});
|
||||
+13
@@ -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',
|
||||
});
|
||||
+45
-3
@@ -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<Map<string, number>> {
|
||||
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,
|
||||
})),
|
||||
|
||||
+291
@@ -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, string> =
|
||||
{
|
||||
[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<AgentChatThreadEntity>,
|
||||
) {}
|
||||
|
||||
private buildOnboardingThreadPredicate(
|
||||
queryBuilder: SelectQueryBuilder<AgentChatThreadEntity>,
|
||||
): 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<AgentChatThreadEntity>,
|
||||
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<AgentChatThreadEntity>,
|
||||
): 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<AgentChatThreadEntity>,
|
||||
{
|
||||
scope,
|
||||
hasErrorOnly,
|
||||
userNeverEngagedOnly,
|
||||
searchTerm,
|
||||
}: Pick<
|
||||
GlobalChatThreadsArgs,
|
||||
'scope' | 'hasErrorOnly' | 'userNeverEngagedOnly' | 'searchTerm'
|
||||
>,
|
||||
): SelectQueryBuilder<AgentChatThreadEntity> {
|
||||
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<PaginatedAdminChatThreadsDTO> {
|
||||
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<GlobalChatThreadRawRow>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE =
|
||||
'1e9195f3-c26a-4bfc-961e-dc317b4badbd';
|
||||
+1
-3
@@ -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 =
|
||||
|
||||
+664
@@ -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<string> => {
|
||||
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<string> => {
|
||||
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<string> => {
|
||||
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<string, unknown>,
|
||||
): Promise<ThreadsResult> => {
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user