Show tool execution messages in AI agent chat (#13117)

https://github.com/user-attachments/assets/c0a42726-50ac-496e-a993-9d6076a84a6a

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Abdul Rahman
2025-07-10 11:15:05 +05:30
committed by GitHub
parent e6cdae5c27
commit 8310b4ff01
62 changed files with 1304 additions and 227 deletions

View File

@ -1042,6 +1042,7 @@ export enum MessageChannelVisibility {
export enum ModelProvider { export enum ModelProvider {
ANTHROPIC = 'ANTHROPIC', ANTHROPIC = 'ANTHROPIC',
NONE = 'NONE',
OPENAI = 'OPENAI' OPENAI = 'OPENAI'
} }
@ -2785,6 +2786,7 @@ export type Workspace = {
customDomain?: Maybe<Scalars['String']>; customDomain?: Maybe<Scalars['String']>;
databaseSchema: Scalars['String']; databaseSchema: Scalars['String'];
databaseUrl: Scalars['String']; databaseUrl: Scalars['String'];
defaultAgent?: Maybe<Agent>;
defaultRole?: Maybe<Role>; defaultRole?: Maybe<Role>;
deletedAt?: Maybe<Scalars['DateTime']>; deletedAt?: Maybe<Scalars['DateTime']>;
displayName?: Maybe<Scalars['String']>; displayName?: Maybe<Scalars['String']>;
@ -3599,7 +3601,7 @@ export type FindOneServerlessFunctionSourceCodeQueryVariables = Exact<{
export type FindOneServerlessFunctionSourceCodeQuery = { __typename?: 'Query', getServerlessFunctionSourceCode?: any | null }; 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<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | 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<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | 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 }; 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 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<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | 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<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | 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<{ export type ActivateWorkflowVersionMutationVariables = Exact<{
workflowVersionId: Scalars['String']; workflowVersionId: Scalars['String'];
@ -4042,6 +4044,9 @@ export const UserQueryFragmentFragmentDoc = gql`
defaultRole { defaultRole {
...RoleFragment ...RoleFragment
} }
defaultAgent {
id
}
} }
availableWorkspaces { availableWorkspaces {
...AvailableWorkspacesFragment ...AvailableWorkspacesFragment

View File

@ -999,6 +999,7 @@ export enum MessageChannelVisibility {
export enum ModelProvider { export enum ModelProvider {
ANTHROPIC = 'ANTHROPIC', ANTHROPIC = 'ANTHROPIC',
NONE = 'NONE',
OPENAI = 'OPENAI' OPENAI = 'OPENAI'
} }
@ -2613,6 +2614,7 @@ export type Workspace = {
customDomain?: Maybe<Scalars['String']>; customDomain?: Maybe<Scalars['String']>;
databaseSchema: Scalars['String']; databaseSchema: Scalars['String'];
databaseUrl: Scalars['String']; databaseUrl: Scalars['String'];
defaultAgent?: Maybe<Agent>;
defaultRole?: Maybe<Role>; defaultRole?: Maybe<Role>;
deletedAt?: Maybe<Scalars['DateTime']>; deletedAt?: Maybe<Scalars['DateTime']>;
displayName?: Maybe<Scalars['String']>; displayName?: Maybe<Scalars['String']>;

View File

@ -6,7 +6,7 @@ import { ActionType } from '@/action-menu/actions/types/ActionType';
import { ActionViewType } from '@/action-menu/actions/types/ActionViewType'; import { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { msg } from '@lingui/core/macro'; 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<string, ActionConfig> = { export const RECORD_AGNOSTIC_ACTIONS_CONFIG: Record<string, ActionConfig> = {
[RecordAgnosticActionsKeys.SEARCH_RECORDS]: { [RecordAgnosticActionsKeys.SEARCH_RECORDS]: {
@ -50,4 +50,24 @@ export const RECORD_AGNOSTIC_ACTIONS_CONFIG: Record<string, ActionConfig> = {
hotKeys: ['/'], hotKeys: ['/'],
shouldBeRegistered: () => true, 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: (
<ActionOpenSidePanelPage
page={CommandMenuPages.AskAI}
pageTitle="Ask AI"
pageIcon={IconSparkles}
/>
),
hotKeys: ['@'],
shouldBeRegistered: () => true,
},
}; };

View File

@ -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<string, ActionConfig> = {
[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;
};

View File

@ -1,4 +1,5 @@
export enum RecordAgnosticActionsKeys { export enum RecordAgnosticActionsKeys {
SEARCH_RECORDS = 'search-records', SEARCH_RECORDS = 'search-records',
SEARCH_RECORDS_FALLBACK = 'search-records-fallback', SEARCH_RECORDS_FALLBACK = 'search-records-fallback',
ASK_AI = 'ask-ai',
} }

View File

@ -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 { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
import { ShouldBeRegisteredFunctionParams } from '@/action-menu/actions/types/ShouldBeRegisteredFunctionParams'; import { ShouldBeRegisteredFunctionParams } from '@/action-menu/actions/types/ShouldBeRegisteredFunctionParams';
import { getActionConfig } from '@/action-menu/actions/utils/getActionConfig'; import { getActionConfig } from '@/action-menu/actions/utils/getActionConfig';
@ -30,7 +30,7 @@ export const useRegisteredActions = (
objectMetadataItem, objectMetadataItem,
}); });
const recordAgnosticActionConfig = RECORD_AGNOSTIC_ACTIONS_CONFIG; const recordAgnosticActionConfig = useRecordAgnosticActions();
const actionsConfig = { const actionsConfig = {
...recordActionConfig, ...recordActionConfig,

View File

@ -2,7 +2,10 @@ import {
ApolloClient, ApolloClient,
ApolloClientOptions, ApolloClientOptions,
ApolloLink, ApolloLink,
FetchResult,
fromPromise, fromPromise,
Observable,
Operation,
ServerError, ServerError,
ServerParseError, ServerParseError,
} from '@apollo/client'; } from '@apollo/client';
@ -19,7 +22,12 @@ import { AuthTokenPair } from '~/generated/graphql';
import { logDebug } from '~/utils/logDebug'; import { logDebug } from '~/utils/logDebug';
import { i18n } from '@lingui/core'; import { i18n } from '@lingui/core';
import { GraphQLFormattedError } from 'graphql'; import {
DefinitionNode,
DirectiveNode,
GraphQLFormattedError,
SelectionNode,
} from 'graphql';
import isEmpty from 'lodash.isempty'; import isEmpty from 'lodash.isempty';
import { getGenericOperationName, isDefined } from 'twenty-shared/utils'; import { getGenericOperationName, isDefined } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { REACT_APP_SERVER_BASE_URL } from '~/config';
@ -115,45 +123,45 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
}, },
attempts: { attempts: {
max: 2, max: 2,
retryIf: (error) => !!error, retryIf: (error) => {
if (this.isAuthenticationError(error)) {
return false;
}
return Boolean(error);
},
}, },
}); });
const handleTokenRenewal = (
operation: Operation,
forward: (operation: Operation) => Observable<FetchResult>,
) => {
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( const errorLink = onError(
({ graphQLErrors, networkError, forward, operation }) => { ({ graphQLErrors, networkError, forward, operation }) => {
if (isDefined(graphQLErrors)) { if (isDefined(graphQLErrors)) {
onErrorCb?.(graphQLErrors); onErrorCb?.(graphQLErrors);
for (const graphQLError of graphQLErrors) { for (const graphQLError of graphQLErrors) {
if (graphQLError.message === 'Unauthorized') { if (graphQLError.message === 'Unauthorized') {
return fromPromise( return handleTokenRenewal(operation, forward);
renewToken(uri, getTokenPair())
.then((tokens) => {
if (isDefined(tokens)) {
onTokenPairChange?.(tokens);
}
})
.catch(() => {
onUnauthenticatedError?.();
}),
).flatMap(() => forward(operation));
} }
switch (graphQLError?.extensions?.code) { switch (graphQLError?.extensions?.code) {
case 'UNAUTHENTICATED': { case 'UNAUTHENTICATED': {
return fromPromise( return handleTokenRenewal(operation, forward);
renewToken(uri, getTokenPair())
.then((tokens) => {
if (isDefined(tokens)) {
onTokenPairChange?.(tokens);
cookieStorage.setItem(
'tokenPair',
JSON.stringify(tokens),
);
}
})
.catch(() => {
onUnauthenticatedError?.();
}),
).flatMap(() => forward(operation));
} }
case 'FORBIDDEN': { case 'FORBIDDEN': {
return; return;
@ -220,6 +228,13 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
} }
if (isDefined(networkError)) { if (isDefined(networkError)) {
if (
this.isRestOperation(operation) &&
this.isAuthenticationError(networkError as ServerError)
) {
return handleTokenRenewal(operation, forward);
}
if (isDebugMode === true) { if (isDebugMode === true) {
logDebug(`[Network error]: ${networkError}`); logDebug(`[Network error]: ${networkError}`);
} }
@ -248,6 +263,26 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
}); });
} }
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) { updateWorkspaceMember(workspaceMember: CurrentWorkspaceMember | null) {
this.currentWorkspaceMember = workspaceMember; this.currentWorkspaceMember = workspaceMember;
} }

View File

@ -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 { FetchResult } from '@apollo/client/link/core';
import { ArgumentNode, DirectiveNode } from 'graphql'; import { ArgumentNode, DirectiveNode } from 'graphql';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
@ -57,7 +62,13 @@ export class StreamingRestLink extends ApolloLink {
fetch(url, requestConfig) fetch(url, requestConfig)
.then(async (response) => { .then(async (response) => {
if (!response.ok) { 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) { if (!response.body) {
@ -66,7 +77,6 @@ export class StreamingRestLink extends ApolloLink {
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let accumulatedData = '';
let isStreaming = true; let isStreaming = true;
while (isStreaming) { while (isStreaming) {
@ -79,19 +89,9 @@ export class StreamingRestLink extends ApolloLink {
} }
const decodedChunk = decoder.decode(value, { stream: true }); const decodedChunk = decoder.decode(value, { stream: true });
accumulatedData += decodedChunk;
if (isDefined(onChunk) && typeof onChunk === 'function') { if (isDefined(onChunk) && typeof onChunk === 'function') {
onChunk(accumulatedData); onChunk(decodedChunk);
}
try {
const parsedData = JSON.parse(decodedChunk);
observer.next({ data: parsedData });
} catch {
observer.next({
data: { streamingData: decodedChunk },
});
} }
} }
}) })

View File

@ -25,6 +25,7 @@ export type CurrentWorkspace = Pick<
| 'metadataVersion' | 'metadataVersion'
> & { > & {
defaultRole?: Omit<Role, 'workspaceMembers'> | null; defaultRole?: Omit<Role, 'workspaceMembers'> | null;
defaultAgent?: { id: string } | null;
}; };
export const currentWorkspaceState = createState<CurrentWorkspace | null>({ export const currentWorkspaceState = createState<CurrentWorkspace | null>({

View File

@ -1,4 +1,5 @@
import { CommandMenu } from '@/command-menu/components/CommandMenu'; 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 { CommandMenuCalendarEventPage } from '@/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage';
import { CommandMenuMessageThreadPage } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadPage'; import { CommandMenuMessageThreadPage } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadPage';
import { CommandMenuRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuRecordPage'; import { CommandMenuRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuRecordPage';
@ -32,4 +33,5 @@ export const COMMAND_MENU_PAGES_CONFIG = new Map<
[CommandMenuPages.WorkflowStepView, <CommandMenuWorkflowViewStep />], [CommandMenuPages.WorkflowStepView, <CommandMenuWorkflowViewStep />],
[CommandMenuPages.WorkflowRunStepView, <CommandMenuWorkflowRunViewStep />], [CommandMenuPages.WorkflowRunStepView, <CommandMenuWorkflowRunViewStep />],
[CommandMenuPages.SearchRecords, <CommandMenuSearchRecordsPage />], [CommandMenuPages.SearchRecords, <CommandMenuSearchRecordsPage />],
[CommandMenuPages.AskAI, <CommandMenuAskAIPage />],
]); ]);

View File

@ -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 { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useCommandMenuHistory } from '@/command-menu/hooks/useCommandMenuHistory'; import { useCommandMenuHistory } from '@/command-menu/hooks/useCommandMenuHistory';
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
import { useOpenRecordsSearchPageInCommandMenu } from '@/command-menu/hooks/useOpenRecordsSearchPageInCommandMenu'; import { useOpenRecordsSearchPageInCommandMenu } from '@/command-menu/hooks/useOpenRecordsSearchPageInCommandMenu';
import { useSetGlobalCommandMenuContext } from '@/command-menu/hooks/useSetGlobalCommandMenuContext'; import { useSetGlobalCommandMenuContext } from '@/command-menu/hooks/useSetGlobalCommandMenuContext';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState'; 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 { useGlobalHotkeys } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeys';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { FeatureFlagKey } from '~/generated/graphql';
export const useCommandMenuHotKeys = () => { export const useCommandMenuHotKeys = () => {
const { toggleCommandMenu } = useCommandMenu(); const { toggleCommandMenu } = useCommandMenu();
const { openRecordsSearchPage } = useOpenRecordsSearchPageInCommandMenu(); const { openRecordsSearchPage } = useOpenRecordsSearchPageInCommandMenu();
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
const { goBackFromCommandMenu } = useCommandMenuHistory(); const { goBackFromCommandMenu } = useCommandMenuHistory();
const { setGlobalCommandMenuContext } = useSetGlobalCommandMenuContext(); const { setGlobalCommandMenuContext } = useSetGlobalCommandMenuContext();
@ -31,6 +36,8 @@ export const useCommandMenuHotKeys = () => {
const commandMenuPage = useRecoilValue(commandMenuPageState); const commandMenuPage = useRecoilValue(commandMenuPageState);
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const contextStoreTargetedRecordsRuleComponent = useRecoilComponentValueV2( const contextStoreTargetedRecordsRuleComponent = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState, contextStoreTargetedRecordsRuleComponentState,
COMMAND_MENU_COMPONENT_INSTANCE_ID, COMMAND_MENU_COMPONENT_INSTANCE_ID,
@ -58,6 +65,20 @@ export const useCommandMenuHotKeys = () => {
}, },
); );
useGlobalHotkeys(
['@'],
() => {
if (isAiEnabled) {
openAskAIPage();
}
},
false,
[openAskAIPage, isAiEnabled],
{
ignoreModifiers: true,
},
);
useHotkeysOnFocusedElement({ useHotkeysOnFocusedElement({
keys: [Key.Escape], keys: [Key.Escape],
callback: () => { callback: () => {

View File

@ -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,
};
};

View File

@ -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 (
<StyledContainer>
<StyledEmptyState>No AI Agent found.</StyledEmptyState>
</StyledContainer>
);
}
return (
<StyledContainer>
<AIChatTab agentId={agentId} />
</StyledContainer>
);
};

View File

@ -11,4 +11,5 @@ export enum CommandMenuPages {
WorkflowStepEdit = 'workflow-step-edit', WorkflowStepEdit = 'workflow-step-edit',
WorkflowRunStepView = 'workflow-run-step-view', WorkflowRunStepView = 'workflow-run-step-view',
SearchRecords = 'search-records', SearchRecords = 'search-records',
AskAI = 'ask-ai',
} }

View File

@ -1,14 +1,17 @@
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
import { useOpenRecordsSearchPageInCommandMenu } from '@/command-menu/hooks/useOpenRecordsSearchPageInCommandMenu'; import { useOpenRecordsSearchPageInCommandMenu } from '@/command-menu/hooks/useOpenRecordsSearchPageInCommandMenu';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { navigationDrawerExpandedMemorizedState } from '@/ui/navigation/states/navigationDrawerExpandedMemorizedState'; import { navigationDrawerExpandedMemorizedState } from '@/ui/navigation/states/navigationDrawerExpandedMemorizedState';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useRecoilState, useSetRecoilState } from 'recoil'; 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 { useIsMobile } from 'twenty-ui/utilities';
import { FeatureFlagKey } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
export const MainNavigationDrawerFixedItems = () => { export const MainNavigationDrawerFixedItems = () => {
@ -29,6 +32,9 @@ export const MainNavigationDrawerFixedItems = () => {
const { t } = useLingui(); const { t } = useLingui();
const { openRecordsSearchPage } = useOpenRecordsSearchPageInCommandMenu(); const { openRecordsSearchPage } = useOpenRecordsSearchPageInCommandMenu();
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
return ( return (
!isMobile && ( !isMobile && (
<> <>
@ -39,6 +45,15 @@ export const MainNavigationDrawerFixedItems = () => {
keyboard={['/']} keyboard={['/']}
mouseUpNavigation={true} mouseUpNavigation={true}
/> />
{isAiEnabled && (
<NavigationDrawerItem
label={t`Ask AI`}
Icon={IconSparkles}
onClick={openAskAIPage}
keyboard={['@']}
mouseUpNavigation={true}
/>
)}
<NavigationDrawerItem <NavigationDrawerItem
label={t`Settings`} label={t`Settings`}
to={getSettingsPath(SettingsPath.ProfilePage)} to={getSettingsPath(SettingsPath.ProfilePage)}

View File

@ -93,6 +93,8 @@ export const UserProviderEffect = () => {
setCurrentWorkspace({ setCurrentWorkspace({
...queryData.currentUser.currentWorkspace, ...queryData.currentUser.currentWorkspace,
defaultRole: queryData.currentUser.currentWorkspace.defaultRole ?? null, defaultRole: queryData.currentUser.currentWorkspace.defaultRole ?? null,
defaultAgent:
queryData.currentUser.currentWorkspace.defaultAgent ?? null,
}); });
} }

View File

@ -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 { import {
AVAILABLE_WORKSPACE_FOR_AUTH_FRAGMENT, AVAILABLE_WORKSPACE_FOR_AUTH_FRAGMENT,
AVAILABLE_WORKSPACES_FOR_AUTH_FRAGMENT, AVAILABLE_WORKSPACES_FOR_AUTH_FRAGMENT,
} from '@/auth/graphql/fragments/authFragments'; } 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 { 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` export const USER_QUERY_FRAGMENT = gql`
${ROLE_FRAGMENT} ${ROLE_FRAGMENT}
@ -92,6 +92,9 @@ export const USER_QUERY_FRAGMENT = gql`
defaultRole { defaultRole {
...RoleFragment ...RoleFragment
} }
defaultAgent {
id
}
} }
availableWorkspaces { availableWorkspaces {
...AvailableWorkspacesFragment ...AvailableWorkspacesFragment

View File

@ -1,5 +1,5 @@
import { TextArea } from '@/ui/input/components/TextArea'; import { TextArea } from '@/ui/input/components/TextArea';
import { useTheme } from '@emotion/react'; import { keyframes, useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import React from 'react'; import React from 'react';
import { Avatar, IconDotsVertical, IconSparkles } from 'twenty-ui/display'; 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 { Button } from 'twenty-ui/input';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { useAgentChat } from '../hooks/useAgentChat'; import { useAgentChat } from '../hooks/useAgentChat';
import { AgentChatMessage } from '../hooks/useAgentChatMessages';
import { AIChatSkeletonLoader } from './AIChatSkeletonLoader'; import { AIChatSkeletonLoader } from './AIChatSkeletonLoader';
const StyledContainer = styled.div` 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; display: flex;
flex-direction: row; flex-direction: row;
align-items: ${({ isShowingToolCall }) =>
isShowingToolCall ? 'center' : 'flex-start'};
gap: ${({ theme }) => theme.spacing(3)}; gap: ${({ theme }) => theme.spacing(3)};
width: 100%; width: 100%;
`; `;
@ -152,6 +155,23 @@ const StyledMessageContainer = styled.div`
width: 100%; 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 = { type AIChatTabProps = {
agentId: string; agentId: string;
}; };
@ -168,16 +188,49 @@ export const AIChatTab: React.FC<AIChatTabProps> = ({ agentId }) => {
agentStreamingMessage, agentStreamingMessage,
} = useAgentChat(agentId); } = useAgentChat(agentId);
const getAssistantMessageContent = (message: AgentChatMessage) => {
if (message.content !== '') {
return message.content;
}
if (agentStreamingMessage.streamingText !== '') {
return agentStreamingMessage.streamingText;
}
if (agentStreamingMessage.toolCall !== '') {
return (
<StyledToolCallContainer>
{agentStreamingMessage.toolCall}
</StyledToolCallContainer>
);
}
return (
<StyledDotsIconContainer>
<StyledDotsIcon size={theme.icon.size.xl} />
</StyledDotsIconContainer>
);
};
return ( return (
<StyledContainer> <StyledContainer>
{messages.length !== 0 && ( {messages.length !== 0 && (
<StyledScrollWrapper componentInstanceId={agentId}> <StyledScrollWrapper
componentInstanceId={`scroll-wrapper-ai-chat-${agentId}`}
>
{messages.map((msg) => ( {messages.map((msg) => (
<StyledMessageBubble <StyledMessageBubble
key={msg.id} key={msg.id}
isUser={msg.role === AgentChatMessageRole.USER} isUser={msg.role === AgentChatMessageRole.USER}
> >
<StyledMessageRow> <StyledMessageRow
isShowingToolCall={
msg.role === AgentChatMessageRole.ASSISTANT &&
msg.content === '' &&
agentStreamingMessage.streamingText === '' &&
agentStreamingMessage.toolCall !== ''
}
>
{msg.role === AgentChatMessageRole.ASSISTANT && ( {msg.role === AgentChatMessageRole.ASSISTANT && (
<StyledAvatarContainer> <StyledAvatarContainer>
<Avatar <Avatar
@ -197,12 +250,8 @@ export const AIChatTab: React.FC<AIChatTabProps> = ({ agentId }) => {
<StyledMessageText <StyledMessageText
isUser={msg.role === AgentChatMessageRole.USER} isUser={msg.role === AgentChatMessageRole.USER}
> >
{msg.role === AgentChatMessageRole.ASSISTANT && !msg.content {msg.role === AgentChatMessageRole.ASSISTANT
? agentStreamingMessage || ( ? getAssistantMessageContent(msg)
<StyledDotsIconContainer>
<StyledDotsIcon size={theme.icon.size.xl} />
</StyledDotsIconContainer>
)
: msg.content} : msg.content}
</StyledMessageText> </StyledMessageText>
{msg.content && ( {msg.content && (

View File

@ -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 { 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 { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { useApolloClient } from '@apollo/client'; import { useApolloClient } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { agentChatInputState } from '../states/agentChatInputState'; import { agentChatInputState } from '../states/agentChatInputState';
import { agentChatMessagesComponentState } from '../states/agentChatMessagesComponentState'; import { agentChatMessagesComponentState } from '../states/agentChatMessagesComponentState';
import { agentStreamingMessageState } from '../states/agentStreamingMessageState'; import { agentStreamingMessageState } from '../states/agentStreamingMessageState';
import { parseAgentStreamingChunk } from '../utils/parseAgentStreamingChunk';
import { AgentChatMessage, useAgentChatMessages } from './useAgentChatMessages'; import { AgentChatMessage, useAgentChatMessages } from './useAgentChatMessages';
import { useAgentChatThreads } from './useAgentChatThreads'; import { useAgentChatThreads } from './useAgentChatThreads';
@ -50,21 +50,14 @@ export const useAgentChat = (agentId: string) => {
useAgentChatThreads(agentId); useAgentChatThreads(agentId);
const currentThreadId = threads[0]?.id; const currentThreadId = threads[0]?.id;
const { const { loading: messagesLoading, refetch: refetchMessages } =
data: messagesData, useAgentChatMessages(currentThreadId, ({ messages }) => {
loading: messagesLoading, setAgentChatMessages(messages);
refetch: refetchMessages, scrollToBottom();
} = useAgentChatMessages(currentThreadId); });
const isLoading = messagesLoading || threadsLoading || isStreaming; const isLoading = messagesLoading || threadsLoading || isStreaming;
if (
agentChatMessages.length === 0 &&
isDefined(messagesData?.messages?.length)
) {
setAgentChatMessages(messagesData.messages);
}
const createOptimisticMessages = (content: string): AgentChatMessage[] => { const createOptimisticMessages = (content: string): AgentChatMessage[] => {
const optimisticUserMessage: OptimisticMessage = { const optimisticUserMessage: OptimisticMessage = {
id: v4(), id: v4(),
@ -104,8 +97,22 @@ export const useAgentChat = (agentId: string) => {
}, },
context: { context: {
onChunk: (chunk: string) => { onChunk: (chunk: string) => {
setAgentStreamingMessage(chunk); parseAgentStreamingChunk(chunk, {
scrollToBottom(); 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(); const { data } = await refetchMessages();
setAgentChatMessages(data?.messages); setAgentChatMessages(data?.messages);
setAgentStreamingMessage(''); setAgentStreamingMessage({
toolCall: '',
streamingText: '',
});
scrollToBottom(); scrollToBottom();
}; };

View File

@ -11,9 +11,13 @@ export type AgentChatMessage = {
createdAt: string; 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, { return useQuery<{ messages: AgentChatMessage[] }>(GET_AGENT_CHAT_MESSAGES, {
variables: { threadId }, variables: { threadId },
skip: !isDefined(threadId), skip: !isDefined(threadId),
onCompleted,
}); });
}; };

View File

@ -1,6 +1,12 @@
import { atom } from 'recoil'; import { atom } from 'recoil';
export const agentStreamingMessageState = atom<string>({ export const agentStreamingMessageState = atom<{
toolCall: string;
streamingText: string;
}>({
key: 'agentStreamingMessageState', key: 'agentStreamingMessageState',
default: '', default: {
toolCall: '',
streamingText: '',
},
}); });

View File

@ -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);
}
}
}
};

View File

@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDefaultAgentId1752070094777 implements MigrationInterface {
name = 'AddDefaultAgentId1752070094777';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(
`ALTER TABLE "core"."agent" ALTER COLUMN "modelId" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN "defaultAgentId"`,
);
}
}

View File

@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AgentAsStandardMetadata1752088464449
implements MigrationInterface
{
name = 'AgentAsStandardMetadata1752088464449';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

View File

@ -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,
);
});
});

View File

@ -1,9 +1,11 @@
export enum ModelProvider { export enum ModelProvider {
NONE = 'none',
OPENAI = 'openai', OPENAI = 'openai',
ANTHROPIC = 'anthropic', ANTHROPIC = 'anthropic',
} }
export type ModelId = export type ModelId =
| 'auto'
| 'gpt-4o' | 'gpt-4o'
| 'gpt-4o-mini' | 'gpt-4o-mini'
| 'gpt-4-turbo' | 'gpt-4-turbo'
@ -11,6 +13,8 @@ export type ModelId =
| 'claude-sonnet-4-20250514' | 'claude-sonnet-4-20250514'
| 'claude-3-5-haiku-20241022'; | 'claude-3-5-haiku-20241022';
export const DEFAULT_MODEL_ID: ModelId = 'gpt-4o';
export interface AIModelConfig { export interface AIModelConfig {
modelId: ModelId; modelId: ModelId;
label: string; label: string;

View File

@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const'; 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 { 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 { 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 { 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'; import { BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';

View File

@ -1,19 +1,19 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { ToolSet } from 'ai'; import { ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm'; 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 { 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 { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; 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 { 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 { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@Injectable() @Injectable()
export class McpService { export class McpService {

View File

@ -1,30 +1,31 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ToolSet } from 'ai';
import { import {
ILike,
In, In,
IsNull, IsNull,
LessThan, LessThan,
LessThanOrEqual, LessThanOrEqual,
Like, Like,
ILike,
MoreThan, MoreThan,
MoreThanOrEqual, MoreThanOrEqual,
Not, Not,
} from 'typeorm'; } 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 { 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 { import {
generateBulkDeleteToolSchema, generateBulkDeleteToolSchema,
generateFindOneToolSchema,
generateFindToolSchema, generateFindToolSchema,
generateSoftDeleteToolSchema,
getRecordInputSchema, getRecordInputSchema,
} from 'src/engine/metadata-modules/agent/utils/agent-tool-schema.utils'; } 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 { 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() @Injectable()
export class ToolService { export class ToolService {
@ -72,7 +73,7 @@ export class ToolService {
execute: async (parameters) => { execute: async (parameters) => {
return this.createRecord( return this.createRecord(
objectMetadata.nameSingular, objectMetadata.nameSingular,
parameters, parameters.input,
workspaceId, workspaceId,
roleId, roleId,
); );
@ -85,7 +86,7 @@ export class ToolService {
execute: async (parameters) => { execute: async (parameters) => {
return this.updateRecord( return this.updateRecord(
objectMetadata.nameSingular, objectMetadata.nameSingular,
parameters, parameters.input,
workspaceId, workspaceId,
roleId, roleId,
); );
@ -100,7 +101,7 @@ export class ToolService {
execute: async (parameters) => { execute: async (parameters) => {
return this.findRecords( return this.findRecords(
objectMetadata.nameSingular, objectMetadata.nameSingular,
parameters, parameters.input,
workspaceId, workspaceId,
roleId, roleId,
); );
@ -109,15 +110,11 @@ export class ToolService {
tools[`find_one_${objectMetadata.nameSingular}`] = { 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.`, 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({ parameters: generateFindOneToolSchema(),
id: z
.string()
.describe('The unique UUID of the record to retrieve'),
}),
execute: async (parameters) => { execute: async (parameters) => {
return this.findOneRecord( return this.findOneRecord(
objectMetadata.nameSingular, objectMetadata.nameSingular,
parameters, parameters.input,
workspaceId, workspaceId,
roleId, roleId,
); );
@ -128,15 +125,11 @@ export class ToolService {
if (objectPermission.canSoftDelete) { if (objectPermission.canSoftDelete) {
tools[`soft_delete_${objectMetadata.nameSingular}`] = { 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.`, 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({ parameters: generateSoftDeleteToolSchema(),
id: z
.string()
.describe('The unique UUID of the record to soft delete'),
}),
execute: async (parameters) => { execute: async (parameters) => {
return this.softDeleteRecord( return this.softDeleteRecord(
objectMetadata.nameSingular, objectMetadata.nameSingular,
parameters, parameters.input,
workspaceId, workspaceId,
roleId, roleId,
); );
@ -149,7 +142,7 @@ export class ToolService {
execute: async (parameters) => { execute: async (parameters) => {
return this.softDeleteManyRecords( return this.softDeleteManyRecords(
objectMetadata.nameSingular, objectMetadata.nameSingular,
parameters, parameters.input,
workspaceId, workspaceId,
roleId, roleId,
); );

View File

@ -4,4 +4,5 @@
* @param cents - Cost in cents (real cost) * @param cents - Cost in cents (real cost)
* @returns Cost in credits (end-user cost) * @returns Cost in credits (end-user cost)
*/ */
export const convertCentsToCredits = (cents: number): number => cents * 10; export const convertCentsToBillingCredits = (cents: number): number =>
cents * 10;

View File

@ -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);
};

View File

@ -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);
};

View File

@ -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,
);
});
});

View File

@ -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,
];
};

View File

@ -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;
});
});

View File

@ -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;
};

View File

@ -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`,
);
});
});

View File

@ -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;
};

View File

@ -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 { 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'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
jest.mock('src/engine/core-modules/ai/constants/ai-models.const', () => ({ jest.mock(
AI_MODELS: [], 'src/engine/core-modules/ai/utils/get-ai-models-with-auto.util',
})); () => ({
getAIModelsWithAuto: jest.fn(() => []),
}),
);
describe('ClientConfigService', () => { describe('ClientConfigService', () => {
let service: ClientConfigService; let service: ClientConfigService;

View File

@ -3,11 +3,9 @@ import { Injectable } from '@nestjs/common';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; 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 { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import { import { ModelProvider } from 'src/engine/core-modules/ai/constants/ai-models.const';
AI_MODELS, import { convertCentsToBillingCredits } from 'src/engine/core-modules/ai/utils/convert-cents-to-billing-credits.util';
ModelProvider, import { getAIModelsWithAuto } from 'src/engine/core-modules/ai/utils/get-ai-models-with-auto.util';
} from 'src/engine/core-modules/ai/constants/ai-models.const';
import { convertCentsToCredits } from 'src/engine/core-modules/ai/utils/ai-cost.utils';
import { import {
ClientAIModelConfig, ClientAIModelConfig,
ClientConfig, ClientConfig,
@ -29,29 +27,32 @@ export class ClientConfigService {
const openaiApiKey = this.twentyConfigService.get('OPENAI_API_KEY'); const openaiApiKey = this.twentyConfigService.get('OPENAI_API_KEY');
const anthropicApiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY'); const anthropicApiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY');
const aiModels = AI_MODELS.reduce<ClientAIModelConfig[]>((acc, model) => { const aiModels = getAIModelsWithAuto().reduce<ClientAIModelConfig[]>(
const isAvailable = (acc, model) => {
(model.provider === ModelProvider.OPENAI && openaiApiKey) || const isAvailable =
(model.provider === ModelProvider.ANTHROPIC && anthropicApiKey); (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; 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 = { const clientConfig: ClientConfig = {
billing: { billing: {

View File

@ -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 { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { Webhook } from 'src/engine/core-modules/webhook/webhook.entity'; import { Webhook } from 'src/engine/core-modules/webhook/webhook.entity';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.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'; import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
registerEnumType(WorkspaceActivationStatus, { registerEnumType(WorkspaceActivationStatus, {
@ -171,12 +172,20 @@ export class Workspace {
@Column({ default: false }) @Column({ default: false })
isCustomDomainEnabled: boolean; isCustomDomainEnabled: boolean;
// TODO: set as non nullable
@Column({ nullable: true, type: 'uuid' }) @Column({ nullable: true, type: 'uuid' })
defaultRoleId: string | null; defaultRoleId: string | null;
@Field(() => RoleDTO, { nullable: true }) @Field(() => RoleDTO, { nullable: true })
defaultRole: RoleDTO | null; 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 }) @Field(() => String, { nullable: true })
@Column({ type: 'varchar', nullable: true }) @Column({ type: 'varchar', nullable: true })
version: string | null; version: string | null;

View File

@ -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 { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/workspace/workspace-workspace-member.listener'; import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/workspace/workspace-workspace-member.listener';
import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.resolver'; 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 { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
@ -57,6 +58,7 @@ import { WorkspaceService } from './services/workspace.service';
WorkspaceCacheStorageModule, WorkspaceCacheStorageModule,
AuditModule, AuditModule,
RoleModule, RoleModule,
AgentModule,
], ],
services: [WorkspaceService], services: [WorkspaceService],
resolvers: workspaceAutoResolverOpts, resolvers: workspaceAutoResolverOpts,

View File

@ -56,6 +56,8 @@ import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard'; import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-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 { 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 { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
@ -92,6 +94,7 @@ export class WorkspaceResolver {
private readonly billingSubscriptionService: BillingSubscriptionService, private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly featureFlagService: FeatureFlagService, private readonly featureFlagService: FeatureFlagService,
private readonly roleService: RoleService, private readonly roleService: RoleService,
private readonly agentService: AgentService,
@InjectRepository(BillingSubscription, 'core') @InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>, private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
) {} ) {}
@ -222,6 +225,29 @@ export class WorkspaceResolver {
); );
} }
@ResolveField(() => AgentDTO, { nullable: true })
async defaultAgent(@Parent() workspace: Workspace): Promise<AgentDTO | null> {
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 }) @ResolveField(() => BillingSubscription, { nullable: true })
async currentBillingSubscription( async currentBillingSubscription(
@Parent() workspace: Workspace, @Parent() workspace: Workspace,

View File

@ -62,11 +62,33 @@ export class AgentChatController {
@AuthUserWorkspaceId() userWorkspaceId: string, @AuthUserWorkspaceId() userWorkspaceId: string,
@Res() res: Response, @Res() res: Response,
) { ) {
await this.agentStreamingService.streamAgentChat({ try {
threadId: body.threadId, await this.agentStreamingService.streamAgentChat({
userMessage: body.userMessage, threadId: body.threadId,
userWorkspaceId, userMessage: body.userMessage,
res, 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();
}
} }
} }

View File

@ -13,8 +13,6 @@ import {
AgentExceptionCode, AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception'; } from 'src/engine/metadata-modules/agent/agent.exception';
import { AgentExecutionService } from './agent-execution.service';
@Injectable() @Injectable()
export class AgentChatService { export class AgentChatService {
constructor( constructor(
@ -22,7 +20,6 @@ export class AgentChatService {
private readonly threadRepository: Repository<AgentChatThreadEntity>, private readonly threadRepository: Repository<AgentChatThreadEntity>,
@InjectRepository(AgentChatMessageEntity, 'core') @InjectRepository(AgentChatMessageEntity, 'core')
private readonly messageRepository: Repository<AgentChatMessageEntity>, private readonly messageRepository: Repository<AgentChatMessageEntity>,
private readonly agentExecutionService: AgentExecutionService,
) {} ) {}
async createThread(agentId: string, userWorkspaceId: string) { async createThread(agentId: string, userWorkspaceId: string) {

View File

@ -10,7 +10,7 @@ import {
ModelId, ModelId,
ModelProvider, ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const'; } 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 { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { import {
AgentChatMessageEntity, 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 { 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 { 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 { 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 { AgentEntity } from './agent.entity';
import { AgentException, AgentExceptionCode } from './agent.exception'; import { AgentException, AgentExceptionCode } from './agent.exception';
@ -45,12 +46,17 @@ export class AgentExecutionService {
private readonly agentToolService: AgentToolService, private readonly agentToolService: AgentToolService,
@InjectRepository(AgentEntity, 'core') @InjectRepository(AgentEntity, 'core')
private readonly agentRepository: Repository<AgentEntity>, private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(AgentChatMessageEntity, 'core')
private readonly agentChatmessageRepository: Repository<AgentChatMessageEntity>,
) {} ) {}
getModel = (modelId: ModelId, provider: ModelProvider) => { getModel = (modelId: ModelId, provider: ModelProvider) => {
switch (provider) { switch (provider) {
case ModelProvider.NONE: {
const OpenAIProvider = createOpenAI({
apiKey: this.twentyConfigService.get('OPENAI_API_KEY'),
});
return OpenAIProvider(getEffectiveModelConfig(modelId).modelId);
}
case ModelProvider.OPENAI: { case ModelProvider.OPENAI: {
const OpenAIProvider = createOpenAI({ const OpenAIProvider = createOpenAI({
apiKey: this.twentyConfigService.get('OPENAI_API_KEY'), apiKey: this.twentyConfigService.get('OPENAI_API_KEY'),
@ -77,6 +83,9 @@ export class AgentExecutionService {
let apiKey: string | undefined; let apiKey: string | undefined;
switch (provider) { switch (provider) {
case ModelProvider.NONE:
apiKey = this.twentyConfigService.get('OPENAI_API_KEY');
break;
case ModelProvider.OPENAI: case ModelProvider.OPENAI:
apiKey = this.twentyConfigService.get('OPENAI_API_KEY'); apiKey = this.twentyConfigService.get('OPENAI_API_KEY');
break; break;
@ -91,7 +100,7 @@ export class AgentExecutionService {
} }
if (!apiKey) { if (!apiKey) {
throw new AgentException( throw new AgentException(
`${provider.toUpperCase()} API key not configured`, `${provider === ModelProvider.NONE ? 'OPENAI' : provider.toUpperCase()} API key not configured`,
AgentExceptionCode.API_KEY_NOT_CONFIGURED, AgentExceptionCode.API_KEY_NOT_CONFIGURED,
); );
} }

View File

@ -65,7 +65,7 @@ export class AgentStreamingService {
this.setupStreamingHeaders(res); this.setupStreamingHeaders(res);
const { textStream } = const { fullStream } =
await this.agentExecutionService.streamChatResponse({ await this.agentExecutionService.streamChatResponse({
agentId: thread.agent.id, agentId: thread.agent.id,
userMessage, userMessage,
@ -74,9 +74,24 @@ export class AgentStreamingService {
let aiResponse = ''; let aiResponse = '';
for await (const chunk of textStream) { for await (const chunk of fullStream) {
aiResponse += chunk; switch (chunk.type) {
res.write(chunk); 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({ await this.agentChatService.addMessage({
@ -90,10 +105,26 @@ export class AgentStreamingService {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : 'Unknown error occurred'; 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 { private setupStreamingHeaders(res: Response): void {
res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Transfer-Encoding', 'chunked'); res.setHeader('Transfer-Encoding', 'chunked');

View File

@ -4,9 +4,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import { ToolSet } from 'ai'; import { ToolSet } from 'ai';
import { Repository } from 'typeorm'; 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 { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
@Injectable() @Injectable()
export class AgentToolService { export class AgentToolService {

View File

@ -8,6 +8,7 @@ import {
ManyToOne, ManyToOne,
OneToMany, OneToMany,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Unique,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
@ -20,6 +21,7 @@ import { AgentChatThreadEntity } from './agent-chat-thread.entity';
@Entity('agent') @Entity('agent')
@Index('IDX_AGENT_ID_DELETED_AT', ['id', 'deletedAt']) @Index('IDX_AGENT_ID_DELETED_AT', ['id', 'deletedAt'])
@Unique('IDX_AGENT_NAME_WORKSPACE_ID_UNIQUE', ['name', 'workspaceId'])
export class AgentEntity { export class AgentEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@ -27,13 +29,19 @@ export class AgentEntity {
@Column({ nullable: false }) @Column({ nullable: false })
name: string; name: string;
@Column({ nullable: false })
label: string;
@Column({ nullable: true })
icon: string;
@Column({ nullable: true }) @Column({ nullable: true })
description: string; description: string;
@Column({ nullable: false, type: 'text' }) @Column({ nullable: false, type: 'text' })
prompt: string; prompt: string;
@Column({ nullable: false, type: 'varchar' }) @Column({ nullable: false, type: 'varchar', default: 'auto' })
modelId: ModelId; modelId: ModelId;
@Column({ nullable: true, type: 'jsonb' }) @Column({ nullable: true, type: 'jsonb' })
@ -42,6 +50,9 @@ export class AgentEntity {
@Column({ nullable: false, type: 'uuid' }) @Column({ nullable: false, type: 'uuid' })
workspaceId: string; workspaceId: string;
@Column({ default: false })
isCustom: boolean;
@ManyToOne(() => Workspace, (workspace) => workspace.agents, { @ManyToOne(() => Workspace, (workspace) => workspace.agents, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })

View File

@ -11,4 +11,5 @@ export enum AgentExceptionCode {
AGENT_NOT_FOUND = 'AGENT_NOT_FOUND', AGENT_NOT_FOUND = 'AGENT_NOT_FOUND',
AGENT_EXECUTION_FAILED = 'AGENT_EXECUTION_FAILED', AGENT_EXECUTION_FAILED = 'AGENT_EXECUTION_FAILED',
API_KEY_NOT_CONFIGURED = 'API_KEY_NOT_CONFIGURED', API_KEY_NOT_CONFIGURED = 'API_KEY_NOT_CONFIGURED',
USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND',
} }

View File

@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const'; 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 { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { AgentEntity } from './agent.entity'; import { AgentEntity } from './agent.entity';
@ -16,6 +17,7 @@ export class AgentService {
private readonly agentRepository: Repository<AgentEntity>, private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(RoleTargetsEntity, 'core') @InjectRepository(RoleTargetsEntity, 'core')
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>, private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
private readonly agentChatService: AgentChatService,
) {} ) {}
async findManyAgents(workspaceId: string) { 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( async createOneAgent(
input: { input: {
name: string; name: string;
label: string;
description?: string; description?: string;
prompt: string; prompt: string;
modelId: ModelId; modelId: ModelId;

View File

@ -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 { convertObjectMetadataToSchemaProperties } from 'src/engine/utils/convert-object-metadata-to-schema-properties.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
export const getRecordInputSchema = (objectMetadata: ObjectMetadataEntity) => { const createToolSchema = (
inputProperties: Record<string, JSONSchema7Definition>,
required?: string[],
) => {
return jsonSchema({ return jsonSchema({
type: 'object', 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, item: objectMetadata,
forResponse: false, forResponse: false,
}), }),
}); );
}; };
export const generateFindToolSchema = ( export const generateFindToolSchema = (
@ -48,10 +68,7 @@ export const generateFindToolSchema = (
} }
}); });
return jsonSchema({ return createToolSchema(schemaProperties);
type: 'object',
properties: schemaProperties,
});
}; };
const generateFieldFilterJsonSchema = ( const generateFieldFilterJsonSchema = (
@ -808,25 +825,22 @@ const generateFieldFilterJsonSchema = (
}; };
export const generateBulkDeleteToolSchema = () => { export const generateBulkDeleteToolSchema = () => {
return jsonSchema({ return createToolSchema({
type: 'object', filter: {
properties: { type: 'object',
filter: { description: 'Filter criteria to select records for bulk delete',
type: 'object', properties: {
description: 'Filter criteria to select records for bulk delete', id: {
properties: { type: 'object',
id: { description: 'Filter to select records to delete',
type: 'object', properties: {
description: 'Filter to select records to delete', in: {
properties: { type: 'array',
in: { items: {
type: 'array', type: 'string',
items: { format: 'uuid',
type: 'string',
format: 'uuid',
},
description: 'Array of record IDs to delete',
}, },
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'],
);
};

View File

@ -6,6 +6,7 @@ import { Repository } from 'typeorm';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; 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 { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { Workspace } from 'src/engine/core-modules/workspace/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 { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@ -115,6 +116,14 @@ describe('WorkspaceManagerService', () => {
deleteObjectsMetadata: jest.fn(), deleteObjectsMetadata: jest.fn(),
}, },
}, },
{
provide: AgentService,
useValue: {
createOneAgent: jest
.fn()
.mockResolvedValue({ id: 'mock-agent-id' }),
},
},
], ],
}).compile(); }).compile();

View File

@ -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);
};

View File

@ -1,6 +1,7 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { seedBillingSubscriptions } from 'src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-subscriptions.util'; 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 { 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 { 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'; 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 seedUsers(dataSource, schemaName);
await seedUserWorkspaces(dataSource, schemaName, workspaceId); await seedUserWorkspaces(dataSource, schemaName, workspaceId);
await seedAgents(dataSource, schemaName, workspaceId);
await seedApiKeys(dataSource, schemaName, workspaceId); await seedApiKeys(dataSource, schemaName, workspaceId);
if (shouldSeedFeatureFlags) { if (shouldSeedFeatureFlags) {

View File

@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; 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 { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { Workspace } from 'src/engine/core-modules/workspace/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 { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
@ -33,6 +34,7 @@ import { WorkspaceManagerService } from './workspace-manager.service';
WorkspaceHealthModule, WorkspaceHealthModule,
FeatureFlagModule, FeatureFlagModule,
PermissionsModule, PermissionsModule,
AgentModule,
TypeOrmModule.forFeature([UserWorkspace, Workspace], 'core'), TypeOrmModule.forFeature([UserWorkspace, Workspace], 'core'),
RoleModule, RoleModule,
UserRoleModule, UserRoleModule,

View File

@ -3,9 +3,11 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from '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 { 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 { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { Workspace } from 'src/engine/core-modules/workspace/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 { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@ -42,6 +44,7 @@ export class WorkspaceManagerService {
private readonly roleRepository: Repository<RoleEntity>, private readonly roleRepository: Repository<RoleEntity>,
@InjectRepository(RoleTargetsEntity, 'core') @InjectRepository(RoleTargetsEntity, 'core')
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>, private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
private readonly agentService: AgentService,
) {} ) {}
public async init({ public async init({
@ -95,6 +98,17 @@ export class WorkspaceManagerService {
`Permissions enabled took ${permissionsEnabledEnd - permissionsEnabledStart}ms`, `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(); const prefillStandardObjectsStart = performance.now();
await this.prefillWorkspaceWithStandardObjectsRecords( await this.prefillWorkspaceWithStandardObjectsRecords(
@ -194,4 +208,21 @@ export class WorkspaceManagerService {
defaultRoleId: memberRole.id, 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,
});
}
} }

View File

@ -616,14 +616,16 @@ export class WorkflowVersionStepWorkspaceService {
}; };
} }
case WorkflowActionType.AI_AGENT: { 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', description: 'Created automatically for workflow step',
prompt: '', prompt: '',
modelId: 'gpt-4o', modelId: 'auto',
}, },
workspaceId, workspaceId,
this.scopedWorkspaceContextFactory.create().userWorkspaceId,
); );
if (!isDefined(newAgent)) { if (!isDefined(newAgent)) {
@ -636,15 +638,13 @@ export class WorkflowVersionStepWorkspaceService {
const userWorkspaceId = const userWorkspaceId =
this.scopedWorkspaceContextFactory.create().userWorkspaceId; this.scopedWorkspaceContextFactory.create().userWorkspaceId;
if (!userWorkspaceId) { if (userWorkspaceId) {
throw new WorkflowVersionStepException( await this.agentChatService.createThread(
'User workspace ID not found', newAgent.id,
WorkflowVersionStepExceptionCode.FAILURE, userWorkspaceId,
); );
} }
await this.agentChatService.createThread(newAgent.id, userWorkspaceId);
return { return {
id: newStepId, id: newStepId,
name: 'AI Agent', name: 'AI Agent',

View File

@ -1,11 +1,11 @@
import { Test, TestingModule } from '@nestjs/testing'; 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 { import {
AgentException, AgentException,
AgentExceptionCode, AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception'; } 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 // Mock the agent service
jest.mock('../../../../../src/engine/metadata-modules/agent/agent.service'); jest.mock('../../../../../src/engine/metadata-modules/agent/agent.service');

View File

@ -216,7 +216,7 @@ describe('AgentToolService Integration', () => {
} }
const result = await createTool.execute( const result = await createTool.execute(
{ name: 'Test Record', description: 'Test description' }, { input: { name: 'Test Record', description: 'Test description' } },
{ {
toolCallId: 'test-tool-call-id', toolCallId: 'test-tool-call-id',
messages: [ messages: [
@ -260,7 +260,7 @@ describe('AgentToolService Integration', () => {
} }
const result = await createTool.execute( const result = await createTool.execute(
{ name: 'Test Record' }, { input: { name: 'Test Record' } },
{ {
toolCallId: 'test-tool-call-id', toolCallId: 'test-tool-call-id',
messages: [ messages: [
@ -304,7 +304,7 @@ describe('AgentToolService Integration', () => {
} }
const result = await findTool.execute( const result = await findTool.execute(
{ limit: 10, offset: 0 }, { input: { limit: 10, offset: 0 } },
{ {
toolCallId: 'test-tool-call-id', toolCallId: 'test-tool-call-id',
messages: [ messages: [
@ -352,7 +352,7 @@ describe('AgentToolService Integration', () => {
} }
const result = await findOneTool.execute( const result = await findOneTool.execute(
{ id: 'test-record-id' }, { input: { id: 'test-record-id' } },
{ {
toolCallId: 'test-tool-call-id', toolCallId: 'test-tool-call-id',
messages: [ messages: [
@ -393,7 +393,7 @@ describe('AgentToolService Integration', () => {
} }
const result = await findOneTool.execute( const result = await findOneTool.execute(
{ id: 'non-existent-id' }, { input: { id: 'non-existent-id' } },
{ {
toolCallId: 'test-tool-call-id', toolCallId: 'test-tool-call-id',
messages: [ messages: [
@ -430,7 +430,7 @@ describe('AgentToolService Integration', () => {
} }
const result = await findOneTool.execute( const result = await findOneTool.execute(
{}, { input: {} },
{ {
toolCallId: 'test-tool-call-id', toolCallId: 'test-tool-call-id',
messages: [ messages: [
@ -488,9 +488,11 @@ describe('AgentToolService Integration', () => {
const result = await updateTool.execute( const result = await updateTool.execute(
{ {
id: 'test-record-id', input: {
name: 'New Name', id: 'test-record-id',
description: 'New description', name: 'New Name',
description: 'New description',
},
}, },
{ {
toolCallId: 'test-tool-call-id', toolCallId: 'test-tool-call-id',
@ -534,8 +536,10 @@ describe('AgentToolService Integration', () => {
const result = await updateTool.execute( const result = await updateTool.execute(
{ {
id: 'non-existent-id', input: {
name: 'New Name', id: 'non-existent-id',
name: 'New Name',
},
}, },
{ {
toolCallId: 'test-tool-call-id', toolCallId: 'test-tool-call-id',
@ -583,7 +587,7 @@ describe('AgentToolService Integration', () => {
} }
const result = await softDeleteTool.execute( const result = await softDeleteTool.execute(
{ id: 'test-record-id' }, { input: { id: 'test-record-id' } },
{ {
toolCallId: 'test-tool-call-id', toolCallId: 'test-tool-call-id',
messages: [ messages: [
@ -624,7 +628,9 @@ describe('AgentToolService Integration', () => {
const result = await softDeleteManyTool.execute( 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', toolCallId: 'test-tool-call-id',
@ -671,7 +677,7 @@ describe('AgentToolService Integration', () => {
} }
const result = await findTool.execute( const result = await findTool.execute(
{}, { input: {} },
{ {
toolCallId: 'test-tool-call-id', toolCallId: 'test-tool-call-id',
messages: [ messages: [
@ -716,10 +722,12 @@ describe('AgentToolService Integration', () => {
const result = await findTool.execute( const result = await findTool.execute(
{ {
name: null, input: {
description: undefined, name: null,
status: '', description: undefined,
validField: 'valid value', status: '',
validField: 'valid value',
},
}, },
{ {
toolCallId: 'test-tool-call-id', toolCallId: 'test-tool-call-id',

View File

@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from '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 { AgentToolService } from 'src/engine/metadata-modules/agent/agent-tool.service';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; 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 { 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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; 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 { export interface AgentToolTestContext {
module: TestingModule; module: TestingModule;
@ -103,7 +103,10 @@ export const createAgentToolTestModule =
const testAgent: AgentEntity & { roleId: string | null } = { const testAgent: AgentEntity & { roleId: string | null } = {
id: testAgentId, id: testAgentId,
name: 'Test Agent', name: 'test-agent',
label: 'Test Agent',
icon: 'IconTest',
isCustom: false,
description: 'Test agent for integration tests', description: 'Test agent for integration tests',
prompt: 'You are a test agent', prompt: 'You are a test agent',
modelId: 'gpt-4o', modelId: 'gpt-4o',