From 8310b4ff01f57c31a24b1a6a6516cf268563e45d Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Thu, 10 Jul 2025 11:15:05 +0530 Subject: [PATCH] Show tool execution messages in AI agent chat (#13117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/user-attachments/assets/c0a42726-50ac-496e-a993-9d6076a84a6a --------- Co-authored-by: Félix Malfait --- .../src/generated-metadata/graphql.ts | 9 +- .../twenty-front/src/generated/graphql.ts | 2 + .../constants/RecordAgnosticActionsConfig.tsx | 22 +- .../hooks/useRecordAgnosticActions.ts | 25 ++ .../types/RecordAgnosticActionsKeys.ts | 1 + .../action-menu/hooks/useRegisteredActions.ts | 4 +- .../modules/apollo/services/apollo.factory.ts | 91 ++++-- .../modules/apollo/utils/streamingRestLink.ts | 28 +- .../auth/states/currentWorkspaceState.ts | 1 + .../constants/CommandMenuPagesConfig.tsx | 2 + .../hooks/useCommandMenuHotKeys.ts | 21 ++ .../hooks/useOpenAskAIPageInCommandMenu.ts | 22 ++ .../components/CommandMenuAskAIPage.tsx | 37 +++ .../command-menu/types/CommandMenuPages.ts | 1 + .../MainNavigationDrawerFixedItems.tsx | 17 +- .../users/components/UserProviderEffect.tsx | 2 + .../graphql/fragments/userQueryFragment.ts | 13 +- .../ai-agent-action/components/AIChatTab.tsx | 69 ++++- .../ai-agent-action/hooks/useAgentChat.ts | 42 ++- .../hooks/useAgentChatMessages.ts | 6 +- .../states/agentStreamingMessageState.ts | 10 +- .../utils/parseAgentStreamingChunk.ts | 43 +++ .../1752070094777-add-default-agent-id.ts | 23 ++ ...752088464449-agent-as-standard-metadata.ts | 33 +++ .../ai/constants/ai-models.const.spec.ts | 55 ++++ .../ai/constants/ai-models.const.ts | 4 + .../ai/services/ai-billing.service.ts | 2 +- .../core-modules/ai/services/mcp.service.ts | 12 +- .../core-modules/ai/services/tool.service.ts | 39 +-- ... convert-cents-to-billing-credits.util.ts} | 3 +- .../ai/utils/get-ai-model-by-id.ts | 9 - .../ai/utils/get-ai-model-by-id.util.ts | 7 + .../get-ai-models-with-auto.util.spec.ts | 35 +++ .../ai/utils/get-ai-models-with-auto.util.ts | 22 ++ .../get-default-model-config.util.spec.ts | 29 ++ .../ai/utils/get-default-model-config.util.ts | 17 ++ .../get-effective-model-config.util.spec.ts | 30 ++ .../utils/get-effective-model-config.util.ts | 20 ++ ...ponse.ts => wrap-jsonrpc-response.util.ts} | 0 .../services/client-config.service.spec.ts | 9 +- .../services/client-config.service.ts | 53 ++-- .../workspace/workspace.entity.ts | 9 + .../workspace/workspace.module.ts | 2 + .../workspace/workspace.resolver.ts | 26 ++ .../agent/agent-chat.controller.ts | 34 ++- .../agent/agent-chat.service.ts | 3 - .../agent/agent-execution.service.ts | 17 +- .../agent/agent-streaming.service.ts | 41 ++- .../agent/agent-tool.service.ts | 2 +- .../metadata-modules/agent/agent.entity.ts | 13 +- .../metadata-modules/agent/agent.exception.ts | 1 + .../metadata-modules/agent/agent.service.ts | 28 ++ .../agent/utils/agent-tool-schema.utils.ts | 90 ++++-- .../workspace-manager.service.spec.ts | 9 + .../dev-seeder/core/utils/seed-agents.util.ts | 277 ++++++++++++++++++ .../core/utils/seed-core-schema.util.ts | 3 + .../workspace-manager.module.ts | 2 + .../workspace-manager.service.ts | 31 ++ ...workflow-version-step.workspace-service.ts | 18 +- .../suites/agent/agent.integration-spec.ts | 4 +- .../agent-tool.service.integration-spec.ts | 44 +-- .../agent/utils/agent-tool-test-utils.ts | 7 +- 62 files changed, 1304 insertions(+), 227 deletions(-) create mode 100644 packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/useRecordAgnosticActions.ts create mode 100644 packages/twenty-front/src/modules/command-menu/hooks/useOpenAskAIPageInCommandMenu.ts create mode 100644 packages/twenty-front/src/modules/command-menu/pages/ask-ai/components/CommandMenuAskAIPage.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/utils/parseAgentStreamingChunk.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1752070094777-add-default-agent-id.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1752088464449-agent-as-standard-metadata.ts create mode 100644 packages/twenty-server/src/engine/core-modules/ai/constants/ai-models.const.spec.ts rename packages/twenty-server/src/engine/core-modules/ai/utils/{ai-cost.utils.ts => convert-cents-to-billing-credits.util.ts} (69%) delete mode 100644 packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-model-by-id.ts create mode 100644 packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-model-by-id.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-models-with-auto.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-models-with-auto.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/ai/utils/get-default-model-config.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/ai/utils/get-default-model-config.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/ai/utils/get-effective-model-config.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/ai/utils/get-effective-model-config.util.ts rename packages/twenty-server/src/engine/core-modules/ai/utils/{wrap-jsonrpc-response.ts => wrap-jsonrpc-response.util.ts} (100%) create mode 100644 packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-agents.util.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 11b3ba5f4..909008775 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1042,6 +1042,7 @@ export enum MessageChannelVisibility { export enum ModelProvider { ANTHROPIC = 'ANTHROPIC', + NONE = 'NONE', OPENAI = 'OPENAI' } @@ -2785,6 +2786,7 @@ export type Workspace = { customDomain?: Maybe; databaseSchema: Scalars['String']; databaseUrl: Scalars['String']; + defaultAgent?: Maybe; defaultRole?: Maybe; deletedAt?: Maybe; displayName?: Maybe; @@ -3599,7 +3601,7 @@ export type FindOneServerlessFunctionSourceCodeQueryVariables = Exact<{ export type FindOneServerlessFunctionSourceCodeQuery = { __typename?: 'Query', getServerlessFunctionSourceCode?: any | null }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: any } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; export type WorkspaceUrlsFragmentFragment = { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }; @@ -3618,7 +3620,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: any } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; export type ActivateWorkflowVersionMutationVariables = Exact<{ workflowVersionId: Scalars['String']; @@ -4042,6 +4044,9 @@ export const UserQueryFragmentFragmentDoc = gql` defaultRole { ...RoleFragment } + defaultAgent { + id + } } availableWorkspaces { ...AvailableWorkspacesFragment diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index b298b25d6..df15bedef 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -999,6 +999,7 @@ export enum MessageChannelVisibility { export enum ModelProvider { ANTHROPIC = 'ANTHROPIC', + NONE = 'NONE', OPENAI = 'OPENAI' } @@ -2613,6 +2614,7 @@ export type Workspace = { customDomain?: Maybe; databaseSchema: Scalars['String']; databaseUrl: Scalars['String']; + defaultAgent?: Maybe; defaultRole?: Maybe; deletedAt?: Maybe; displayName?: Maybe; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/constants/RecordAgnosticActionsConfig.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/constants/RecordAgnosticActionsConfig.tsx index 9ee67958e..1ae0368e1 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/constants/RecordAgnosticActionsConfig.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/constants/RecordAgnosticActionsConfig.tsx @@ -6,7 +6,7 @@ import { ActionType } from '@/action-menu/actions/types/ActionType'; import { ActionViewType } from '@/action-menu/actions/types/ActionViewType'; import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; import { msg } from '@lingui/core/macro'; -import { IconSearch } from 'twenty-ui/display'; +import { IconSearch, IconSparkles } from 'twenty-ui/display'; export const RECORD_AGNOSTIC_ACTIONS_CONFIG: Record = { [RecordAgnosticActionsKeys.SEARCH_RECORDS]: { @@ -50,4 +50,24 @@ export const RECORD_AGNOSTIC_ACTIONS_CONFIG: Record = { hotKeys: ['/'], shouldBeRegistered: () => true, }, + [RecordAgnosticActionsKeys.ASK_AI]: { + type: ActionType.Standard, + scope: ActionScope.Global, + key: RecordAgnosticActionsKeys.ASK_AI, + label: msg`Ask AI`, + shortLabel: msg`Ask AI`, + position: 2, + isPinned: false, + Icon: IconSparkles, + availableOn: [ActionViewType.GLOBAL], + component: ( + + ), + hotKeys: ['@'], + shouldBeRegistered: () => true, + }, }; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/useRecordAgnosticActions.ts b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/useRecordAgnosticActions.ts new file mode 100644 index 000000000..c3ab8ac9c --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/useRecordAgnosticActions.ts @@ -0,0 +1,25 @@ +import { RECORD_AGNOSTIC_ACTIONS_CONFIG } from '@/action-menu/actions/record-agnostic-actions/constants/RecordAgnosticActionsConfig'; +import { RecordAgnosticActionsKeys } from '@/action-menu/actions/record-agnostic-actions/types/RecordAgnosticActionsKeys'; +import { ActionConfig } from '@/action-menu/actions/types/ActionConfig'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated/graphql'; + +export const useRecordAgnosticActions = () => { + const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); + + const actions: Record = { + [RecordAgnosticActionsKeys.SEARCH_RECORDS]: + RECORD_AGNOSTIC_ACTIONS_CONFIG[RecordAgnosticActionsKeys.SEARCH_RECORDS], + [RecordAgnosticActionsKeys.SEARCH_RECORDS_FALLBACK]: + RECORD_AGNOSTIC_ACTIONS_CONFIG[ + RecordAgnosticActionsKeys.SEARCH_RECORDS_FALLBACK + ], + }; + + if (isAiEnabled) { + actions[RecordAgnosticActionsKeys.ASK_AI] = + RECORD_AGNOSTIC_ACTIONS_CONFIG[RecordAgnosticActionsKeys.ASK_AI]; + } + + return actions; +}; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/types/RecordAgnosticActionsKeys.ts b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/types/RecordAgnosticActionsKeys.ts index efeae410f..56c7e74bb 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/types/RecordAgnosticActionsKeys.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/types/RecordAgnosticActionsKeys.ts @@ -1,4 +1,5 @@ export enum RecordAgnosticActionsKeys { SEARCH_RECORDS = 'search-records', SEARCH_RECORDS_FALLBACK = 'search-records-fallback', + ASK_AI = 'ask-ai', } diff --git a/packages/twenty-front/src/modules/action-menu/hooks/useRegisteredActions.ts b/packages/twenty-front/src/modules/action-menu/hooks/useRegisteredActions.ts index 658b04d95..53d46a459 100644 --- a/packages/twenty-front/src/modules/action-menu/hooks/useRegisteredActions.ts +++ b/packages/twenty-front/src/modules/action-menu/hooks/useRegisteredActions.ts @@ -1,4 +1,4 @@ -import { RECORD_AGNOSTIC_ACTIONS_CONFIG } from '@/action-menu/actions/record-agnostic-actions/constants/RecordAgnosticActionsConfig'; +import { useRecordAgnosticActions } from '@/action-menu/actions/record-agnostic-actions/hooks/useRecordAgnosticActions'; import { ActionViewType } from '@/action-menu/actions/types/ActionViewType'; import { ShouldBeRegisteredFunctionParams } from '@/action-menu/actions/types/ShouldBeRegisteredFunctionParams'; import { getActionConfig } from '@/action-menu/actions/utils/getActionConfig'; @@ -30,7 +30,7 @@ export const useRegisteredActions = ( objectMetadataItem, }); - const recordAgnosticActionConfig = RECORD_AGNOSTIC_ACTIONS_CONFIG; + const recordAgnosticActionConfig = useRecordAgnosticActions(); const actionsConfig = { ...recordActionConfig, diff --git a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts index 28c291b48..93a24f146 100644 --- a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts +++ b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts @@ -2,7 +2,10 @@ import { ApolloClient, ApolloClientOptions, ApolloLink, + FetchResult, fromPromise, + Observable, + Operation, ServerError, ServerParseError, } from '@apollo/client'; @@ -19,7 +22,12 @@ import { AuthTokenPair } from '~/generated/graphql'; import { logDebug } from '~/utils/logDebug'; import { i18n } from '@lingui/core'; -import { GraphQLFormattedError } from 'graphql'; +import { + DefinitionNode, + DirectiveNode, + GraphQLFormattedError, + SelectionNode, +} from 'graphql'; import isEmpty from 'lodash.isempty'; import { getGenericOperationName, isDefined } from 'twenty-shared/utils'; import { REACT_APP_SERVER_BASE_URL } from '~/config'; @@ -115,45 +123,45 @@ export class ApolloFactory implements ApolloManager { }, attempts: { max: 2, - retryIf: (error) => !!error, + retryIf: (error) => { + if (this.isAuthenticationError(error)) { + return false; + } + return Boolean(error); + }, }, }); + + const handleTokenRenewal = ( + operation: Operation, + forward: (operation: Operation) => Observable, + ) => { + return fromPromise( + renewToken(uri, getTokenPair()) + .then((tokens) => { + if (isDefined(tokens)) { + onTokenPairChange?.(tokens); + cookieStorage.setItem('tokenPair', JSON.stringify(tokens)); + } + }) + .catch(() => { + onUnauthenticatedError?.(); + }), + ).flatMap(() => forward(operation)); + }; + const errorLink = onError( ({ graphQLErrors, networkError, forward, operation }) => { if (isDefined(graphQLErrors)) { onErrorCb?.(graphQLErrors); for (const graphQLError of graphQLErrors) { if (graphQLError.message === 'Unauthorized') { - return fromPromise( - renewToken(uri, getTokenPair()) - .then((tokens) => { - if (isDefined(tokens)) { - onTokenPairChange?.(tokens); - } - }) - .catch(() => { - onUnauthenticatedError?.(); - }), - ).flatMap(() => forward(operation)); + return handleTokenRenewal(operation, forward); } switch (graphQLError?.extensions?.code) { case 'UNAUTHENTICATED': { - return fromPromise( - renewToken(uri, getTokenPair()) - .then((tokens) => { - if (isDefined(tokens)) { - onTokenPairChange?.(tokens); - cookieStorage.setItem( - 'tokenPair', - JSON.stringify(tokens), - ); - } - }) - .catch(() => { - onUnauthenticatedError?.(); - }), - ).flatMap(() => forward(operation)); + return handleTokenRenewal(operation, forward); } case 'FORBIDDEN': { return; @@ -220,6 +228,13 @@ export class ApolloFactory implements ApolloManager { } if (isDefined(networkError)) { + if ( + this.isRestOperation(operation) && + this.isAuthenticationError(networkError as ServerError) + ) { + return handleTokenRenewal(operation, forward); + } + if (isDebugMode === true) { logDebug(`[Network error]: ${networkError}`); } @@ -248,6 +263,26 @@ export class ApolloFactory implements ApolloManager { }); } + private isRestOperation(operation: Operation): boolean { + return operation.query.definitions.some( + (def: DefinitionNode) => + def.kind === 'OperationDefinition' && + def.selectionSet?.selections.some( + (selection: SelectionNode) => + selection.kind === 'Field' && + selection.directives?.some( + (directive: DirectiveNode) => + directive.name.value === 'rest' || + directive.name.value === 'stream', + ), + ), + ); + } + + private isAuthenticationError(error: ServerError): boolean { + return error.statusCode === 401; + } + updateWorkspaceMember(workspaceMember: CurrentWorkspaceMember | null) { this.currentWorkspaceMember = workspaceMember; } diff --git a/packages/twenty-front/src/modules/apollo/utils/streamingRestLink.ts b/packages/twenty-front/src/modules/apollo/utils/streamingRestLink.ts index 1fdb541df..1ceb35064 100644 --- a/packages/twenty-front/src/modules/apollo/utils/streamingRestLink.ts +++ b/packages/twenty-front/src/modules/apollo/utils/streamingRestLink.ts @@ -1,4 +1,9 @@ -import { ApolloLink, Observable, Operation } from '@apollo/client/core'; +import { + ApolloLink, + Observable, + Operation, + ServerError, +} from '@apollo/client/core'; import { FetchResult } from '@apollo/client/link/core'; import { ArgumentNode, DirectiveNode } from 'graphql'; import { isDefined } from 'twenty-shared/utils'; @@ -57,7 +62,13 @@ export class StreamingRestLink extends ApolloLink { fetch(url, requestConfig) .then(async (response) => { if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const networkError = new Error( + `HTTP error! status: ${response.status}`, + ) as ServerError; + + networkError.statusCode = response.status; + + throw networkError; } if (!response.body) { @@ -66,7 +77,6 @@ export class StreamingRestLink extends ApolloLink { const reader = response.body.getReader(); const decoder = new TextDecoder(); - let accumulatedData = ''; let isStreaming = true; while (isStreaming) { @@ -79,19 +89,9 @@ export class StreamingRestLink extends ApolloLink { } const decodedChunk = decoder.decode(value, { stream: true }); - accumulatedData += decodedChunk; if (isDefined(onChunk) && typeof onChunk === 'function') { - onChunk(accumulatedData); - } - - try { - const parsedData = JSON.parse(decodedChunk); - observer.next({ data: parsedData }); - } catch { - observer.next({ - data: { streamingData: decodedChunk }, - }); + onChunk(decodedChunk); } } }) diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index 53abbe284..69d1a6541 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -25,6 +25,7 @@ export type CurrentWorkspace = Pick< | 'metadataVersion' > & { defaultRole?: Omit | null; + defaultAgent?: { id: string } | null; }; export const currentWorkspaceState = createState({ diff --git a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx index f4ecbdb70..253eaeeb2 100644 --- a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx +++ b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx @@ -1,4 +1,5 @@ import { CommandMenu } from '@/command-menu/components/CommandMenu'; +import { CommandMenuAskAIPage } from '@/command-menu/pages/ask-ai/components/CommandMenuAskAIPage'; import { CommandMenuCalendarEventPage } from '@/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage'; import { CommandMenuMessageThreadPage } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadPage'; import { CommandMenuRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuRecordPage'; @@ -32,4 +33,5 @@ export const COMMAND_MENU_PAGES_CONFIG = new Map< [CommandMenuPages.WorkflowStepView, ], [CommandMenuPages.WorkflowRunStepView, ], [CommandMenuPages.SearchRecords, ], + [CommandMenuPages.AskAI, ], ]); diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts index 2ae936f69..b648bb9f2 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts @@ -2,6 +2,7 @@ import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/Com import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { useCommandMenuHistory } from '@/command-menu/hooks/useCommandMenuHistory'; +import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu'; import { useOpenRecordsSearchPageInCommandMenu } from '@/command-menu/hooks/useOpenRecordsSearchPageInCommandMenu'; import { useSetGlobalCommandMenuContext } from '@/command-menu/hooks/useSetGlobalCommandMenuContext'; import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState'; @@ -12,15 +13,19 @@ import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeybo import { useGlobalHotkeys } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeys'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { isNonEmptyString } from '@sniptt/guards'; import { useRecoilValue } from 'recoil'; import { Key } from 'ts-key-enum'; +import { FeatureFlagKey } from '~/generated/graphql'; export const useCommandMenuHotKeys = () => { const { toggleCommandMenu } = useCommandMenu(); const { openRecordsSearchPage } = useOpenRecordsSearchPageInCommandMenu(); + const { openAskAIPage } = useOpenAskAIPageInCommandMenu(); + const { goBackFromCommandMenu } = useCommandMenuHistory(); const { setGlobalCommandMenuContext } = useSetGlobalCommandMenuContext(); @@ -31,6 +36,8 @@ export const useCommandMenuHotKeys = () => { const commandMenuPage = useRecoilValue(commandMenuPageState); + const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); + const contextStoreTargetedRecordsRuleComponent = useRecoilComponentValueV2( contextStoreTargetedRecordsRuleComponentState, COMMAND_MENU_COMPONENT_INSTANCE_ID, @@ -58,6 +65,20 @@ export const useCommandMenuHotKeys = () => { }, ); + useGlobalHotkeys( + ['@'], + () => { + if (isAiEnabled) { + openAskAIPage(); + } + }, + false, + [openAskAIPage, isAiEnabled], + { + ignoreModifiers: true, + }, + ); + useHotkeysOnFocusedElement({ keys: [Key.Escape], callback: () => { diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useOpenAskAIPageInCommandMenu.ts b/packages/twenty-front/src/modules/command-menu/hooks/useOpenAskAIPageInCommandMenu.ts new file mode 100644 index 000000000..e0aea16f6 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/hooks/useOpenAskAIPageInCommandMenu.ts @@ -0,0 +1,22 @@ +import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; +import { t } from '@lingui/core/macro'; +import { IconSparkles } from 'twenty-ui/display'; +import { v4 } from 'uuid'; + +export const useOpenAskAIPageInCommandMenu = () => { + const { navigateCommandMenu } = useCommandMenu(); + + const openAskAIPage = () => { + navigateCommandMenu({ + page: CommandMenuPages.AskAI, + pageTitle: t`Ask AI`, + pageIcon: IconSparkles, + pageId: v4(), + }); + }; + + return { + openAskAIPage, + }; +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/ask-ai/components/CommandMenuAskAIPage.tsx b/packages/twenty-front/src/modules/command-menu/pages/ask-ai/components/CommandMenuAskAIPage.tsx new file mode 100644 index 000000000..b57a49983 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/ask-ai/components/CommandMenuAskAIPage.tsx @@ -0,0 +1,37 @@ +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { AIChatTab } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatTab'; +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; + +const StyledContainer = styled.div` + height: 100%; + width: 100%; +`; + +const StyledEmptyState = styled.div` + align-items: center; + color: ${({ theme }) => theme.font.color.secondary}; + display: flex; + font-size: ${({ theme }) => theme.font.size.md}; + height: 100%; + justify-content: center; +`; + +export const CommandMenuAskAIPage = () => { + const currentWorkspace = useRecoilValue(currentWorkspaceState); + const agentId = currentWorkspace?.defaultAgent?.id; + + if (!agentId) { + return ( + + No AI Agent found. + + ); + } + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts index 6341ceebf..7a51137ab 100644 --- a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts +++ b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts @@ -11,4 +11,5 @@ export enum CommandMenuPages { WorkflowStepEdit = 'workflow-step-edit', WorkflowRunStepView = 'workflow-run-step-view', SearchRecords = 'search-records', + AskAI = 'ask-ai', } diff --git a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerFixedItems.tsx b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerFixedItems.tsx index bc9d27101..8e8db954e 100644 --- a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerFixedItems.tsx +++ b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerFixedItems.tsx @@ -1,14 +1,17 @@ +import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu'; import { useOpenRecordsSearchPageInCommandMenu } from '@/command-menu/hooks/useOpenRecordsSearchPageInCommandMenu'; import { SettingsPath } from '@/types/SettingsPath'; import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; import { navigationDrawerExpandedMemorizedState } from '@/ui/navigation/states/navigationDrawerExpandedMemorizedState'; import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useLingui } from '@lingui/react/macro'; import { useLocation, useNavigate } from 'react-router-dom'; import { useRecoilState, useSetRecoilState } from 'recoil'; -import { IconSearch, IconSettings } from 'twenty-ui/display'; +import { IconSearch, IconSettings, IconSparkles } from 'twenty-ui/display'; import { useIsMobile } from 'twenty-ui/utilities'; +import { FeatureFlagKey } from '~/generated/graphql'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; export const MainNavigationDrawerFixedItems = () => { @@ -29,6 +32,9 @@ export const MainNavigationDrawerFixedItems = () => { const { t } = useLingui(); const { openRecordsSearchPage } = useOpenRecordsSearchPageInCommandMenu(); + const { openAskAIPage } = useOpenAskAIPageInCommandMenu(); + const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); + return ( !isMobile && ( <> @@ -39,6 +45,15 @@ export const MainNavigationDrawerFixedItems = () => { keyboard={['/']} mouseUpNavigation={true} /> + {isAiEnabled && ( + + )} { setCurrentWorkspace({ ...queryData.currentUser.currentWorkspace, defaultRole: queryData.currentUser.currentWorkspace.defaultRole ?? null, + defaultAgent: + queryData.currentUser.currentWorkspace.defaultAgent ?? null, }); } diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index 1186723bc..9828a0872 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -1,13 +1,13 @@ -import { OBJECT_PERMISSION_FRAGMENT } from '@/settings/roles/graphql/fragments/objectPermissionFragment'; -import { ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/roleFragment'; -import { DELETED_WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/deletedWorkspaceMemberQueryFragment'; -import { WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/workspaceMemberQueryFragment'; -import { gql } from '@apollo/client'; import { AVAILABLE_WORKSPACE_FOR_AUTH_FRAGMENT, AVAILABLE_WORKSPACES_FOR_AUTH_FRAGMENT, } from '@/auth/graphql/fragments/authFragments'; +import { OBJECT_PERMISSION_FRAGMENT } from '@/settings/roles/graphql/fragments/objectPermissionFragment'; +import { ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/roleFragment'; import { WORKSPACE_URLS_FRAGMENT } from '@/users/graphql/fragments/workspaceUrlsFragment'; +import { DELETED_WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/deletedWorkspaceMemberQueryFragment'; +import { WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/workspaceMemberQueryFragment'; +import { gql } from '@apollo/client'; export const USER_QUERY_FRAGMENT = gql` ${ROLE_FRAGMENT} @@ -92,6 +92,9 @@ export const USER_QUERY_FRAGMENT = gql` defaultRole { ...RoleFragment } + defaultAgent { + id + } } availableWorkspaces { ...AvailableWorkspacesFragment diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatTab.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatTab.tsx index ee2b7e5ca..5948ae5d0 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatTab.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatTab.tsx @@ -1,5 +1,5 @@ import { TextArea } from '@/ui/input/components/TextArea'; -import { useTheme } from '@emotion/react'; +import { keyframes, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import React from 'react'; import { Avatar, IconDotsVertical, IconSparkles } from 'twenty-ui/display'; @@ -11,6 +11,7 @@ import { t } from '@lingui/core/macro'; import { Button } from 'twenty-ui/input'; import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; import { useAgentChat } from '../hooks/useAgentChat'; +import { AgentChatMessage } from '../hooks/useAgentChatMessages'; import { AIChatSkeletonLoader } from './AIChatSkeletonLoader'; const StyledContainer = styled.div` @@ -86,9 +87,11 @@ const StyledMessageBubble = styled.div<{ isUser?: boolean }>` } `; -const StyledMessageRow = styled.div` +const StyledMessageRow = styled.div<{ isShowingToolCall?: boolean }>` display: flex; flex-direction: row; + align-items: ${({ isShowingToolCall }) => + isShowingToolCall ? 'center' : 'flex-start'}; gap: ${({ theme }) => theme.spacing(3)}; width: 100%; `; @@ -152,6 +155,23 @@ const StyledMessageContainer = styled.div` width: 100%; `; +const dots = keyframes` + 0% { content: ''; } + 33% { content: '.'; } + 66% { content: '..'; } + 100% { content: '...'; } +`; + +const StyledToolCallContainer = styled.div` + &::after { + display: inline-block; + content: ''; + animation: ${dots} 750ms steps(3, end) infinite; + width: 2ch; + text-align: left; + } +`; + type AIChatTabProps = { agentId: string; }; @@ -168,16 +188,49 @@ export const AIChatTab: React.FC = ({ agentId }) => { agentStreamingMessage, } = useAgentChat(agentId); + const getAssistantMessageContent = (message: AgentChatMessage) => { + if (message.content !== '') { + return message.content; + } + + if (agentStreamingMessage.streamingText !== '') { + return agentStreamingMessage.streamingText; + } + + if (agentStreamingMessage.toolCall !== '') { + return ( + + {agentStreamingMessage.toolCall} + + ); + } + + return ( + + + + ); + }; + return ( {messages.length !== 0 && ( - + {messages.map((msg) => ( - + {msg.role === AgentChatMessageRole.ASSISTANT && ( = ({ agentId }) => { - {msg.role === AgentChatMessageRole.ASSISTANT && !msg.content - ? agentStreamingMessage || ( - - - - ) + {msg.role === AgentChatMessageRole.ASSISTANT + ? getAssistantMessageContent(msg) : msg.content} {msg.content && ( diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentChat.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentChat.ts index f9e98b4df..11b48fd80 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentChat.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentChat.ts @@ -8,11 +8,11 @@ import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWr import { STREAM_CHAT_QUERY } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/api/agent-chat-apollo.api'; import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role'; import { useApolloClient } from '@apollo/client'; -import { isDefined } from 'twenty-shared/utils'; import { v4 } from 'uuid'; import { agentChatInputState } from '../states/agentChatInputState'; import { agentChatMessagesComponentState } from '../states/agentChatMessagesComponentState'; import { agentStreamingMessageState } from '../states/agentStreamingMessageState'; +import { parseAgentStreamingChunk } from '../utils/parseAgentStreamingChunk'; import { AgentChatMessage, useAgentChatMessages } from './useAgentChatMessages'; import { useAgentChatThreads } from './useAgentChatThreads'; @@ -50,21 +50,14 @@ export const useAgentChat = (agentId: string) => { useAgentChatThreads(agentId); const currentThreadId = threads[0]?.id; - const { - data: messagesData, - loading: messagesLoading, - refetch: refetchMessages, - } = useAgentChatMessages(currentThreadId); + const { loading: messagesLoading, refetch: refetchMessages } = + useAgentChatMessages(currentThreadId, ({ messages }) => { + setAgentChatMessages(messages); + scrollToBottom(); + }); const isLoading = messagesLoading || threadsLoading || isStreaming; - if ( - agentChatMessages.length === 0 && - isDefined(messagesData?.messages?.length) - ) { - setAgentChatMessages(messagesData.messages); - } - const createOptimisticMessages = (content: string): AgentChatMessage[] => { const optimisticUserMessage: OptimisticMessage = { id: v4(), @@ -104,8 +97,22 @@ export const useAgentChat = (agentId: string) => { }, context: { onChunk: (chunk: string) => { - setAgentStreamingMessage(chunk); - scrollToBottom(); + parseAgentStreamingChunk(chunk, { + onTextDelta: (message: string) => { + setAgentStreamingMessage((prev) => ({ + ...prev, + streamingText: prev.streamingText + message, + })); + scrollToBottom(); + }, + onToolCall: (message: string) => { + setAgentStreamingMessage((prev) => ({ + ...prev, + toolCall: message, + })); + scrollToBottom(); + }, + }); }, }, }); @@ -128,7 +135,10 @@ export const useAgentChat = (agentId: string) => { const { data } = await refetchMessages(); setAgentChatMessages(data?.messages); - setAgentStreamingMessage(''); + setAgentStreamingMessage({ + toolCall: '', + streamingText: '', + }); scrollToBottom(); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentChatMessages.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentChatMessages.ts index f26d727e8..24f6babd0 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentChatMessages.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentChatMessages.ts @@ -11,9 +11,13 @@ export type AgentChatMessage = { createdAt: string; }; -export const useAgentChatMessages = (threadId: string) => { +export const useAgentChatMessages = ( + threadId: string, + onCompleted?: (data: { messages: AgentChatMessage[] }) => void, +) => { return useQuery<{ messages: AgentChatMessage[] }>(GET_AGENT_CHAT_MESSAGES, { variables: { threadId }, skip: !isDefined(threadId), + onCompleted, }); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentStreamingMessageState.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentStreamingMessageState.ts index 5aaffced5..c0cc47d5c 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentStreamingMessageState.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentStreamingMessageState.ts @@ -1,6 +1,12 @@ import { atom } from 'recoil'; -export const agentStreamingMessageState = atom({ +export const agentStreamingMessageState = atom<{ + toolCall: string; + streamingText: string; +}>({ key: 'agentStreamingMessageState', - default: '', + default: { + toolCall: '', + streamingText: '', + }, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/utils/parseAgentStreamingChunk.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/utils/parseAgentStreamingChunk.ts new file mode 100644 index 000000000..ebad33061 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/utils/parseAgentStreamingChunk.ts @@ -0,0 +1,43 @@ +export type AgentStreamingEvent = { + type: 'text-delta' | 'tool-call'; + message: string; +}; + +export type AgentStreamingParserCallbacks = { + onTextDelta?: (message: string) => void; + onToolCall?: (message: string) => void; + onError?: (error: Error, rawLine: string) => void; +}; + +export const parseAgentStreamingChunk = ( + chunk: string, + callbacks: AgentStreamingParserCallbacks, +): void => { + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.trim() !== '') { + try { + const event = JSON.parse(line) as AgentStreamingEvent; + + switch (event.type) { + case 'text-delta': + callbacks.onTextDelta?.(event.message); + break; + case 'tool-call': + callbacks.onToolCall?.(event.message); + break; + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to parse stream event:', error); + + const errorMessage = + error instanceof Error + ? error + : new Error(`Unknown parsing error: ${String(error)}`); + callbacks.onError?.(errorMessage, line); + } + } + } +}; diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1752070094777-add-default-agent-id.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1752070094777-add-default-agent-id.ts new file mode 100644 index 000000000..c946e0934 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1752070094777-add-default-agent-id.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDefaultAgentId1752070094777 implements MigrationInterface { + name = 'AddDefaultAgentId1752070094777'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "defaultAgentId" uuid`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agent" ALTER COLUMN "modelId" SET DEFAULT 'auto'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."agent" ALTER COLUMN "modelId" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "defaultAgentId"`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1752088464449-agent-as-standard-metadata.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1752088464449-agent-as-standard-metadata.ts new file mode 100644 index 000000000..8d8641a4c --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1752088464449-agent-as-standard-metadata.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AgentAsStandardMetadata1752088464449 + implements MigrationInterface +{ + name = 'AgentAsStandardMetadata1752088464449'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."agent" ADD "label" character varying NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agent" ADD "icon" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agent" ADD "isCustom" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agent" ADD CONSTRAINT "IDX_AGENT_NAME_WORKSPACE_ID_UNIQUE" UNIQUE ("name", "workspaceId")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."agent" DROP CONSTRAINT "IDX_AGENT_NAME_WORKSPACE_ID_UNIQUE"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agent" DROP COLUMN "isCustom"`, + ); + await queryRunner.query(`ALTER TABLE "core"."agent" DROP COLUMN "icon"`); + await queryRunner.query(`ALTER TABLE "core"."agent" DROP COLUMN "label"`); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/ai/constants/ai-models.const.spec.ts b/packages/twenty-server/src/engine/core-modules/ai/constants/ai-models.const.spec.ts new file mode 100644 index 000000000..088b894e1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/constants/ai-models.const.spec.ts @@ -0,0 +1,55 @@ +import { getAIModelsWithAuto } from 'src/engine/core-modules/ai/utils/get-ai-models-with-auto.util'; +import { getDefaultModelConfig } from 'src/engine/core-modules/ai/utils/get-default-model-config.util'; + +import { AI_MODELS, DEFAULT_MODEL_ID, ModelProvider } from './ai-models.const'; + +describe('AI_MODELS', () => { + it('should contain all expected models', () => { + expect(AI_MODELS).toHaveLength(6); + expect(AI_MODELS.map((model) => model.modelId)).toEqual([ + 'gpt-4o', + 'gpt-4o-mini', + 'gpt-4-turbo', + 'claude-opus-4-20250514', + 'claude-sonnet-4-20250514', + 'claude-3-5-haiku-20241022', + ]); + }); + + it('should have the default model as the first model', () => { + const DEFAULT_MODEL = AI_MODELS.find( + (model) => model.modelId === DEFAULT_MODEL_ID, + ); + + expect(DEFAULT_MODEL).toBeDefined(); + expect(DEFAULT_MODEL?.modelId).toBe(DEFAULT_MODEL_ID); + }); +}); + +describe('getAIModelsWithAuto', () => { + it('should return AI_MODELS with auto model prepended', () => { + const ORIGINAL_MODELS = AI_MODELS; + const MODELS_WITH_AUTO = getAIModelsWithAuto(); + + expect(MODELS_WITH_AUTO).toHaveLength(ORIGINAL_MODELS.length + 1); + expect(MODELS_WITH_AUTO[0].modelId).toBe('auto'); + expect(MODELS_WITH_AUTO[0].label).toBe('Auto'); + expect(MODELS_WITH_AUTO[0].provider).toBe(ModelProvider.NONE); + + // Check that the rest of the models are the same + expect(MODELS_WITH_AUTO.slice(1)).toEqual(ORIGINAL_MODELS); + }); + + it('should have auto model with default model costs', () => { + const MODELS_WITH_AUTO = getAIModelsWithAuto(); + const AUTO_MODEL = MODELS_WITH_AUTO[0]; + const DEFAULT_MODEL = getDefaultModelConfig(); + + expect(AUTO_MODEL.inputCostPer1kTokensInCents).toBe( + DEFAULT_MODEL.inputCostPer1kTokensInCents, + ); + expect(AUTO_MODEL.outputCostPer1kTokensInCents).toBe( + DEFAULT_MODEL.outputCostPer1kTokensInCents, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/ai/constants/ai-models.const.ts b/packages/twenty-server/src/engine/core-modules/ai/constants/ai-models.const.ts index eb2218880..1cc29639f 100644 --- a/packages/twenty-server/src/engine/core-modules/ai/constants/ai-models.const.ts +++ b/packages/twenty-server/src/engine/core-modules/ai/constants/ai-models.const.ts @@ -1,9 +1,11 @@ export enum ModelProvider { + NONE = 'none', OPENAI = 'openai', ANTHROPIC = 'anthropic', } export type ModelId = + | 'auto' | 'gpt-4o' | 'gpt-4o-mini' | 'gpt-4-turbo' @@ -11,6 +13,8 @@ export type ModelId = | 'claude-sonnet-4-20250514' | 'claude-3-5-haiku-20241022'; +export const DEFAULT_MODEL_ID: ModelId = 'gpt-4o'; + export interface AIModelConfig { modelId: ModelId; label: string; diff --git a/packages/twenty-server/src/engine/core-modules/ai/services/ai-billing.service.ts b/packages/twenty-server/src/engine/core-modules/ai/services/ai-billing.service.ts index 3bf3b3530..6aff7756e 100644 --- a/packages/twenty-server/src/engine/core-modules/ai/services/ai-billing.service.ts +++ b/packages/twenty-server/src/engine/core-modules/ai/services/ai-billing.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const'; import { DOLLAR_TO_CREDIT_MULTIPLIER } from 'src/engine/core-modules/ai/constants/dollar-to-credit-multiplier'; -import { getAIModelById } from 'src/engine/core-modules/ai/utils/get-ai-model-by-id'; +import { getAIModelById } from 'src/engine/core-modules/ai/utils/get-ai-model-by-id.util'; import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant'; import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names'; import { BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type'; diff --git a/packages/twenty-server/src/engine/core-modules/ai/services/mcp.service.ts b/packages/twenty-server/src/engine/core-modules/ai/services/mcp.service.ts index 75c8d99fa..35371cdab 100644 --- a/packages/twenty-server/src/engine/core-modules/ai/services/mcp.service.ts +++ b/packages/twenty-server/src/engine/core-modules/ai/services/mcp.service.ts @@ -1,19 +1,19 @@ -import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { isDefined } from 'twenty-shared/utils'; import { ToolSet } from 'ai'; +import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; +import { JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc'; +import { ToolService } from 'src/engine/core-modules/ai/services/tool.service'; +import { wrapJsonRpcResponse } from 'src/engine/core-modules/ai/utils/wrap-jsonrpc-response.util'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; -import { ToolService } from 'src/engine/core-modules/ai/services/tool.service'; -import { JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc'; -import { wrapJsonRpcResponse } from 'src/engine/core-modules/ai/utils/wrap-jsonrpc-response'; import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/admin-role-label.constants'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; @Injectable() export class McpService { diff --git a/packages/twenty-server/src/engine/core-modules/ai/services/tool.service.ts b/packages/twenty-server/src/engine/core-modules/ai/services/tool.service.ts index 747f9426f..736c7fee5 100644 --- a/packages/twenty-server/src/engine/core-modules/ai/services/tool.service.ts +++ b/packages/twenty-server/src/engine/core-modules/ai/services/tool.service.ts @@ -1,30 +1,31 @@ import { Injectable } from '@nestjs/common'; +import { ToolSet } from 'ai'; import { + ILike, In, IsNull, LessThan, LessThanOrEqual, Like, - ILike, MoreThan, MoreThanOrEqual, Not, } from 'typeorm'; -import { ToolSet } from 'ai'; -import { z } from 'zod'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; -import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; -import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service'; import { generateBulkDeleteToolSchema, + generateFindOneToolSchema, generateFindToolSchema, + generateSoftDeleteToolSchema, getRecordInputSchema, } from 'src/engine/metadata-modules/agent/utils/agent-tool-schema.utils'; import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-related-object.util'; +import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; +import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; @Injectable() export class ToolService { @@ -72,7 +73,7 @@ export class ToolService { execute: async (parameters) => { return this.createRecord( objectMetadata.nameSingular, - parameters, + parameters.input, workspaceId, roleId, ); @@ -85,7 +86,7 @@ export class ToolService { execute: async (parameters) => { return this.updateRecord( objectMetadata.nameSingular, - parameters, + parameters.input, workspaceId, roleId, ); @@ -100,7 +101,7 @@ export class ToolService { execute: async (parameters) => { return this.findRecords( objectMetadata.nameSingular, - parameters, + parameters.input, workspaceId, roleId, ); @@ -109,15 +110,11 @@ export class ToolService { tools[`find_one_${objectMetadata.nameSingular}`] = { description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`, - parameters: z.object({ - id: z - .string() - .describe('The unique UUID of the record to retrieve'), - }), + parameters: generateFindOneToolSchema(), execute: async (parameters) => { return this.findOneRecord( objectMetadata.nameSingular, - parameters, + parameters.input, workspaceId, roleId, ); @@ -128,15 +125,11 @@ export class ToolService { if (objectPermission.canSoftDelete) { tools[`soft_delete_${objectMetadata.nameSingular}`] = { description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`, - parameters: z.object({ - id: z - .string() - .describe('The unique UUID of the record to soft delete'), - }), + parameters: generateSoftDeleteToolSchema(), execute: async (parameters) => { return this.softDeleteRecord( objectMetadata.nameSingular, - parameters, + parameters.input, workspaceId, roleId, ); @@ -149,7 +142,7 @@ export class ToolService { execute: async (parameters) => { return this.softDeleteManyRecords( objectMetadata.nameSingular, - parameters, + parameters.input, workspaceId, roleId, ); diff --git a/packages/twenty-server/src/engine/core-modules/ai/utils/ai-cost.utils.ts b/packages/twenty-server/src/engine/core-modules/ai/utils/convert-cents-to-billing-credits.util.ts similarity index 69% rename from packages/twenty-server/src/engine/core-modules/ai/utils/ai-cost.utils.ts rename to packages/twenty-server/src/engine/core-modules/ai/utils/convert-cents-to-billing-credits.util.ts index cff71218f..4fb6e664d 100644 --- a/packages/twenty-server/src/engine/core-modules/ai/utils/ai-cost.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/ai/utils/convert-cents-to-billing-credits.util.ts @@ -4,4 +4,5 @@ * @param cents - Cost in cents (real cost) * @returns Cost in credits (end-user cost) */ -export const convertCentsToCredits = (cents: number): number => cents * 10; +export const convertCentsToBillingCredits = (cents: number): number => + cents * 10; diff --git a/packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-model-by-id.ts b/packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-model-by-id.ts deleted file mode 100644 index e90dd835b..000000000 --- a/packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-model-by-id.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { - AI_MODELS, - AIModelConfig, - ModelId, -} from 'src/engine/core-modules/ai/constants/ai-models.const'; - -export const getAIModelById = (modelId: ModelId): AIModelConfig | undefined => { - return AI_MODELS.find((model) => model.modelId === modelId); -}; diff --git a/packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-model-by-id.util.ts b/packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-model-by-id.util.ts new file mode 100644 index 000000000..aeb22df97 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-model-by-id.util.ts @@ -0,0 +1,7 @@ +import { AIModelConfig } from 'src/engine/core-modules/ai/constants/ai-models.const'; + +import { getEffectiveModelConfig } from './get-effective-model-config.util'; + +export const getAIModelById = (modelId: string): AIModelConfig => { + return getEffectiveModelConfig(modelId); +}; diff --git a/packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-models-with-auto.util.spec.ts b/packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-models-with-auto.util.spec.ts new file mode 100644 index 000000000..26d961642 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-models-with-auto.util.spec.ts @@ -0,0 +1,35 @@ +import { + AI_MODELS, + ModelProvider, +} from 'src/engine/core-modules/ai/constants/ai-models.const'; + +import { getAIModelsWithAuto } from './get-ai-models-with-auto.util'; +import { getDefaultModelConfig } from './get-default-model-config.util'; + +describe('getAIModelsWithAuto', () => { + it('should return AI_MODELS with auto model prepended', () => { + const ORIGINAL_MODELS = AI_MODELS; + const MODELS_WITH_AUTO = getAIModelsWithAuto(); + + expect(MODELS_WITH_AUTO).toHaveLength(ORIGINAL_MODELS.length + 1); + expect(MODELS_WITH_AUTO[0].modelId).toBe('auto'); + expect(MODELS_WITH_AUTO[0].label).toBe('Auto'); + expect(MODELS_WITH_AUTO[0].provider).toBe(ModelProvider.NONE); + + // Check that the rest of the models are the same + expect(MODELS_WITH_AUTO.slice(1)).toEqual(ORIGINAL_MODELS); + }); + + it('should have auto model with default model costs', () => { + const MODELS_WITH_AUTO = getAIModelsWithAuto(); + const AUTO_MODEL = MODELS_WITH_AUTO[0]; + const DEFAULT_MODEL = getDefaultModelConfig(); + + expect(AUTO_MODEL.inputCostPer1kTokensInCents).toBe( + DEFAULT_MODEL.inputCostPer1kTokensInCents, + ); + expect(AUTO_MODEL.outputCostPer1kTokensInCents).toBe( + DEFAULT_MODEL.outputCostPer1kTokensInCents, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-models-with-auto.util.ts b/packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-models-with-auto.util.ts new file mode 100644 index 000000000..330c74f60 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/utils/get-ai-models-with-auto.util.ts @@ -0,0 +1,22 @@ +import { + AI_MODELS, + AIModelConfig, + ModelProvider, +} from 'src/engine/core-modules/ai/constants/ai-models.const'; + +import { getDefaultModelConfig } from './get-default-model-config.util'; + +export const getAIModelsWithAuto = (): AIModelConfig[] => { + return [ + { + modelId: 'auto', + label: 'Auto', + provider: ModelProvider.NONE, + inputCostPer1kTokensInCents: + getDefaultModelConfig().inputCostPer1kTokensInCents, + outputCostPer1kTokensInCents: + getDefaultModelConfig().outputCostPer1kTokensInCents, + }, + ...AI_MODELS, + ]; +}; diff --git a/packages/twenty-server/src/engine/core-modules/ai/utils/get-default-model-config.util.spec.ts b/packages/twenty-server/src/engine/core-modules/ai/utils/get-default-model-config.util.spec.ts new file mode 100644 index 000000000..563726ce7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/utils/get-default-model-config.util.spec.ts @@ -0,0 +1,29 @@ +import { + AI_MODELS, + DEFAULT_MODEL_ID, + ModelProvider, +} from 'src/engine/core-modules/ai/constants/ai-models.const'; + +import { getDefaultModelConfig } from './get-default-model-config.util'; + +describe('getDefaultModelConfig', () => { + it('should return the configuration for the default model', () => { + const result = getDefaultModelConfig(); + + expect(result).toBeDefined(); + expect(result.modelId).toBe(DEFAULT_MODEL_ID); + expect(result.provider).toBe(ModelProvider.OPENAI); + }); + + it('should throw an error if default model is not found', () => { + const originalFind = AI_MODELS.find; + + AI_MODELS.find = jest.fn().mockReturnValue(undefined); + + expect(() => getDefaultModelConfig()).toThrow( + `Default model with ID ${DEFAULT_MODEL_ID} not found`, + ); + + AI_MODELS.find = originalFind; + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/ai/utils/get-default-model-config.util.ts b/packages/twenty-server/src/engine/core-modules/ai/utils/get-default-model-config.util.ts new file mode 100644 index 000000000..a38a26f6e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/utils/get-default-model-config.util.ts @@ -0,0 +1,17 @@ +import { + AI_MODELS, + AIModelConfig, + DEFAULT_MODEL_ID, +} from 'src/engine/core-modules/ai/constants/ai-models.const'; + +export const getDefaultModelConfig = (): AIModelConfig => { + const defaultModel = AI_MODELS.find( + (model) => model.modelId === DEFAULT_MODEL_ID, + ); + + if (!defaultModel) { + throw new Error(`Default model with ID ${DEFAULT_MODEL_ID} not found`); + } + + return defaultModel; +}; diff --git a/packages/twenty-server/src/engine/core-modules/ai/utils/get-effective-model-config.util.spec.ts b/packages/twenty-server/src/engine/core-modules/ai/utils/get-effective-model-config.util.spec.ts new file mode 100644 index 000000000..1936179a9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/utils/get-effective-model-config.util.spec.ts @@ -0,0 +1,30 @@ +import { + DEFAULT_MODEL_ID, + ModelProvider, +} from 'src/engine/core-modules/ai/constants/ai-models.const'; + +import { getEffectiveModelConfig } from './get-effective-model-config.util'; + +describe('getEffectiveModelConfig', () => { + it('should return default model config when modelId is "auto"', () => { + const result = getEffectiveModelConfig('auto'); + + expect(result).toBeDefined(); + expect(result.modelId).toBe(DEFAULT_MODEL_ID); + expect(result.provider).toBe(ModelProvider.OPENAI); + }); + + it('should return the correct model config for a specific model', () => { + const result = getEffectiveModelConfig('gpt-4o'); + + expect(result).toBeDefined(); + expect(result.modelId).toBe('gpt-4o'); + expect(result.provider).toBe(ModelProvider.OPENAI); + }); + + it('should throw an error for non-existent model', () => { + expect(() => getEffectiveModelConfig('non-existent-model' as any)).toThrow( + `Model with ID non-existent-model not found`, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/ai/utils/get-effective-model-config.util.ts b/packages/twenty-server/src/engine/core-modules/ai/utils/get-effective-model-config.util.ts new file mode 100644 index 000000000..a59f3ff65 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/utils/get-effective-model-config.util.ts @@ -0,0 +1,20 @@ +import { + AI_MODELS, + AIModelConfig, +} from 'src/engine/core-modules/ai/constants/ai-models.const'; + +import { getDefaultModelConfig } from './get-default-model-config.util'; + +export const getEffectiveModelConfig = (modelId: string): AIModelConfig => { + if (modelId === 'auto') { + return getDefaultModelConfig(); + } + + const model = AI_MODELS.find((model) => model.modelId === modelId); + + if (!model) { + throw new Error(`Model with ID ${modelId} not found`); + } + + return model; +}; diff --git a/packages/twenty-server/src/engine/core-modules/ai/utils/wrap-jsonrpc-response.ts b/packages/twenty-server/src/engine/core-modules/ai/utils/wrap-jsonrpc-response.util.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/ai/utils/wrap-jsonrpc-response.ts rename to packages/twenty-server/src/engine/core-modules/ai/utils/wrap-jsonrpc-response.util.ts diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts index b61c3ebc9..7246b1a13 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts @@ -9,9 +9,12 @@ import { DomainManagerService } from 'src/engine/core-modules/domain-manager/ser import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -jest.mock('src/engine/core-modules/ai/constants/ai-models.const', () => ({ - AI_MODELS: [], -})); +jest.mock( + 'src/engine/core-modules/ai/utils/get-ai-models-with-auto.util', + () => ({ + getAIModelsWithAuto: jest.fn(() => []), + }), +); describe('ClientConfigService', () => { let service: ClientConfigService; diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts index 18ca4da3c..ae61139c9 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts @@ -3,11 +3,9 @@ import { Injectable } from '@nestjs/common'; import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface'; -import { - AI_MODELS, - ModelProvider, -} from 'src/engine/core-modules/ai/constants/ai-models.const'; -import { convertCentsToCredits } from 'src/engine/core-modules/ai/utils/ai-cost.utils'; +import { ModelProvider } from 'src/engine/core-modules/ai/constants/ai-models.const'; +import { convertCentsToBillingCredits } from 'src/engine/core-modules/ai/utils/convert-cents-to-billing-credits.util'; +import { getAIModelsWithAuto } from 'src/engine/core-modules/ai/utils/get-ai-models-with-auto.util'; import { ClientAIModelConfig, ClientConfig, @@ -29,29 +27,32 @@ export class ClientConfigService { const openaiApiKey = this.twentyConfigService.get('OPENAI_API_KEY'); const anthropicApiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY'); - const aiModels = AI_MODELS.reduce((acc, model) => { - const isAvailable = - (model.provider === ModelProvider.OPENAI && openaiApiKey) || - (model.provider === ModelProvider.ANTHROPIC && anthropicApiKey); + const aiModels = getAIModelsWithAuto().reduce( + (acc, model) => { + const isAvailable = + (model.provider === ModelProvider.OPENAI && openaiApiKey) || + (model.provider === ModelProvider.ANTHROPIC && anthropicApiKey); + + if (!isAvailable) { + return acc; + } + + acc.push({ + modelId: model.modelId, + label: model.label, + provider: model.provider, + inputCostPer1kTokensInCredits: convertCentsToBillingCredits( + model.inputCostPer1kTokensInCents, + ), + outputCostPer1kTokensInCredits: convertCentsToBillingCredits( + model.outputCostPer1kTokensInCents, + ), + }); - if (!isAvailable) { return acc; - } - - acc.push({ - modelId: model.modelId, - label: model.label, - provider: model.provider, - inputCostPer1kTokensInCredits: convertCentsToCredits( - model.inputCostPer1kTokensInCents, - ), - outputCostPer1kTokensInCredits: convertCentsToCredits( - model.outputCostPer1kTokensInCents, - ), - }); - - return acc; - }, []); + }, + [], + ); const clientConfig: ClientConfig = { billing: { diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index d758261a1..a0e17625a 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -25,6 +25,7 @@ import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/worksp import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { Webhook } from 'src/engine/core-modules/webhook/webhook.entity'; import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; +import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto'; import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; registerEnumType(WorkspaceActivationStatus, { @@ -171,12 +172,20 @@ export class Workspace { @Column({ default: false }) isCustomDomainEnabled: boolean; + // TODO: set as non nullable @Column({ nullable: true, type: 'uuid' }) defaultRoleId: string | null; @Field(() => RoleDTO, { nullable: true }) defaultRole: RoleDTO | null; + // TODO: set as non nullable + @Column({ nullable: true, type: 'uuid' }) + defaultAgentId: string | null; + + @Field(() => AgentDTO, { nullable: true }) + defaultAgent: AgentDTO | null; + @Field(() => String, { nullable: true }) @Column({ type: 'varchar', nullable: true }) version: string | null; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts index 83502a474..2b7cf70f4 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts @@ -19,6 +19,7 @@ import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user import { User } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/workspace/workspace-workspace-member.listener'; import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.resolver'; +import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; @@ -57,6 +58,7 @@ import { WorkspaceService } from './services/workspace.service'; WorkspaceCacheStorageModule, AuditModule, RoleModule, + AgentModule, ], services: [WorkspaceService], resolvers: workspaceAutoResolverOpts, diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index df6c6cadc..55b88eb67 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -56,6 +56,8 @@ import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; +import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto'; import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; @@ -92,6 +94,7 @@ export class WorkspaceResolver { private readonly billingSubscriptionService: BillingSubscriptionService, private readonly featureFlagService: FeatureFlagService, private readonly roleService: RoleService, + private readonly agentService: AgentService, @InjectRepository(BillingSubscription, 'core') private readonly billingSubscriptionRepository: Repository, ) {} @@ -222,6 +225,29 @@ export class WorkspaceResolver { ); } + @ResolveField(() => AgentDTO, { nullable: true }) + async defaultAgent(@Parent() workspace: Workspace): Promise { + if (!workspace.defaultAgentId) { + return null; + } + + try { + const agent = await this.agentService.findOneAgent( + workspace.defaultAgentId, + workspace.id, + ); + + // Convert roleId from null to undefined to match AgentDTO + return { + ...agent, + roleId: agent.roleId ?? undefined, + }; + } catch (error) { + // If agent is not found, return null instead of throwing + return null; + } + } + @ResolveField(() => BillingSubscription, { nullable: true }) async currentBillingSubscription( @Parent() workspace: Workspace, diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-chat.controller.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-chat.controller.ts index 32eb5a886..3050eeb7e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent-chat.controller.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent-chat.controller.ts @@ -62,11 +62,33 @@ export class AgentChatController { @AuthUserWorkspaceId() userWorkspaceId: string, @Res() res: Response, ) { - await this.agentStreamingService.streamAgentChat({ - threadId: body.threadId, - userMessage: body.userMessage, - userWorkspaceId, - res, - }); + try { + await this.agentStreamingService.streamAgentChat({ + threadId: body.threadId, + userMessage: body.userMessage, + userWorkspaceId, + res, + }); + } catch (error) { + // Handle errors at controller level for streaming responses + // since the RestApiExceptionFilter interferes with our streaming error handling + const errorMessage = + error instanceof Error ? error.message : 'Unknown error occurred'; + + if (!res.headersSent) { + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.setHeader('Transfer-Encoding', 'chunked'); + res.setHeader('Cache-Control', 'no-cache'); + } + + res.write( + JSON.stringify({ + type: 'error', + message: errorMessage, + }) + '\n', + ); + + res.end(); + } } } diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-chat.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-chat.service.ts index c4998b610..ff08c1ef2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent-chat.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent-chat.service.ts @@ -13,8 +13,6 @@ import { AgentExceptionCode, } from 'src/engine/metadata-modules/agent/agent.exception'; -import { AgentExecutionService } from './agent-execution.service'; - @Injectable() export class AgentChatService { constructor( @@ -22,7 +20,6 @@ export class AgentChatService { private readonly threadRepository: Repository, @InjectRepository(AgentChatMessageEntity, 'core') private readonly messageRepository: Repository, - private readonly agentExecutionService: AgentExecutionService, ) {} async createThread(agentId: string, userWorkspaceId: string) { diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-execution.service.ts index c9da17ac1..058ff333b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent-execution.service.ts @@ -10,7 +10,7 @@ import { ModelId, ModelProvider, } from 'src/engine/core-modules/ai/constants/ai-models.const'; -import { getAIModelById } from 'src/engine/core-modules/ai/utils/get-ai-model-by-id'; +import { getEffectiveModelConfig } from 'src/engine/core-modules/ai/utils/get-effective-model-config.util'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { AgentChatMessageEntity, @@ -22,6 +22,7 @@ import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constant import { convertOutputSchemaToZod } from 'src/engine/metadata-modules/agent/utils/convert-output-schema-to-zod'; import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type'; import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util'; +import { getAIModelById } from 'src/engine/core-modules/ai/utils/get-ai-model-by-id.util'; import { AgentEntity } from './agent.entity'; import { AgentException, AgentExceptionCode } from './agent.exception'; @@ -45,12 +46,17 @@ export class AgentExecutionService { private readonly agentToolService: AgentToolService, @InjectRepository(AgentEntity, 'core') private readonly agentRepository: Repository, - @InjectRepository(AgentChatMessageEntity, 'core') - private readonly agentChatmessageRepository: Repository, ) {} getModel = (modelId: ModelId, provider: ModelProvider) => { switch (provider) { + case ModelProvider.NONE: { + const OpenAIProvider = createOpenAI({ + apiKey: this.twentyConfigService.get('OPENAI_API_KEY'), + }); + + return OpenAIProvider(getEffectiveModelConfig(modelId).modelId); + } case ModelProvider.OPENAI: { const OpenAIProvider = createOpenAI({ apiKey: this.twentyConfigService.get('OPENAI_API_KEY'), @@ -77,6 +83,9 @@ export class AgentExecutionService { let apiKey: string | undefined; switch (provider) { + case ModelProvider.NONE: + apiKey = this.twentyConfigService.get('OPENAI_API_KEY'); + break; case ModelProvider.OPENAI: apiKey = this.twentyConfigService.get('OPENAI_API_KEY'); break; @@ -91,7 +100,7 @@ export class AgentExecutionService { } if (!apiKey) { throw new AgentException( - `${provider.toUpperCase()} API key not configured`, + `${provider === ModelProvider.NONE ? 'OPENAI' : provider.toUpperCase()} API key not configured`, AgentExceptionCode.API_KEY_NOT_CONFIGURED, ); } diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-streaming.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-streaming.service.ts index 74418a886..5358c12ff 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent-streaming.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent-streaming.service.ts @@ -65,7 +65,7 @@ export class AgentStreamingService { this.setupStreamingHeaders(res); - const { textStream } = + const { fullStream } = await this.agentExecutionService.streamChatResponse({ agentId: thread.agent.id, userMessage, @@ -74,9 +74,24 @@ export class AgentStreamingService { let aiResponse = ''; - for await (const chunk of textStream) { - aiResponse += chunk; - res.write(chunk); + for await (const chunk of fullStream) { + switch (chunk.type) { + case 'text-delta': + aiResponse += chunk.textDelta; + this.sendStreamEvent(res, { + type: chunk.type, + message: chunk.textDelta, + }); + break; + case 'tool-call': + this.sendStreamEvent(res, { + type: chunk.type, + message: chunk.args?.toolDescription, + }); + break; + default: + break; + } } await this.agentChatService.addMessage({ @@ -90,10 +105,26 @@ export class AgentStreamingService { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - return { success: false, error: errorMessage }; + if (!res.headersSent) { + this.setupStreamingHeaders(res); + } + + this.sendStreamEvent(res, { + type: 'error', + message: errorMessage, + }); + + res.end(); } } + private sendStreamEvent( + res: Response, + event: { type: string; message: string }, + ): void { + res.write(JSON.stringify(event) + '\n'); + } + private setupStreamingHeaders(res: Response): void { res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.setHeader('Transfer-Encoding', 'chunked'); diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool.service.ts index 1b24ef810..b09eab22c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool.service.ts @@ -4,9 +4,9 @@ import { InjectRepository } from '@nestjs/typeorm'; import { ToolSet } from 'ai'; import { Repository } from 'typeorm'; +import { ToolService } from 'src/engine/core-modules/ai/services/tool.service'; import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; -import { ToolService } from 'src/engine/core-modules/ai/services/tool.service'; @Injectable() export class AgentToolService { diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent.entity.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent.entity.ts index 05f13ff30..1b9405f89 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent.entity.ts @@ -8,6 +8,7 @@ import { ManyToOne, OneToMany, PrimaryGeneratedColumn, + Unique, UpdateDateColumn, } from 'typeorm'; @@ -20,6 +21,7 @@ import { AgentChatThreadEntity } from './agent-chat-thread.entity'; @Entity('agent') @Index('IDX_AGENT_ID_DELETED_AT', ['id', 'deletedAt']) +@Unique('IDX_AGENT_NAME_WORKSPACE_ID_UNIQUE', ['name', 'workspaceId']) export class AgentEntity { @PrimaryGeneratedColumn('uuid') id: string; @@ -27,13 +29,19 @@ export class AgentEntity { @Column({ nullable: false }) name: string; + @Column({ nullable: false }) + label: string; + + @Column({ nullable: true }) + icon: string; + @Column({ nullable: true }) description: string; @Column({ nullable: false, type: 'text' }) prompt: string; - @Column({ nullable: false, type: 'varchar' }) + @Column({ nullable: false, type: 'varchar', default: 'auto' }) modelId: ModelId; @Column({ nullable: true, type: 'jsonb' }) @@ -42,6 +50,9 @@ export class AgentEntity { @Column({ nullable: false, type: 'uuid' }) workspaceId: string; + @Column({ default: false }) + isCustom: boolean; + @ManyToOne(() => Workspace, (workspace) => workspace.agents, { onDelete: 'CASCADE', }) diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent.exception.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent.exception.ts index 125071106..85f5ea42b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent.exception.ts @@ -11,4 +11,5 @@ export enum AgentExceptionCode { AGENT_NOT_FOUND = 'AGENT_NOT_FOUND', AGENT_EXECUTION_FAILED = 'AGENT_EXECUTION_FAILED', API_KEY_NOT_CONFIGURED = 'API_KEY_NOT_CONFIGURED', + USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND', } diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent.service.ts index 08fffeeda..5a9db613a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent.service.ts @@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const'; +import { AgentChatService } from 'src/engine/metadata-modules/agent/agent-chat.service'; import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; import { AgentEntity } from './agent.entity'; @@ -16,6 +17,7 @@ export class AgentService { private readonly agentRepository: Repository, @InjectRepository(RoleTargetsEntity, 'core') private readonly roleTargetsRepository: Repository, + private readonly agentChatService: AgentChatService, ) {} async findManyAgents(workspaceId: string) { @@ -51,9 +53,35 @@ export class AgentService { }; } + async createOneAgentAndFirstThread( + input: { + name: string; + label: string; + description?: string; + prompt: string; + modelId: ModelId; + }, + workspaceId: string, + userWorkspaceId: string | null, + ) { + const agent = await this.createOneAgent(input, workspaceId); + + if (!userWorkspaceId) { + throw new AgentException( + 'User workspace ID not found', + AgentExceptionCode.USER_WORKSPACE_ID_NOT_FOUND, + ); + } + + await this.agentChatService.createThread(agent.id, userWorkspaceId); + + return agent; + } + async createOneAgent( input: { name: string; + label: string; description?: string; prompt: string; modelId: ModelId; diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/utils/agent-tool-schema.utils.ts b/packages/twenty-server/src/engine/metadata-modules/agent/utils/agent-tool-schema.utils.ts index f9a03749b..0c9b45574 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/utils/agent-tool-schema.utils.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/utils/agent-tool-schema.utils.ts @@ -10,14 +10,34 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat import { convertObjectMetadataToSchemaProperties } from 'src/engine/utils/convert-object-metadata-to-schema-properties.util'; import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; -export const getRecordInputSchema = (objectMetadata: ObjectMetadataEntity) => { +const createToolSchema = ( + inputProperties: Record, + required?: string[], +) => { return jsonSchema({ type: 'object', - properties: convertObjectMetadataToSchemaProperties({ + properties: { + toolDescription: { + type: 'string', + description: + 'A clear, human-readable description of the action being performed. Explain what operation you are executing and with what parameters in natural language.', + }, + input: { + type: 'object', + properties: inputProperties, + ...(required && { required }), + }, + }, + }); +}; + +export const getRecordInputSchema = (objectMetadata: ObjectMetadataEntity) => { + return createToolSchema( + convertObjectMetadataToSchemaProperties({ item: objectMetadata, forResponse: false, }), - }); + ); }; export const generateFindToolSchema = ( @@ -48,10 +68,7 @@ export const generateFindToolSchema = ( } }); - return jsonSchema({ - type: 'object', - properties: schemaProperties, - }); + return createToolSchema(schemaProperties); }; const generateFieldFilterJsonSchema = ( @@ -808,25 +825,22 @@ const generateFieldFilterJsonSchema = ( }; export const generateBulkDeleteToolSchema = () => { - return jsonSchema({ - type: 'object', - properties: { - filter: { - type: 'object', - description: 'Filter criteria to select records for bulk delete', - properties: { - id: { - type: 'object', - description: 'Filter to select records to delete', - properties: { - in: { - type: 'array', - items: { - type: 'string', - format: 'uuid', - }, - description: 'Array of record IDs to delete', + return createToolSchema({ + filter: { + type: 'object', + description: 'Filter criteria to select records for bulk delete', + properties: { + id: { + type: 'object', + description: 'Filter to select records to delete', + properties: { + in: { + type: 'array', + items: { + type: 'string', + format: 'uuid', }, + description: 'Array of record IDs to delete', }, }, }, @@ -834,3 +848,29 @@ export const generateBulkDeleteToolSchema = () => { }, }); }; + +export const generateFindOneToolSchema = () => { + return createToolSchema( + { + id: { + type: 'string', + format: 'uuid', + description: 'The unique UUID of the record to retrieve', + }, + }, + ['id'], + ); +}; + +export const generateSoftDeleteToolSchema = () => { + return createToolSchema( + { + id: { + type: 'string', + format: 'uuid', + description: 'The unique UUID of the record to soft delete', + }, + }, + ['id'], + ); +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/__tests__/workspace-manager.service.spec.ts b/packages/twenty-server/src/engine/workspace-manager/__tests__/workspace-manager.service.spec.ts index dc487321e..b81ea88e9 100644 --- a/packages/twenty-server/src/engine/workspace-manager/__tests__/workspace-manager.service.spec.ts +++ b/packages/twenty-server/src/engine/workspace-manager/__tests__/workspace-manager.service.spec.ts @@ -6,6 +6,7 @@ import { Repository } from 'typeorm'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; @@ -115,6 +116,14 @@ describe('WorkspaceManagerService', () => { deleteObjectsMetadata: jest.fn(), }, }, + { + provide: AgentService, + useValue: { + createOneAgent: jest + .fn() + .mockResolvedValue({ id: 'mock-agent-id' }), + }, + }, ], }).compile(); diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-agents.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-agents.util.ts new file mode 100644 index 000000000..73c2dfcc1 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-agents.util.ts @@ -0,0 +1,277 @@ +import { DataSource } from 'typeorm'; + +import { AgentChatMessageRole } from 'src/engine/metadata-modules/agent/agent-chat-message.entity'; +import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util'; +import { + SEED_APPLE_WORKSPACE_ID, + SEED_YCOMBINATOR_WORKSPACE_ID, +} from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util'; + +const agentTableName = 'agent'; +const workspaceTableName = 'workspace'; +const agentChatThreadTableName = 'agentChatThread'; +const agentChatMessageTableName = 'agentChatMessage'; + +export const AGENT_DATA_SEED_IDS = { + APPLE_DEFAULT_AGENT: '20202020-0000-4000-8000-000000000001', + YCOMBINATOR_DEFAULT_AGENT: '20202020-0000-4000-8000-000000000002', +}; + +export const AGENT_CHAT_THREAD_DATA_SEED_IDS = { + APPLE_DEFAULT_THREAD: '20202020-0000-4000-8000-000000000011', + YCOMBINATOR_DEFAULT_THREAD: '20202020-0000-4000-8000-000000000012', +}; + +export const AGENT_CHAT_MESSAGE_DATA_SEED_IDS = { + APPLE_MESSAGE_1: '20202020-0000-4000-8000-000000000021', + APPLE_MESSAGE_2: '20202020-0000-4000-8000-000000000022', + APPLE_MESSAGE_3: '20202020-0000-4000-8000-000000000023', + APPLE_MESSAGE_4: '20202020-0000-4000-8000-000000000024', + YCOMBINATOR_MESSAGE_1: '20202020-0000-4000-8000-000000000031', + YCOMBINATOR_MESSAGE_2: '20202020-0000-4000-8000-000000000032', + YCOMBINATOR_MESSAGE_3: '20202020-0000-4000-8000-000000000033', + YCOMBINATOR_MESSAGE_4: '20202020-0000-4000-8000-000000000034', +}; + +const seedAgentChatThreads = async ( + dataSource: DataSource, + schemaName: string, + workspaceId: string, + agentId: string, +) => { + let threadId: string; + let userWorkspaceId: string; + + if (workspaceId === SEED_APPLE_WORKSPACE_ID) { + threadId = AGENT_CHAT_THREAD_DATA_SEED_IDS.APPLE_DEFAULT_THREAD; + userWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.TIM; + } else if (workspaceId === SEED_YCOMBINATOR_WORKSPACE_ID) { + threadId = AGENT_CHAT_THREAD_DATA_SEED_IDS.YCOMBINATOR_DEFAULT_THREAD; + userWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.TIM_ACME; + } else { + throw new Error( + `Unsupported workspace ID for agent chat thread seeding: ${workspaceId}`, + ); + } + + const now = new Date(); + + await dataSource + .createQueryBuilder() + .insert() + .into(`${schemaName}.${agentChatThreadTableName}`, [ + 'id', + 'agentId', + 'userWorkspaceId', + 'createdAt', + 'updatedAt', + ]) + .orIgnore() + .values([ + { + id: threadId, + agentId, + userWorkspaceId, + createdAt: now, + updatedAt: now, + }, + ]) + .execute(); + + return threadId; +}; + +const seedAgentChatMessages = async ( + dataSource: DataSource, + schemaName: string, + workspaceId: string, + threadId: string, +) => { + let messageIds: string[]; + let messages: Array<{ + id: string; + threadId: string; + role: AgentChatMessageRole; + content: string; + createdAt: Date; + }>; + + const now = new Date(); + const baseTime = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24 hours ago + + if (workspaceId === SEED_APPLE_WORKSPACE_ID) { + messageIds = [ + AGENT_CHAT_MESSAGE_DATA_SEED_IDS.APPLE_MESSAGE_1, + AGENT_CHAT_MESSAGE_DATA_SEED_IDS.APPLE_MESSAGE_2, + AGENT_CHAT_MESSAGE_DATA_SEED_IDS.APPLE_MESSAGE_3, + AGENT_CHAT_MESSAGE_DATA_SEED_IDS.APPLE_MESSAGE_4, + ]; + messages = [ + { + id: messageIds[0], + threadId, + role: AgentChatMessageRole.USER, + content: + 'Hello! Can you help me understand our current product roadmap and key metrics?', + createdAt: new Date(baseTime.getTime()), + }, + { + id: messageIds[1], + threadId, + role: AgentChatMessageRole.ASSISTANT, + content: + "Hello! I'd be happy to help you understand Apple's product roadmap and metrics. Based on your workspace data, I can see you have various projects and initiatives tracked. What specific aspect would you like to explore - product development timelines, user engagement metrics, or revenue targets?", + createdAt: new Date(baseTime.getTime() + 5 * 60 * 1000), // 5 minutes later + }, + { + id: messageIds[2], + threadId, + role: AgentChatMessageRole.USER, + content: + "I'd like to focus on our user engagement metrics and how they're trending over the last quarter.", + createdAt: new Date(baseTime.getTime() + 10 * 60 * 1000), // 10 minutes later + }, + { + id: messageIds[3], + threadId, + role: AgentChatMessageRole.ASSISTANT, + content: + 'Great! Looking at your user engagement data, I can see several key trends from the last quarter. Your active user base has grown by 15%, with particularly strong engagement in the mobile app. Daily active users are averaging 2.3 million, and session duration has increased by 8%. Would you like me to dive deeper into any specific engagement metrics or create a detailed report?', + createdAt: new Date(baseTime.getTime() + 15 * 60 * 1000), // 15 minutes later + }, + ]; + } else if (workspaceId === SEED_YCOMBINATOR_WORKSPACE_ID) { + messageIds = [ + AGENT_CHAT_MESSAGE_DATA_SEED_IDS.YCOMBINATOR_MESSAGE_1, + AGENT_CHAT_MESSAGE_DATA_SEED_IDS.YCOMBINATOR_MESSAGE_2, + AGENT_CHAT_MESSAGE_DATA_SEED_IDS.YCOMBINATOR_MESSAGE_3, + AGENT_CHAT_MESSAGE_DATA_SEED_IDS.YCOMBINATOR_MESSAGE_4, + ]; + messages = [ + { + id: messageIds[0], + threadId, + role: AgentChatMessageRole.USER, + content: + 'What are the current startup trends and which companies in our portfolio are performing best?', + createdAt: new Date(baseTime.getTime()), + }, + { + id: messageIds[1], + threadId, + role: AgentChatMessageRole.ASSISTANT, + content: + 'Hello! I can help you analyze startup trends and portfolio performance. From your YCombinator workspace data, I can see strong performance in AI/ML startups, particularly in the B2B SaaS space. Several companies are showing 40%+ month-over-month growth. Would you like me to provide specific company performance metrics or focus on broader industry trends?', + createdAt: new Date(baseTime.getTime() + 3 * 60 * 1000), // 3 minutes later + }, + { + id: messageIds[2], + threadId, + role: AgentChatMessageRole.USER, + content: + 'Please focus on our top 5 performing companies and their key metrics.', + createdAt: new Date(baseTime.getTime() + 8 * 60 * 1000), // 8 minutes later + }, + { + id: messageIds[3], + threadId, + role: AgentChatMessageRole.ASSISTANT, + content: + 'Here are your top 5 performing portfolio companies: 1) TechFlow AI - 45% MoM growth, $2M ARR, 2) DataSync Pro - 38% MoM growth, $1.5M ARR, 3) CloudOps Solutions - 35% MoM growth, $3.2M ARR, 4) SecureNet - 32% MoM growth, $1.8M ARR, 5) HealthTech Plus - 28% MoM growth, $2.5M ARR. All are showing strong customer retention (>95%) and expanding market share. Would you like detailed breakdowns for any specific company?', + createdAt: new Date(baseTime.getTime() + 12 * 60 * 1000), // 12 minutes later + }, + ]; + } else { + throw new Error( + `Unsupported workspace ID for agent chat message seeding: ${workspaceId}`, + ); + } + + await dataSource + .createQueryBuilder() + .insert() + .into(`${schemaName}.${agentChatMessageTableName}`, [ + 'id', + 'threadId', + 'role', + 'content', + 'createdAt', + ]) + .orIgnore() + .values(messages) + .execute(); +}; + +export const seedAgents = async ( + dataSource: DataSource, + schemaName: string, + workspaceId: string, +) => { + let agentId: string; + let agentName: string; + let agentLabel: string; + let agentDescription: string; + + if (workspaceId === SEED_APPLE_WORKSPACE_ID) { + agentId = AGENT_DATA_SEED_IDS.APPLE_DEFAULT_AGENT; + agentName = 'apple-ai-assistant'; + agentLabel = 'Apple AI Assistant'; + agentDescription = + 'AI assistant for Apple workspace to help with tasks, insights, and workflow guidance'; + } else if (workspaceId === SEED_YCOMBINATOR_WORKSPACE_ID) { + agentId = AGENT_DATA_SEED_IDS.YCOMBINATOR_DEFAULT_AGENT; + agentName = 'yc-ai-assistant'; + agentLabel = 'YC AI Assistant'; + agentDescription = + 'AI assistant for YCombinator workspace to help with tasks, insights, and workflow guidance'; + } else { + throw new Error( + `Unsupported workspace ID for agent seeding: ${workspaceId}`, + ); + } + + await dataSource + .createQueryBuilder() + .insert() + .into(`${schemaName}.${agentTableName}`, [ + 'id', + 'name', + 'label', + 'description', + 'prompt', + 'modelId', + 'responseFormat', + 'workspaceId', + ]) + .orIgnore() + .values([ + { + id: agentId, + name: agentName, + label: agentLabel, + description: agentDescription, + prompt: + 'You are a helpful AI assistant for this workspace. Help users with their tasks, provide insights about their data, and guide them through workflows. Be concise but thorough in your responses.', + modelId: 'auto', + responseFormat: null, + workspaceId, + }, + ]) + .execute(); + + await dataSource + .createQueryBuilder() + .update(`${schemaName}.${workspaceTableName}`) + .set({ defaultAgentId: agentId }) + .where('id = :workspaceId', { workspaceId }) + .execute(); + + const threadId = await seedAgentChatThreads( + dataSource, + schemaName, + workspaceId, + agentId, + ); + + await seedAgentChatMessages(dataSource, schemaName, workspaceId, threadId); +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-core-schema.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-core-schema.util.ts index ced97451f..7ede2e34f 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-core-schema.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-core-schema.util.ts @@ -1,6 +1,7 @@ import { DataSource } from 'typeorm'; import { seedBillingSubscriptions } from 'src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-subscriptions.util'; +import { seedAgents } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-agents.util'; import { seedApiKeys } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util'; import { seedFeatureFlags } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util'; import { seedUserWorkspaces } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util'; @@ -33,6 +34,8 @@ export const seedCoreSchema = async ({ await seedUsers(dataSource, schemaName); await seedUserWorkspaces(dataSource, schemaName, workspaceId); + await seedAgents(dataSource, schemaName, workspaceId); + await seedApiKeys(dataSource, schemaName, workspaceId); if (shouldSeedFeatureFlags) { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts index a91944b7f..63a660dd4 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; @@ -33,6 +34,7 @@ import { WorkspaceManagerService } from './workspace-manager.service'; WorkspaceHealthModule, FeatureFlagModule, PermissionsModule, + AgentModule, TypeOrmModule.forFeature([UserWorkspace, Workspace], 'core'), RoleModule, UserRoleModule, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts index 642a4a9a1..8bb07ac2e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts @@ -3,9 +3,11 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; @@ -42,6 +44,7 @@ export class WorkspaceManagerService { private readonly roleRepository: Repository, @InjectRepository(RoleTargetsEntity, 'core') private readonly roleTargetsRepository: Repository, + private readonly agentService: AgentService, ) {} public async init({ @@ -95,6 +98,17 @@ export class WorkspaceManagerService { `Permissions enabled took ${permissionsEnabledEnd - permissionsEnabledStart}ms`, ); + if (featureFlags[FeatureFlagKey.IS_AI_ENABLED]) { + const defaultAgentEnabledStart = performance.now(); + + await this.initDefaultAgent(workspaceId); + const defaultAgentEnabledEnd = performance.now(); + + this.logger.log( + `Default agent enabled took ${defaultAgentEnabledEnd - defaultAgentEnabledStart}ms`, + ); + } + const prefillStandardObjectsStart = performance.now(); await this.prefillWorkspaceWithStandardObjectsRecords( @@ -194,4 +208,21 @@ export class WorkspaceManagerService { defaultRoleId: memberRole.id, }); } + + private async initDefaultAgent(workspaceId: string) { + const agent = await this.agentService.createOneAgent( + { + label: 'Routing Agent', + name: 'routing-agent', + description: 'Default Routing Agent', + prompt: '', + modelId: 'auto', + }, + workspaceId, + ); + + await this.workspaceRepository.update(workspaceId, { + defaultAgentId: agent.id, + }); + } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts index 5270ef9b3..91ca97f24 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts @@ -616,14 +616,16 @@ export class WorkflowVersionStepWorkspaceService { }; } case WorkflowActionType.AI_AGENT: { - const newAgent = await this.agentService.createOneAgent( + const newAgent = await this.agentService.createOneAgentAndFirstThread( { - name: 'AI Agent Workflow Step', + label: 'AI Agent Workflow Step', + name: 'ai-agent-workflow', description: 'Created automatically for workflow step', prompt: '', - modelId: 'gpt-4o', + modelId: 'auto', }, workspaceId, + this.scopedWorkspaceContextFactory.create().userWorkspaceId, ); if (!isDefined(newAgent)) { @@ -636,15 +638,13 @@ export class WorkflowVersionStepWorkspaceService { const userWorkspaceId = this.scopedWorkspaceContextFactory.create().userWorkspaceId; - if (!userWorkspaceId) { - throw new WorkflowVersionStepException( - 'User workspace ID not found', - WorkflowVersionStepExceptionCode.FAILURE, + if (userWorkspaceId) { + await this.agentChatService.createThread( + newAgent.id, + userWorkspaceId, ); } - await this.agentChatService.createThread(newAgent.id, userWorkspaceId); - return { id: newStepId, name: 'AI Agent', diff --git a/packages/twenty-server/test/integration/graphql/suites/agent/agent.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/agent/agent.integration-spec.ts index efb48aa5f..a141c8ae7 100644 --- a/packages/twenty-server/test/integration/graphql/suites/agent/agent.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/agent/agent.integration-spec.ts @@ -1,11 +1,11 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; -import { AgentResolver } from 'src/engine/metadata-modules/agent/agent.resolver'; import { AgentException, AgentExceptionCode, } from 'src/engine/metadata-modules/agent/agent.exception'; +import { AgentResolver } from 'src/engine/metadata-modules/agent/agent.resolver'; +import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; // Mock the agent service jest.mock('../../../../../src/engine/metadata-modules/agent/agent.service'); diff --git a/packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts index d7469ebdc..b50acbcb2 100644 --- a/packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts @@ -216,7 +216,7 @@ describe('AgentToolService Integration', () => { } const result = await createTool.execute( - { name: 'Test Record', description: 'Test description' }, + { input: { name: 'Test Record', description: 'Test description' } }, { toolCallId: 'test-tool-call-id', messages: [ @@ -260,7 +260,7 @@ describe('AgentToolService Integration', () => { } const result = await createTool.execute( - { name: 'Test Record' }, + { input: { name: 'Test Record' } }, { toolCallId: 'test-tool-call-id', messages: [ @@ -304,7 +304,7 @@ describe('AgentToolService Integration', () => { } const result = await findTool.execute( - { limit: 10, offset: 0 }, + { input: { limit: 10, offset: 0 } }, { toolCallId: 'test-tool-call-id', messages: [ @@ -352,7 +352,7 @@ describe('AgentToolService Integration', () => { } const result = await findOneTool.execute( - { id: 'test-record-id' }, + { input: { id: 'test-record-id' } }, { toolCallId: 'test-tool-call-id', messages: [ @@ -393,7 +393,7 @@ describe('AgentToolService Integration', () => { } const result = await findOneTool.execute( - { id: 'non-existent-id' }, + { input: { id: 'non-existent-id' } }, { toolCallId: 'test-tool-call-id', messages: [ @@ -430,7 +430,7 @@ describe('AgentToolService Integration', () => { } const result = await findOneTool.execute( - {}, + { input: {} }, { toolCallId: 'test-tool-call-id', messages: [ @@ -488,9 +488,11 @@ describe('AgentToolService Integration', () => { const result = await updateTool.execute( { - id: 'test-record-id', - name: 'New Name', - description: 'New description', + input: { + id: 'test-record-id', + name: 'New Name', + description: 'New description', + }, }, { toolCallId: 'test-tool-call-id', @@ -534,8 +536,10 @@ describe('AgentToolService Integration', () => { const result = await updateTool.execute( { - id: 'non-existent-id', - name: 'New Name', + input: { + id: 'non-existent-id', + name: 'New Name', + }, }, { toolCallId: 'test-tool-call-id', @@ -583,7 +587,7 @@ describe('AgentToolService Integration', () => { } const result = await softDeleteTool.execute( - { id: 'test-record-id' }, + { input: { id: 'test-record-id' } }, { toolCallId: 'test-tool-call-id', messages: [ @@ -624,7 +628,9 @@ describe('AgentToolService Integration', () => { const result = await softDeleteManyTool.execute( { - filter: { id: { in: ['record-1', 'record-2', 'record-3'] } }, + input: { + filter: { id: { in: ['record-1', 'record-2', 'record-3'] } }, + }, }, { toolCallId: 'test-tool-call-id', @@ -671,7 +677,7 @@ describe('AgentToolService Integration', () => { } const result = await findTool.execute( - {}, + { input: {} }, { toolCallId: 'test-tool-call-id', messages: [ @@ -716,10 +722,12 @@ describe('AgentToolService Integration', () => { const result = await findTool.execute( { - name: null, - description: undefined, - status: '', - validField: 'valid value', + input: { + name: null, + description: undefined, + status: '', + validField: 'valid value', + }, }, { toolCallId: 'test-tool-call-id', diff --git a/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts b/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts index afd0761db..b05ed6937 100644 --- a/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts +++ b/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts @@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { ToolService } from 'src/engine/core-modules/ai/services/tool.service'; import { AgentToolService } from 'src/engine/metadata-modules/agent/agent-tool.service'; import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; @@ -12,7 +13,6 @@ import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; -import { ToolService } from 'src/engine/core-modules/ai/services/tool.service'; export interface AgentToolTestContext { module: TestingModule; @@ -103,7 +103,10 @@ export const createAgentToolTestModule = const testAgent: AgentEntity & { roleId: string | null } = { id: testAgentId, - name: 'Test Agent', + name: 'test-agent', + label: 'Test Agent', + icon: 'IconTest', + isCustom: false, description: 'Test agent for integration tests', prompt: 'You are a test agent', modelId: 'gpt-4o',