feat: implement skills system for AI agents (#16865)

## Summary
This PR introduces a Skills system for AI agents, inspired by the [Agent
Skills specification](https://agentskills.io/specification).

## Changes

### Backend
- **SkillEntity**: New database entity with migration for storing skills
- **V2 Sync Mechanism**: Implemented FlatSkill, builders, validators,
and action handlers following the v2 flat entity pattern
- **Standard Skills**: Pre-defined skills (workflow-building,
data-manipulation, dashboard-building, metadata-building, research,
code-interpreter, xlsx, pdf, docx, pptx)
- **GraphQL API**: CRUD operations for skills with proper guards and
permissions
- **Workspace Cache**: Integrated skills into the workspace cache system

### Frontend  
- **Skills Table**: Searchable table in AI settings showing all skills
- **Skill Form**: Create/edit page with Label (primary), Description,
and Content (markdown editor)
- **API Name**: Following existing patterns, name is derived from label
with advanced settings toggle for custom API names
- **Standard vs Custom**: Standard skills are read-only, custom skills
can be edited/deleted

## Key Design Decisions
- Skills are stored in the database (Salesforce-like approach) rather
than files
- Name is derived from Label by default (isLabelSyncedWithName pattern)
- Skills reference functions/files via @ mentions in markdown content
rather than explicit relations
- Standard skills are synced from code, custom skills are created via UI

## Screenshots
Skills table and form UI follow existing settings patterns.

## Testing
- [x] Lint passes
- [x] Typecheck passes
- [ ] CI tests
This commit is contained in:
Félix Malfait
2026-01-02 15:22:01 +01:00
committed by GitHub
parent 2a3fd788ae
commit 21ff42074d
146 changed files with 6964 additions and 1282 deletions
+2 -2
View File
@@ -171,7 +171,7 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6]
shard: [1, 2, 3, 4, 5, 6, 7, 8]
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -209,7 +209,7 @@ jobs:
ANALYTICS_ENABLED: true
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
CLICKHOUSE_PASSWORD: clickhousePassword
SHARD_COUNTER: 6
SHARD_COUNTER: 8
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
+17 -15
View File
@@ -56,21 +56,22 @@
"@react-pdf/renderer": "^4.1.6",
"@scalar/api-reference-react": "^0.4.36",
"@sentry/react": "^10.27.0",
"@tiptap/core": "^3.4.2",
"@tiptap/extension-bold": "^3.4.2",
"@tiptap/extension-document": "^3.4.2",
"@tiptap/extension-hard-break": "^3.4.2",
"@tiptap/extension-heading": "^3.4.2",
"@tiptap/extension-image": "^3.4.4",
"@tiptap/extension-italic": "^3.4.2",
"@tiptap/extension-link": "^3.4.2",
"@tiptap/core": "3.4.2",
"@tiptap/extension-bold": "3.4.2",
"@tiptap/extension-document": "3.4.2",
"@tiptap/extension-hard-break": "3.4.2",
"@tiptap/extension-heading": "3.4.2",
"@tiptap/extension-image": "3.4.4",
"@tiptap/extension-italic": "3.4.2",
"@tiptap/extension-link": "3.4.2",
"@tiptap/extension-list": "3.4.2",
"@tiptap/extension-paragraph": "^3.4.2",
"@tiptap/extension-strike": "^3.4.2",
"@tiptap/extension-text": "^3.4.2",
"@tiptap/extension-underline": "^3.4.2",
"@tiptap/extensions": "^3.4.2",
"@tiptap/react": "^3.4.2",
"@tiptap/extension-paragraph": "3.4.2",
"@tiptap/extension-strike": "3.4.2",
"@tiptap/extension-text": "3.4.2",
"@tiptap/extension-underline": "3.4.2",
"@tiptap/extensions": "3.4.2",
"@tiptap/react": "3.4.2",
"@types/marked": "^6.0.0",
"@xyflow/react": "^12.4.2",
"ai": "5.0.52",
"apollo-link-rest": "^0.9.0",
@@ -90,6 +91,7 @@
"jwt-decode": "^4.0.0",
"linkify-react": "^4.1.3",
"linkifyjs": "^4.1.3",
"marked": "^17.0.1",
"qs": "^6.11.2",
"react-data-grid": "7.0.0-beta.13",
"react-datepicker": "^6.7.1",
@@ -120,7 +122,7 @@
"@lingui/swc-plugin": "^5.6.0",
"@lingui/vite-plugin": "^5.1.2",
"@playwright/test": "^1.56.1",
"@tiptap/suggestion": "^3.4.2",
"@tiptap/suggestion": "3.4.2",
"@types/apollo-upload-client": "^17.0.2",
"@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.3",
@@ -972,6 +972,14 @@ export type CreateServerlessFunctionInput = {
timeoutSeconds?: InputMaybe<Scalars['Float']>;
};
export type CreateSkillInput = {
content: Scalars['String'];
description?: InputMaybe<Scalars['String']>;
icon?: InputMaybe<Scalars['String']>;
label: Scalars['String'];
name: Scalars['String'];
};
export type CreateViewFieldInput = {
aggregateOperation?: InputMaybe<AggregateOperations>;
fieldMetadataId: Scalars['UUID'];
@@ -1807,6 +1815,7 @@ export enum ModelProvider {
export type Mutation = {
__typename?: 'Mutation';
activateSkill: Skill;
activateWorkflowVersion: Scalars['Boolean'];
activateWorkspace: Workspace;
assignRoleToAgent: Scalars['Boolean'];
@@ -1854,9 +1863,11 @@ export type Mutation = {
createRowLevelPermissionPredicate: RowLevelPermissionPredicate;
createRowLevelPermissionPredicateGroup: RowLevelPermissionPredicateGroup;
createSAMLIdentityProvider: SetupSsoOutput;
createSkill: Skill;
createWebhook: Webhook;
createWorkflowVersionEdge: WorkflowVersionStepChanges;
createWorkflowVersionStep: WorkflowVersionStepChanges;
deactivateSkill: Skill;
deactivateWorkflowVersion: Scalars['Boolean'];
deleteApprovedAccessDomain: Scalars['Boolean'];
deleteCoreView: Scalars['Boolean'];
@@ -1886,6 +1897,7 @@ export type Mutation = {
deleteRowLevelPermissionPredicate: RowLevelPermissionPredicate;
deleteRowLevelPermissionPredicateGroup: RowLevelPermissionPredicateGroup;
deleteSSOIdentityProvider: DeleteSsoOutput;
deleteSkill: Skill;
deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput;
deleteUser: User;
deleteUserFromWorkspace: UserWorkspace;
@@ -1981,6 +1993,7 @@ export type Mutation = {
updatePasswordViaResetToken: InvalidatePasswordOutput;
updateRowLevelPermissionPredicate: RowLevelPermissionPredicate;
updateRowLevelPermissionPredicateGroup: RowLevelPermissionPredicateGroup;
updateSkill: Skill;
updateUserEmail: Scalars['Boolean'];
updateWebhook?: Maybe<Webhook>;
updateWorkflowRunStep: WorkflowAction;
@@ -2005,6 +2018,11 @@ export type Mutation = {
};
export type MutationActivateSkillArgs = {
id: Scalars['UUID'];
};
export type MutationActivateWorkflowVersionArgs = {
workflowVersionId: Scalars['UUID'];
};
@@ -2228,6 +2246,11 @@ export type MutationCreateSamlIdentityProviderArgs = {
};
export type MutationCreateSkillArgs = {
input: CreateSkillInput;
};
export type MutationCreateWebhookArgs = {
input: CreateWebhookInput;
};
@@ -2243,6 +2266,11 @@ export type MutationCreateWorkflowVersionStepArgs = {
};
export type MutationDeactivateSkillArgs = {
id: Scalars['UUID'];
};
export type MutationDeactivateWorkflowVersionArgs = {
workflowVersionId: Scalars['UUID'];
};
@@ -2384,6 +2412,11 @@ export type MutationDeleteSsoIdentityProviderArgs = {
};
export type MutationDeleteSkillArgs = {
id: Scalars['UUID'];
};
export type MutationDeleteTwoFactorAuthenticationMethodArgs = {
twoFactorAuthenticationMethodId: Scalars['UUID'];
};
@@ -2854,6 +2887,11 @@ export type MutationUpdateRowLevelPermissionPredicateGroupArgs = {
};
export type MutationUpdateSkillArgs = {
input: UpdateSkillInput;
};
export type MutationUpdateUserEmailArgs = {
newEmail: Scalars['String'];
verifyEmailRedirectPath?: InputMaybe<Scalars['String']>;
@@ -3390,6 +3428,8 @@ export type Query = {
object: Object;
objects: ObjectConnection;
search: SearchResultConnection;
skill?: Maybe<Skill>;
skills: Array<Skill>;
validatePasswordResetToken: ValidatePasswordResetTokenOutput;
versionInfo: VersionInfo;
webhook?: Maybe<Webhook>;
@@ -3732,6 +3772,11 @@ export type QuerySearchArgs = {
};
export type QuerySkillArgs = {
id: Scalars['UUID'];
};
export type QueryValidatePasswordResetTokenArgs = {
passwordResetToken: Scalars['String'];
};
@@ -4159,6 +4204,22 @@ export type SignedFile = {
token: Scalars['String'];
};
export type Skill = {
__typename?: 'Skill';
applicationId?: Maybe<Scalars['UUID']>;
content: Scalars['String'];
createdAt: Scalars['DateTime'];
description?: Maybe<Scalars['String']>;
icon?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
isActive: Scalars['Boolean'];
isCustom: Scalars['Boolean'];
label: Scalars['String'];
name: Scalars['String'];
standardId?: Maybe<Scalars['UUID']>;
updatedAt: Scalars['DateTime'];
};
export type StandaloneRichTextConfiguration = {
__typename?: 'StandaloneRichTextConfiguration';
body: RichTextV2Body;
@@ -4603,6 +4664,16 @@ export type UpdateServerlessFunctionInputUpdates = {
timeoutSeconds?: InputMaybe<Scalars['Float']>;
};
export type UpdateSkillInput = {
content?: InputMaybe<Scalars['String']>;
description?: InputMaybe<Scalars['String']>;
icon?: InputMaybe<Scalars['String']>;
id: Scalars['UUID'];
isActive?: InputMaybe<Scalars['Boolean']>;
label?: InputMaybe<Scalars['String']>;
name?: InputMaybe<Scalars['String']>;
};
export type UpdateViewFieldInput = {
/** The id of the view field to update */
id: Scalars['UUID'];
@@ -5220,6 +5291,15 @@ export type WorkspaceUrlsAndId = {
export type AgentFieldsFragment = { __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string };
export type SkillFieldsFragment = { __typename?: 'Skill', id: string, name: string, label: string, description?: string | null, icon?: string | null, content: string, isCustom: boolean, isActive: boolean, createdAt: string, updatedAt: string };
export type ActivateSkillMutationVariables = Exact<{
id: Scalars['UUID'];
}>;
export type ActivateSkillMutation = { __typename?: 'Mutation', activateSkill: { __typename?: 'Skill', id: string, name: string, label: string, description?: string | null, icon?: string | null, content: string, isCustom: boolean, isActive: boolean, createdAt: string, updatedAt: string } };
export type AssignRoleToAgentMutationVariables = Exact<{
agentId: Scalars['UUID'];
roleId: Scalars['UUID'];
@@ -5240,6 +5320,20 @@ export type CreateOneAgentMutationVariables = Exact<{
export type CreateOneAgentMutation = { __typename?: 'Mutation', createOneAgent: { __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string } };
export type CreateSkillMutationVariables = Exact<{
input: CreateSkillInput;
}>;
export type CreateSkillMutation = { __typename?: 'Mutation', createSkill: { __typename?: 'Skill', id: string, name: string, label: string, description?: string | null, icon?: string | null, content: string, isCustom: boolean, isActive: boolean, createdAt: string, updatedAt: string } };
export type DeactivateSkillMutationVariables = Exact<{
id: Scalars['UUID'];
}>;
export type DeactivateSkillMutation = { __typename?: 'Mutation', deactivateSkill: { __typename?: 'Skill', id: string, name: string, label: string, description?: string | null, icon?: string | null, content: string, isCustom: boolean, isActive: boolean, createdAt: string, updatedAt: string } };
export type DeleteOneAgentMutationVariables = Exact<{
input: AgentIdInput;
}>;
@@ -5247,6 +5341,13 @@ export type DeleteOneAgentMutationVariables = Exact<{
export type DeleteOneAgentMutation = { __typename?: 'Mutation', deleteOneAgent: { __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string } };
export type DeleteSkillMutationVariables = Exact<{
id: Scalars['UUID'];
}>;
export type DeleteSkillMutation = { __typename?: 'Mutation', deleteSkill: { __typename?: 'Skill', id: string, name: string, label: string, description?: string | null, icon?: string | null, content: string, isCustom: boolean, isActive: boolean, createdAt: string, updatedAt: string } };
export type EvaluateAgentTurnMutationVariables = Exact<{
turnId: Scalars['UUID'];
}>;
@@ -5276,11 +5377,23 @@ export type UpdateOneAgentMutationVariables = Exact<{
export type UpdateOneAgentMutation = { __typename?: 'Mutation', updateOneAgent: { __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string } };
export type UpdateSkillMutationVariables = Exact<{
input: UpdateSkillInput;
}>;
export type UpdateSkillMutation = { __typename?: 'Mutation', updateSkill: { __typename?: 'Skill', id: string, name: string, label: string, description?: string | null, icon?: string | null, content: string, isCustom: boolean, isActive: boolean, createdAt: string, updatedAt: string } };
export type FindManyAgentsQueryVariables = Exact<{ [key: string]: never; }>;
export type FindManyAgentsQuery = { __typename?: 'Query', findManyAgents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string }> };
export type FindManySkillsQueryVariables = Exact<{ [key: string]: never; }>;
export type FindManySkillsQuery = { __typename?: 'Query', skills: Array<{ __typename?: 'Skill', id: string, name: string, label: string, description?: string | null, icon?: string | null, content: string, isCustom: boolean, isActive: boolean, createdAt: string, updatedAt: string }> };
export type FindOneAgentQueryVariables = Exact<{
id: Scalars['UUID'];
}>;
@@ -5288,6 +5401,13 @@ export type FindOneAgentQueryVariables = Exact<{
export type FindOneAgentQuery = { __typename?: 'Query', findOneAgent: { __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string } };
export type FindOneSkillQueryVariables = Exact<{
id: Scalars['UUID'];
}>;
export type FindOneSkillQuery = { __typename?: 'Query', skill?: { __typename?: 'Skill', id: string, name: string, label: string, description?: string | null, icon?: string | null, content: string, isCustom: boolean, isActive: boolean, createdAt: string, updatedAt: string } | null };
export type GetAgentTurnsQueryVariables = Exact<{
agentId: Scalars['UUID'];
}>;
@@ -6758,6 +6878,20 @@ export type GetWorkspaceFromInviteHashQueryVariables = Exact<{
export type GetWorkspaceFromInviteHashQuery = { __typename?: 'Query', findWorkspaceFromInviteHash: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, allowImpersonation: boolean } };
export const SkillFieldsFragmentDoc = gql`
fragment SkillFields on Skill {
id
name
label
description
icon
content
isCustom
isActive
createdAt
updatedAt
}
`;
export const AgentFieldsFragmentDoc = gql`
fragment AgentFields on Agent {
id
@@ -7417,6 +7551,39 @@ export const WorkflowDiffFragmentFragmentDoc = gql`
stepsDiff
}
`;
export const ActivateSkillDocument = gql`
mutation ActivateSkill($id: UUID!) {
activateSkill(id: $id) {
...SkillFields
}
}
${SkillFieldsFragmentDoc}`;
export type ActivateSkillMutationFn = Apollo.MutationFunction<ActivateSkillMutation, ActivateSkillMutationVariables>;
/**
* __useActivateSkillMutation__
*
* To run a mutation, you first call `useActivateSkillMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useActivateSkillMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [activateSkillMutation, { data, loading, error }] = useActivateSkillMutation({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useActivateSkillMutation(baseOptions?: Apollo.MutationHookOptions<ActivateSkillMutation, ActivateSkillMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<ActivateSkillMutation, ActivateSkillMutationVariables>(ActivateSkillDocument, options);
}
export type ActivateSkillMutationHookResult = ReturnType<typeof useActivateSkillMutation>;
export type ActivateSkillMutationResult = Apollo.MutationResult<ActivateSkillMutation>;
export type ActivateSkillMutationOptions = Apollo.BaseMutationOptions<ActivateSkillMutation, ActivateSkillMutationVariables>;
export const AssignRoleToAgentDocument = gql`
mutation AssignRoleToAgent($agentId: UUID!, $roleId: UUID!) {
assignRoleToAgent(agentId: $agentId, roleId: $roleId)
@@ -7517,6 +7684,72 @@ export function useCreateOneAgentMutation(baseOptions?: Apollo.MutationHookOptio
export type CreateOneAgentMutationHookResult = ReturnType<typeof useCreateOneAgentMutation>;
export type CreateOneAgentMutationResult = Apollo.MutationResult<CreateOneAgentMutation>;
export type CreateOneAgentMutationOptions = Apollo.BaseMutationOptions<CreateOneAgentMutation, CreateOneAgentMutationVariables>;
export const CreateSkillDocument = gql`
mutation CreateSkill($input: CreateSkillInput!) {
createSkill(input: $input) {
...SkillFields
}
}
${SkillFieldsFragmentDoc}`;
export type CreateSkillMutationFn = Apollo.MutationFunction<CreateSkillMutation, CreateSkillMutationVariables>;
/**
* __useCreateSkillMutation__
*
* To run a mutation, you first call `useCreateSkillMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateSkillMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [createSkillMutation, { data, loading, error }] = useCreateSkillMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useCreateSkillMutation(baseOptions?: Apollo.MutationHookOptions<CreateSkillMutation, CreateSkillMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateSkillMutation, CreateSkillMutationVariables>(CreateSkillDocument, options);
}
export type CreateSkillMutationHookResult = ReturnType<typeof useCreateSkillMutation>;
export type CreateSkillMutationResult = Apollo.MutationResult<CreateSkillMutation>;
export type CreateSkillMutationOptions = Apollo.BaseMutationOptions<CreateSkillMutation, CreateSkillMutationVariables>;
export const DeactivateSkillDocument = gql`
mutation DeactivateSkill($id: UUID!) {
deactivateSkill(id: $id) {
...SkillFields
}
}
${SkillFieldsFragmentDoc}`;
export type DeactivateSkillMutationFn = Apollo.MutationFunction<DeactivateSkillMutation, DeactivateSkillMutationVariables>;
/**
* __useDeactivateSkillMutation__
*
* To run a mutation, you first call `useDeactivateSkillMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeactivateSkillMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [deactivateSkillMutation, { data, loading, error }] = useDeactivateSkillMutation({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useDeactivateSkillMutation(baseOptions?: Apollo.MutationHookOptions<DeactivateSkillMutation, DeactivateSkillMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeactivateSkillMutation, DeactivateSkillMutationVariables>(DeactivateSkillDocument, options);
}
export type DeactivateSkillMutationHookResult = ReturnType<typeof useDeactivateSkillMutation>;
export type DeactivateSkillMutationResult = Apollo.MutationResult<DeactivateSkillMutation>;
export type DeactivateSkillMutationOptions = Apollo.BaseMutationOptions<DeactivateSkillMutation, DeactivateSkillMutationVariables>;
export const DeleteOneAgentDocument = gql`
mutation DeleteOneAgent($input: AgentIdInput!) {
deleteOneAgent(input: $input) {
@@ -7550,6 +7783,39 @@ export function useDeleteOneAgentMutation(baseOptions?: Apollo.MutationHookOptio
export type DeleteOneAgentMutationHookResult = ReturnType<typeof useDeleteOneAgentMutation>;
export type DeleteOneAgentMutationResult = Apollo.MutationResult<DeleteOneAgentMutation>;
export type DeleteOneAgentMutationOptions = Apollo.BaseMutationOptions<DeleteOneAgentMutation, DeleteOneAgentMutationVariables>;
export const DeleteSkillDocument = gql`
mutation DeleteSkill($id: UUID!) {
deleteSkill(id: $id) {
...SkillFields
}
}
${SkillFieldsFragmentDoc}`;
export type DeleteSkillMutationFn = Apollo.MutationFunction<DeleteSkillMutation, DeleteSkillMutationVariables>;
/**
* __useDeleteSkillMutation__
*
* To run a mutation, you first call `useDeleteSkillMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteSkillMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [deleteSkillMutation, { data, loading, error }] = useDeleteSkillMutation({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useDeleteSkillMutation(baseOptions?: Apollo.MutationHookOptions<DeleteSkillMutation, DeleteSkillMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeleteSkillMutation, DeleteSkillMutationVariables>(DeleteSkillDocument, options);
}
export type DeleteSkillMutationHookResult = ReturnType<typeof useDeleteSkillMutation>;
export type DeleteSkillMutationResult = Apollo.MutationResult<DeleteSkillMutation>;
export type DeleteSkillMutationOptions = Apollo.BaseMutationOptions<DeleteSkillMutation, DeleteSkillMutationVariables>;
export const EvaluateAgentTurnDocument = gql`
mutation EvaluateAgentTurn($turnId: UUID!) {
evaluateAgentTurn(turnId: $turnId) {
@@ -7694,6 +7960,39 @@ export function useUpdateOneAgentMutation(baseOptions?: Apollo.MutationHookOptio
export type UpdateOneAgentMutationHookResult = ReturnType<typeof useUpdateOneAgentMutation>;
export type UpdateOneAgentMutationResult = Apollo.MutationResult<UpdateOneAgentMutation>;
export type UpdateOneAgentMutationOptions = Apollo.BaseMutationOptions<UpdateOneAgentMutation, UpdateOneAgentMutationVariables>;
export const UpdateSkillDocument = gql`
mutation UpdateSkill($input: UpdateSkillInput!) {
updateSkill(input: $input) {
...SkillFields
}
}
${SkillFieldsFragmentDoc}`;
export type UpdateSkillMutationFn = Apollo.MutationFunction<UpdateSkillMutation, UpdateSkillMutationVariables>;
/**
* __useUpdateSkillMutation__
*
* To run a mutation, you first call `useUpdateSkillMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateSkillMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [updateSkillMutation, { data, loading, error }] = useUpdateSkillMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useUpdateSkillMutation(baseOptions?: Apollo.MutationHookOptions<UpdateSkillMutation, UpdateSkillMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpdateSkillMutation, UpdateSkillMutationVariables>(UpdateSkillDocument, options);
}
export type UpdateSkillMutationHookResult = ReturnType<typeof useUpdateSkillMutation>;
export type UpdateSkillMutationResult = Apollo.MutationResult<UpdateSkillMutation>;
export type UpdateSkillMutationOptions = Apollo.BaseMutationOptions<UpdateSkillMutation, UpdateSkillMutationVariables>;
export const FindManyAgentsDocument = gql`
query FindManyAgents {
findManyAgents {
@@ -7728,6 +8027,40 @@ export function useFindManyAgentsLazyQuery(baseOptions?: Apollo.LazyQueryHookOpt
export type FindManyAgentsQueryHookResult = ReturnType<typeof useFindManyAgentsQuery>;
export type FindManyAgentsLazyQueryHookResult = ReturnType<typeof useFindManyAgentsLazyQuery>;
export type FindManyAgentsQueryResult = Apollo.QueryResult<FindManyAgentsQuery, FindManyAgentsQueryVariables>;
export const FindManySkillsDocument = gql`
query FindManySkills {
skills {
...SkillFields
}
}
${SkillFieldsFragmentDoc}`;
/**
* __useFindManySkillsQuery__
*
* To run a query within a React component, call `useFindManySkillsQuery` and pass it any options that fit your needs.
* When your component renders, `useFindManySkillsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useFindManySkillsQuery({
* variables: {
* },
* });
*/
export function useFindManySkillsQuery(baseOptions?: Apollo.QueryHookOptions<FindManySkillsQuery, FindManySkillsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<FindManySkillsQuery, FindManySkillsQueryVariables>(FindManySkillsDocument, options);
}
export function useFindManySkillsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<FindManySkillsQuery, FindManySkillsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<FindManySkillsQuery, FindManySkillsQueryVariables>(FindManySkillsDocument, options);
}
export type FindManySkillsQueryHookResult = ReturnType<typeof useFindManySkillsQuery>;
export type FindManySkillsLazyQueryHookResult = ReturnType<typeof useFindManySkillsLazyQuery>;
export type FindManySkillsQueryResult = Apollo.QueryResult<FindManySkillsQuery, FindManySkillsQueryVariables>;
export const FindOneAgentDocument = gql`
query FindOneAgent($id: UUID!) {
findOneAgent(input: {id: $id}) {
@@ -7763,6 +8096,41 @@ export function useFindOneAgentLazyQuery(baseOptions?: Apollo.LazyQueryHookOptio
export type FindOneAgentQueryHookResult = ReturnType<typeof useFindOneAgentQuery>;
export type FindOneAgentLazyQueryHookResult = ReturnType<typeof useFindOneAgentLazyQuery>;
export type FindOneAgentQueryResult = Apollo.QueryResult<FindOneAgentQuery, FindOneAgentQueryVariables>;
export const FindOneSkillDocument = gql`
query FindOneSkill($id: UUID!) {
skill(id: $id) {
...SkillFields
}
}
${SkillFieldsFragmentDoc}`;
/**
* __useFindOneSkillQuery__
*
* To run a query within a React component, call `useFindOneSkillQuery` and pass it any options that fit your needs.
* When your component renders, `useFindOneSkillQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useFindOneSkillQuery({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useFindOneSkillQuery(baseOptions: Apollo.QueryHookOptions<FindOneSkillQuery, FindOneSkillQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<FindOneSkillQuery, FindOneSkillQueryVariables>(FindOneSkillDocument, options);
}
export function useFindOneSkillLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<FindOneSkillQuery, FindOneSkillQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<FindOneSkillQuery, FindOneSkillQueryVariables>(FindOneSkillDocument, options);
}
export type FindOneSkillQueryHookResult = ReturnType<typeof useFindOneSkillQuery>;
export type FindOneSkillLazyQueryHookResult = ReturnType<typeof useFindOneSkillLazyQuery>;
export type FindOneSkillQueryResult = Apollo.QueryResult<FindOneSkillQuery, FindOneSkillQueryVariables>;
export const GetAgentTurnsDocument = gql`
query GetAgentTurns($agentId: UUID!) {
agentTurns(agentId: $agentId) {
@@ -3996,6 +3996,22 @@ export type SignedFile = {
token: Scalars['String'];
};
export type Skill = {
__typename?: 'Skill';
applicationId?: Maybe<Scalars['UUID']>;
content: Scalars['String'];
createdAt: Scalars['DateTime'];
description?: Maybe<Scalars['String']>;
icon?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
isActive: Scalars['Boolean'];
isCustom: Scalars['Boolean'];
label: Scalars['String'];
name: Scalars['String'];
standardId?: Maybe<Scalars['UUID']>;
updatedAt: Scalars['DateTime'];
};
export type StandaloneRichTextConfiguration = {
__typename?: 'StandaloneRichTextConfiguration';
body: RichTextV2Body;
@@ -17,9 +17,12 @@ import { Text } from '@tiptap/extension-text';
import { Underline } from '@tiptap/extension-underline';
import { Dropcursor, Placeholder, UndoRedo } from '@tiptap/extensions';
import { type Editor, useEditor } from '@tiptap/react';
import { marked } from 'marked';
import { type DependencyList, useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
export type AdvancedTextEditorContentType = 'json' | 'markdown';
type UseAdvancedTextEditorProps = {
placeholder: string | undefined;
readonly: boolean | undefined;
@@ -30,6 +33,7 @@ type UseAdvancedTextEditorProps = {
onImageUpload?: (file: File) => Promise<string>;
onImageUploadError?: (error: Error, file: File) => void;
enableSlashCommand?: boolean;
contentType?: AdvancedTextEditorContentType;
};
export const useAdvancedTextEditor = (
@@ -43,9 +47,12 @@ export const useAdvancedTextEditor = (
onImageUpload,
onImageUploadError,
enableSlashCommand,
contentType = 'json',
}: UseAdvancedTextEditorProps,
dependencies?: DependencyList,
) => {
const isMarkdownMode = contentType === 'markdown';
const extensions = useMemo(
() => [
Document,
@@ -87,12 +94,23 @@ export const useAdvancedTextEditor = (
],
);
const getEditorContent = () => {
if (!isDefined(defaultValue)) {
return undefined;
}
if (isMarkdownMode) {
// Convert markdown to HTML, then TipTap will parse the HTML
return marked.parse(defaultValue, { async: false }) as string;
}
return getInitialAdvancedTextEditorContent(defaultValue);
};
const editor = useEditor(
{
extensions,
content: isDefined(defaultValue)
? getInitialAdvancedTextEditorContent(defaultValue)
: undefined,
content: getEditorContent(),
editable: !readonly,
onUpdate: ({ editor }) => {
onUpdate(editor);
@@ -0,0 +1,16 @@
import { gql } from '@apollo/client';
export const SKILL_FRAGMENT = gql`
fragment SkillFields on Skill {
id
name
label
description
icon
content
isCustom
isActive
createdAt
updatedAt
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
import { SKILL_FRAGMENT } from '@/ai/graphql/fragments/skillFragment';
export const ACTIVATE_SKILL = gql`
${SKILL_FRAGMENT}
mutation ActivateSkill($id: UUID!) {
activateSkill(id: $id) {
...SkillFields
}
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
import { SKILL_FRAGMENT } from '@/ai/graphql/fragments/skillFragment';
export const CREATE_SKILL = gql`
${SKILL_FRAGMENT}
mutation CreateSkill($input: CreateSkillInput!) {
createSkill(input: $input) {
...SkillFields
}
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
import { SKILL_FRAGMENT } from '@/ai/graphql/fragments/skillFragment';
export const DEACTIVATE_SKILL = gql`
${SKILL_FRAGMENT}
mutation DeactivateSkill($id: UUID!) {
deactivateSkill(id: $id) {
...SkillFields
}
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
import { SKILL_FRAGMENT } from '@/ai/graphql/fragments/skillFragment';
export const DELETE_SKILL = gql`
${SKILL_FRAGMENT}
mutation DeleteSkill($id: UUID!) {
deleteSkill(id: $id) {
...SkillFields
}
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
import { SKILL_FRAGMENT } from '@/ai/graphql/fragments/skillFragment';
export const UPDATE_SKILL = gql`
${SKILL_FRAGMENT}
mutation UpdateSkill($input: UpdateSkillInput!) {
updateSkill(input: $input) {
...SkillFields
}
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
import { SKILL_FRAGMENT } from '@/ai/graphql/fragments/skillFragment';
export const FIND_MANY_SKILLS = gql`
${SKILL_FRAGMENT}
query FindManySkills {
skills {
...SkillFields
}
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
import { SKILL_FRAGMENT } from '@/ai/graphql/fragments/skillFragment';
export const FIND_ONE_SKILL = gql`
${SKILL_FRAGMENT}
query FindOneSkill($id: UUID!) {
skill(id: $id) {
...SkillFields
}
}
`;
@@ -171,6 +171,12 @@ const SettingsAgentTurnDetail = lazy(() =>
})),
);
const SettingsSkillForm = lazy(() =>
import('~/pages/settings/ai/SettingsSkillForm').then((module) => ({
default: module.SettingsSkillForm,
})),
);
const SettingsWorkspaceMembers = lazy(() =>
import('~/pages/settings/members/SettingsWorkspaceMembers').then(
(module) => ({
@@ -440,6 +446,14 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.AIAgentTurnDetail}
element={<SettingsAgentTurnDetail />}
/>
<Route
path={SettingsPath.AINewSkill}
element={<SettingsSkillForm mode="create" />}
/>
<Route
path={SettingsPath.AISkillDetail}
element={<SettingsSkillForm mode="edit" />}
/>
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
<Route path={SettingsPath.Domain} element={<SettingsDomain />} />
<Route
@@ -26,6 +26,7 @@ export const useMetadataErrorHandler = () => {
role: t`role`,
roleTarget: t`role target`,
agent: t`agent`,
skill: t`skill`,
pageLayout: t`page layout`,
pageLayoutTab: t`page layout tab`,
pageLayoutWidget: t`page layout widget`,
@@ -1,5 +1,8 @@
import { AdvancedTextEditor } from '@/advanced-text-editor/components/AdvancedTextEditor';
import { useAdvancedTextEditor } from '@/advanced-text-editor/hooks/useAdvancedTextEditor';
import {
type AdvancedTextEditorContentType,
useAdvancedTextEditor,
} from '@/advanced-text-editor/hooks/useAdvancedTextEditor';
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper';
@@ -86,6 +89,7 @@ type FormAdvancedTextFieldInputProps = {
fullScreenBreadcrumbs?: BreadcrumbProps['links'];
minHeight: number;
maxWidth: number;
contentType?: AdvancedTextEditorContentType;
};
export const FormAdvancedTextFieldInput = ({
@@ -103,6 +107,7 @@ export const FormAdvancedTextFieldInput = ({
fullScreenBreadcrumbs,
minHeight,
maxWidth,
contentType = 'json',
}: FormAdvancedTextFieldInputProps) => {
const instanceId = useId();
const isMobile = useIsMobile();
@@ -119,9 +124,15 @@ export const FormAdvancedTextFieldInput = ({
placeholder: placeholder,
readonly,
defaultValue,
contentType,
onUpdate: (editor) => {
const jsonContent = editor.getJSON();
onChange(JSON.stringify(jsonContent));
if (contentType === 'markdown') {
// For markdown mode, output the HTML which preserves formatting
onChange(editor.getHTML());
} else {
const jsonContent = editor.getJSON();
onChange(JSON.stringify(jsonContent));
}
},
onFocus: () => {
pushFocusItemToFocusStack({
@@ -1,82 +1,17 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
IconPlus,
IconRobot,
IconServer,
IconSettings,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { t } from '@lingui/core/macro';
import { SettingsAIAgentsTable } from './components/SettingsAIAgentsTable';
import { SettingsAIMCP } from './components/SettingsAIMCP';
import { SettingsAIRouterSettings } from './components/SettingsAIRouterSettings';
const SETTINGS_AI_TABS_ID = 'settings-ai-tabs-id';
const SETTINGS_AI_TABS = {
AGENTS: 'agents',
SETTINGS: 'settings',
MCP: 'mcp',
};
import { SettingsSkillsTable } from './components/SettingsSkillsTable';
export const SettingsAI = () => {
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
SETTINGS_AI_TABS_ID,
);
const tabs = [
{
id: SETTINGS_AI_TABS.AGENTS,
title: t`Agents`,
Icon: IconRobot,
},
{
id: SETTINGS_AI_TABS.MCP,
title: t`MCP`,
Icon: IconServer,
},
{
id: SETTINGS_AI_TABS.SETTINGS,
title: t`Settings`,
Icon: IconSettings,
},
];
const renderActiveTabContent = () => {
switch (activeTabId) {
case SETTINGS_AI_TABS.AGENTS:
return <SettingsAIAgentsTable />;
case SETTINGS_AI_TABS.SETTINGS:
return <SettingsAIRouterSettings />;
case SETTINGS_AI_TABS.MCP:
return <SettingsAIMCP />;
}
};
return (
<SubMenuTopBarContainer
title={t`AI`}
actionButton={
activeTabId === SETTINGS_AI_TABS.AGENTS ? (
<UndecoratedLink to={getSettingsPath(SettingsPath.AINewAgent)}>
<Button
Icon={IconPlus}
title={t`New Agent`}
accent="blue"
size="small"
/>
</UndecoratedLink>
) : undefined
}
links={[
{
children: t`Workspace`,
@@ -86,8 +21,9 @@ export const SettingsAI = () => {
]}
>
<SettingsPageContainer>
<TabList tabs={tabs} componentInstanceId={SETTINGS_AI_TABS_ID} />
{renderActiveTabContent()}
<SettingsAIRouterSettings />
<SettingsSkillsTable />
<SettingsAIMCP />
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
@@ -0,0 +1,600 @@
import { ApolloError } from '@apollo/client';
import styled from '@emotion/styled';
import { useParams } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { TextArea } from '@/ui/input/components/TextArea';
import { TitleInput } from '@/ui/input/components/TitleInput';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
AppTooltip,
H2Title,
IconArchive,
IconArchiveOff,
IconInfoCircle,
IconRefresh,
IconTrash,
TooltipDelay,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import {
useActivateSkillMutation,
useCreateSkillMutation,
useDeactivateSkillMutation,
useDeleteSkillMutation,
useFindOneSkillQuery,
useUpdateSkillMutation,
type FindOneSkillQuery,
} from '~/generated-metadata/graphql';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/computeMetadataNameFromLabel';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useEffect, useState } from 'react';
import Skeleton from 'react-loading-skeleton';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
const StyledFormContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
`;
const StyledIconNameRow = styled.div`
align-items: flex-start;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledNameContainer = styled.div`
flex: 1;
`;
const StyledAdvancedSettingsOuterContainer = styled.div`
padding-top: ${({ theme }) => theme.spacing(4)};
`;
const StyledAdvancedSettingsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
width: 100%;
`;
const StyledHeaderTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.lg};
width: fit-content;
max-width: 420px;
& > input:disabled {
color: ${({ theme }) => theme.font.color.primary};
}
`;
const StyledDangerButtonsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
type SkillFormValues = {
name: string;
label: string;
description: string;
content: string;
icon: string;
isLabelSyncedWithName: boolean;
};
const DELETE_SKILL_MODAL_ID = 'delete-skill-modal';
export const SettingsSkillForm = ({ mode }: { mode: 'create' | 'edit' }) => {
const { skillId = '' } = useParams<{ skillId: string }>();
const navigate = useNavigateSettings();
const navigateApp = useNavigateApp();
const { enqueueErrorSnackBar } = useSnackBar();
const theme = useTheme();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isReadonlyMode, setIsReadonlyMode] = useState(false);
const [originalFormValues, setOriginalFormValues] =
useState<SkillFormValues | null>(null);
const { openModal, closeModal } = useModal();
const isEditMode = mode === 'edit';
const isCreateMode = mode === 'create';
const [formValues, setFormValues] = useState<SkillFormValues>({
name: '',
label: '',
description: '',
content: '',
icon: 'IconSparkles',
isLabelSyncedWithName: true,
});
const { data, loading } = useFindOneSkillQuery({
variables: { id: skillId },
skip: isCreateMode || !skillId,
onCompleted: (data: FindOneSkillQuery) => {
const skill = data?.skill;
if (isDefined(skill)) {
if (!skill.isCustom) {
setIsReadonlyMode(true);
}
const computedNameFromLabel = computeMetadataNameFromLabel(skill.label);
const isLabelSyncedWithName = skill.name === computedNameFromLabel;
const initialValues: SkillFormValues = {
name: skill.name,
label: skill.label,
description: skill.description ?? '',
content: skill.content,
icon: skill.icon ?? 'IconSparkles',
isLabelSyncedWithName,
};
setFormValues(initialValues);
setOriginalFormValues(initialValues);
} else {
enqueueErrorSnackBar({
message: t`Skill not found`,
});
navigateApp(AppPath.NotFound);
}
},
onError: (error: ApolloError) => {
enqueueErrorSnackBar({
apolloError: error,
});
navigateApp(AppPath.NotFound);
},
});
const [createSkill] = useCreateSkillMutation();
const [updateSkill] = useUpdateSkillMutation();
const [deleteSkill] = useDeleteSkillMutation();
const [activateSkill] = useActivateSkillMutation();
const [deactivateSkill] = useDeactivateSkillMutation();
const skill = data?.skill;
const handleFieldChange = <K extends keyof SkillFormValues>(
fieldName: K,
value: SkillFormValues[K],
) => {
setFormValues((prev) => {
const newValues = { ...prev, [fieldName]: value };
if (fieldName === 'label' && prev.isLabelSyncedWithName) {
newValues.name = computeMetadataNameFromLabel(value as string);
}
if (fieldName === 'isLabelSyncedWithName' && value === true) {
newValues.name = computeMetadataNameFromLabel(prev.label);
}
return newValues;
});
};
const validateForm = (): boolean => {
return (
formValues.name.trim().length > 0 &&
formValues.label.trim().length > 0 &&
formValues.content.trim().length > 0
);
};
const autoSave = useDebouncedCallback(async () => {
if (
isCreateMode ||
isReadonlyMode ||
!validateForm() ||
isSubmitting ||
!skill
) {
return;
}
const hasChanges =
originalFormValues && !isDeeplyEqual(formValues, originalFormValues);
if (!hasChanges) {
return;
}
setIsSubmitting(true);
try {
await updateSkill({
variables: {
input: {
id: skill.id,
name: formValues.name,
label: formValues.label,
description: formValues.description || undefined,
content: formValues.content,
icon: formValues.icon || undefined,
},
},
});
setOriginalFormValues({ ...formValues });
} catch (error) {
enqueueErrorSnackBar({
apolloError: error instanceof ApolloError ? error : undefined,
});
} finally {
setIsSubmitting(false);
}
}, 1_000);
useEffect(() => {
if (isEditMode && !loading && isDefined(originalFormValues)) {
autoSave();
}
}, [formValues, isEditMode, loading, originalFormValues, autoSave]);
useEffect(() => {
return () => {
autoSave.flush();
};
}, [autoSave]);
if (!isCreateMode && !loading && !skill) {
return null;
}
const canSave = !isReadonlyMode && validateForm() && !isSubmitting;
const handleSave = async () => {
if (isReadonlyMode || !validateForm()) {
return;
}
setIsSubmitting(true);
try {
if (isCreateMode) {
await createSkill({
variables: {
input: {
name: formValues.name,
label: formValues.label,
description: formValues.description || undefined,
content: formValues.content,
icon: formValues.icon || undefined,
},
},
});
navigate(SettingsPath.AI);
return;
}
if (!skill) {
return;
}
await updateSkill({
variables: {
input: {
id: skill.id,
name: formValues.name,
label: formValues.label,
description: formValues.description || undefined,
content: formValues.content,
icon: formValues.icon || undefined,
},
},
});
navigate(SettingsPath.AI);
} catch (error) {
enqueueErrorSnackBar({
apolloError: error instanceof ApolloError ? error : undefined,
});
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async () => {
if (!skill) return;
setIsSubmitting(true);
try {
await deleteSkill({
variables: { id: skill.id },
});
closeModal(DELETE_SKILL_MODAL_ID);
navigate(SettingsPath.AI);
} catch (error) {
enqueueErrorSnackBar({
apolloError: error instanceof ApolloError ? error : undefined,
});
} finally {
setIsSubmitting(false);
}
};
const handleDeactivate = async () => {
if (!skill) return;
setIsSubmitting(true);
try {
await deactivateSkill({
variables: { id: skill.id },
});
navigate(SettingsPath.AI);
} catch (error) {
enqueueErrorSnackBar({
apolloError: error instanceof ApolloError ? error : undefined,
});
} finally {
setIsSubmitting(false);
}
};
const handleActivate = async () => {
if (!skill) return;
setIsSubmitting(true);
try {
await activateSkill({
variables: { id: skill.id },
});
navigate(SettingsPath.AI);
} catch (error) {
enqueueErrorSnackBar({
apolloError: error instanceof ApolloError ? error : undefined,
});
} finally {
setIsSubmitting(false);
}
};
const handleCancel = () => {
setFormValues({
name: '',
label: '',
description: '',
content: '',
icon: 'IconSparkles',
isLabelSyncedWithName: true,
});
navigate(SettingsPath.AI);
};
const breadcrumbText = !isCreateMode
? loading
? t`Skill`
: skill?.label
: t`New Skill`;
const isNameEditEnabled =
!isReadonlyMode && !formValues.isLabelSyncedWithName;
const apiNameTooltipText = formValues.isLabelSyncedWithName
? t`Deactivate "Synchronize Label and API Name" to set a custom API name`
: t`Input must be in camel case and cannot start with a number`;
const renderTitle = () => {
if (isCreateMode) {
return t`New Skill`;
}
if (loading === true) {
return t`Skill`;
}
return (
<StyledHeaderTitle>
<TitleInput
instanceId="skill-label-input"
disabled={isReadonlyMode}
sizeVariant="md"
value={formValues.label}
onChange={(value) => handleFieldChange('label', value)}
placeholder={t`Skill name`}
/>
</StyledHeaderTitle>
);
};
return (
<SubMenuTopBarContainer
title={renderTitle()}
actionButton={
isCreateMode ? (
<SaveAndCancelButtons
onSave={handleSave}
onCancel={handleCancel}
isSaveDisabled={!canSave}
isLoading={isSubmitting}
isCancelDisabled={isSubmitting}
/>
) : undefined
}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: t`AI`, href: getSettingsPath(SettingsPath.AI) },
{ children: breadcrumbText },
]}
>
<SettingsPageContainer>
{isEditMode && loading ? (
<Section>
<Skeleton height={400} borderRadius={4} />
</Section>
) : (
<>
<Section>
<H2Title
title={t`About`}
description={t`Define the name and instructions for this skill`}
/>
<StyledFormContainer>
<StyledIconNameRow>
<IconPicker
selectedIconKey={formValues.icon || 'IconSparkles'}
onChange={({ iconKey }) =>
handleFieldChange('icon', iconKey)
}
disabled={isReadonlyMode}
/>
<StyledNameContainer>
<SettingsTextInput
instanceId="skill-label-input"
placeholder={t`Skill name`}
value={formValues.label}
onChange={(value) => handleFieldChange('label', value)}
disabled={isReadonlyMode}
fullWidth
/>
</StyledNameContainer>
</StyledIconNameRow>
<TextArea
textAreaId="skill-description-textarea"
placeholder={t`Write a description`}
minRows={3}
value={formValues.description}
onChange={(value) =>
handleFieldChange('description', value ?? '')
}
disabled={isReadonlyMode}
/>
<FormAdvancedTextFieldInput
key={originalFormValues?.content ?? 'loading'}
label={t`Instructions`}
readonly={isReadonlyMode}
defaultValue={formValues.content}
contentType="markdown"
onChange={(content: string) =>
handleFieldChange('content', content)
}
enableFullScreen={true}
fullScreenBreadcrumbs={[
{
children: formValues.label || t`Skill`,
href: '#',
},
{
children: t`Instructions Editor`,
},
]}
minHeight={300}
maxWidth={700}
/>
<AdvancedSettingsWrapper hideDot>
<StyledAdvancedSettingsOuterContainer>
<StyledAdvancedSettingsContainer>
<SettingsTextInput
instanceId="skill-api-name"
label={t`API Name`}
placeholder={t`mySkill`}
value={formValues.name}
onChange={(value) => handleFieldChange('name', value)}
disabled={!isNameEditEnabled}
fullWidth
RightIcon={() =>
apiNameTooltipText && (
<>
<IconInfoCircle
id="info-circle-id-skill-name"
size={theme.icon.size.md}
color={theme.font.color.tertiary}
style={{ outline: 'none' }}
/>
<AppTooltip
anchorSelect="#info-circle-id-skill-name"
content={apiNameTooltipText}
offset={5}
noArrow
place="bottom"
positionStrategy="fixed"
delay={TooltipDelay.shortDelay}
/>
</>
)
}
/>
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconRefresh}
title={t`Synchronize Label and API Name`}
description={t`Should changing the label also change the API name?`}
checked={formValues.isLabelSyncedWithName}
disabled={isReadonlyMode}
advancedMode
onChange={(value) =>
handleFieldChange('isLabelSyncedWithName', value)
}
/>
</Card>
</StyledAdvancedSettingsContainer>
</StyledAdvancedSettingsOuterContainer>
</AdvancedSettingsWrapper>
</StyledFormContainer>
</Section>
{skill && (
<Section>
<H2Title
title={t`Danger zone`}
description={t`Deactivate or delete this skill`}
/>
<StyledDangerButtonsContainer>
<Button
Icon={skill.isActive ? IconArchive : IconArchiveOff}
title={skill.isActive ? t`Deactivate` : t`Activate`}
size="small"
onClick={skill.isActive ? handleDeactivate : handleActivate}
/>
{skill.isCustom && (
<Button
Icon={IconTrash}
title={t`Delete`}
size="small"
accent="danger"
variant="secondary"
onClick={() => openModal(DELETE_SKILL_MODAL_ID)}
/>
)}
</StyledDangerButtonsContainer>
</Section>
)}
</>
)}
</SettingsPageContainer>
<ConfirmationModal
modalId={DELETE_SKILL_MODAL_ID}
title={t`Delete Skill`}
subtitle={t`Are you sure you want to delete this skill? This action cannot be undone.`}
onConfirmClick={handleDelete}
confirmButtonText={t`Delete`}
loading={isSubmitting}
/>
</SubMenuTopBarContainer>
);
};
@@ -1,5 +1,4 @@
import { Select } from '@/ui/input/components/Select';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
@@ -20,12 +19,13 @@ const StyledWrapper = styled.div`
border-radius: ${({ theme }) => theme.border.radius.md};
`;
const StyledImage = styled.img`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
height: 100%;
object-fit: cover;
width: 100%;
`;
// TODO: Re-enable when MCP image is ready
// const StyledImage = styled.img`
// border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
// height: 100%;
// object-fit: cover;
// width: 100%;
// `;
const StyledSchemaSelector = styled.div`
align-items: center;
@@ -62,7 +62,6 @@ const StyledEditorContainer = styled.div`
`;
export const SettingsAIMCP = () => {
const theme = useTheme();
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
@@ -117,9 +116,6 @@ export const SettingsAIMCP = () => {
description={t`Access your workspace data from your favorite MCP client like Claude Desktop, Windsurf or Cursor.`}
/>
<StyledWrapper>
<StyledImage
src={`/images/integrations/integration-mcp-cover-${theme.name}.svg`}
/>
<StyledSchemaSelector>
<Select
dropdownId="mcp-schema-selector"
@@ -0,0 +1,69 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { t } from '@lingui/core/macro';
import { IconArchiveOff, IconDotsVertical, IconTrash } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
type SettingsSkillInactiveMenuDropDownProps = {
isCustomSkill: boolean;
onActivate: () => void;
onDelete: () => void;
skillId: string;
};
export const SettingsSkillInactiveMenuDropDown = ({
onActivate,
skillId,
onDelete,
isCustomSkill,
}: SettingsSkillInactiveMenuDropDownProps) => {
const dropdownId = `${skillId}-settings-skill-inactive-menu-dropdown`;
const { closeDropdown } = useCloseDropdown();
const handleActivate = () => {
onActivate();
closeDropdown(dropdownId);
};
const handleDelete = () => {
onDelete();
closeDropdown(dropdownId);
};
return (
<Dropdown
dropdownId={dropdownId}
clickableComponent={
<LightIconButton
aria-label={t`Inactive Skill Options`}
Icon={IconDotsVertical}
accent="tertiary"
/>
}
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Narrow}>
<DropdownMenuItemsContainer>
<MenuItem
text={t`Activate`}
LeftIcon={IconArchiveOff}
onClick={handleActivate}
/>
{isCustomSkill && (
<MenuItem
text={t`Delete`}
LeftIcon={IconTrash}
accent="danger"
onClick={handleDelete}
/>
)}
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
@@ -0,0 +1,62 @@
import styled from '@emotion/styled';
import { type ReactNode } from 'react';
import { SettingsItemTypeTag } from '@/settings/components/SettingsItemTypeTag';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useIcons, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { type Skill } from '~/generated-metadata/graphql';
export type SettingsSkillTableRowProps = {
skill: Skill;
action?: ReactNode;
link?: string;
};
export const StyledSkillTableRow = styled(TableRow)<{ isActive?: boolean }>`
grid-template-columns: 1fr 120px 36px;
opacity: ${({ isActive = true }) => (isActive ? 1 : 0.5)};
`;
const StyledNameTableCell = styled(TableCell)`
color: ${({ theme }) => theme.font.color.primary};
gap: ${({ theme }) => theme.spacing(2)};
min-width: 0;
overflow: hidden;
`;
const StyledIconContainer = styled.div`
align-items: center;
display: flex;
flex-shrink: 0;
`;
const StyledActionTableCell = styled(TableCell)`
justify-content: flex-end;
padding-right: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsSkillTableRow = ({
skill,
action,
link,
}: SettingsSkillTableRowProps) => {
const { getIcon } = useIcons();
const Icon = getIcon(skill.icon ?? 'IconSparkles');
return (
<StyledSkillTableRow key={skill.id} to={link} isActive={skill.isActive}>
<StyledNameTableCell>
<StyledIconContainer>
<Icon size={16} />
</StyledIconContainer>
<OverflowingTextWithTooltip text={skill.label} />
</StyledNameTableCell>
<TableCell>
<SettingsItemTypeTag item={skill} />
</TableCell>
<StyledActionTableCell>{action}</StyledActionTableCell>
</StyledSkillTableRow>
);
};
@@ -0,0 +1,227 @@
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useMemo, useState } from 'react';
import Skeleton from 'react-loading-skeleton';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { SortableTableHeader } from '@/ui/layout/table/components/SortableTableHeader';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { useSortedArray } from '@/ui/layout/table/hooks/useSortedArray';
import { useTheme } from '@emotion/react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
H2Title,
IconArchive,
IconChevronRight,
IconFilterCog,
IconPlus,
IconSearch,
} from 'twenty-ui/display';
import { Button, LightIconButton } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { MenuItemToggle, UndecoratedLink } from 'twenty-ui/navigation';
import {
useActivateSkillMutation,
useDeleteSkillMutation,
useFindManySkillsQuery,
} from '~/generated-metadata/graphql';
import { SettingsSkillInactiveMenuDropDown } from '~/pages/settings/ai/components/SettingsSkillInactiveMenuDropDown';
import { SETTINGS_SKILL_TABLE_METADATA } from '~/pages/settings/ai/constants/SettingsSkillTableMetadata';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
import {
SettingsSkillTableRow,
StyledSkillTableRow,
} from './SettingsSkillTableRow';
const StyledSearchContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledSearchInput = styled(SettingsTextInput)`
flex: 1;
`;
const StyledTable = styled(Table)`
margin-top: ${({ theme }) => theme.spacing(3)};
`;
const StyledTableHeaderRow = styled(StyledSkillTableRow)`
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledHeaderContainer = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
`;
export const SettingsSkillsTable = () => {
const { data, loading, refetch } = useFindManySkillsQuery();
const [activateSkill] = useActivateSkillMutation();
const [deleteSkill] = useDeleteSkillMutation();
const { t } = useLingui();
const theme = useTheme();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [searchTerm, setSearchTerm] = useState('');
const [showDeactivated, setShowDeactivated] = useState(true);
const skills = data?.skills ?? [];
const sortedSkills = useSortedArray(skills, SETTINGS_SKILL_TABLE_METADATA);
const filteredSkills = useMemo(
() =>
sortedSkills.filter((skill) => {
const searchNormalized = normalizeSearchText(searchTerm);
const matchesSearch =
normalizeSearchText(skill.name).includes(searchNormalized) ||
normalizeSearchText(skill.label).includes(searchNormalized);
if (!matchesSearch) {
return false;
}
if (!skill.isActive && !showDeactivated) {
return false;
}
return true;
}),
[sortedSkills, searchTerm, showDeactivated],
);
const showSkeleton = loading && skills.length === 0;
const handleActivate = async (skillId: string) => {
try {
await activateSkill({ variables: { id: skillId } });
enqueueSuccessSnackBar({ message: t`Skill activated` });
refetch();
} catch {
enqueueErrorSnackBar({ message: t`Failed to activate skill` });
}
};
const handleDelete = async (skillId: string) => {
try {
await deleteSkill({ variables: { id: skillId } });
enqueueSuccessSnackBar({ message: t`Skill deleted` });
refetch();
} catch {
enqueueErrorSnackBar({ message: t`Failed to delete skill` });
}
};
return (
<Section>
<StyledHeaderContainer>
<H2Title
title={t`Skills`}
description={t`Skills available in the chat`}
/>
<UndecoratedLink to={getSettingsPath(SettingsPath.AINewSkill)}>
<Button
Icon={IconPlus}
title={t`New Skill`}
size="small"
variant="secondary"
/>
</UndecoratedLink>
</StyledHeaderContainer>
<StyledSearchContainer>
<StyledSearchInput
instanceId="skill-table-search"
LeftIcon={IconSearch}
placeholder={t`Search a skill...`}
value={searchTerm}
onChange={setSearchTerm}
/>
<Dropdown
dropdownId="settings-skills-filter-dropdown"
clickableComponent={
<LightIconButton
aria-label={t`Filter skills`}
Icon={IconFilterCog}
accent="tertiary"
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconArchive}
onToggleChange={() => setShowDeactivated(!showDeactivated)}
toggled={showDeactivated}
text={t`Deactivated`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
</StyledSearchContainer>
<StyledTable>
<StyledTableHeaderRow>
{SETTINGS_SKILL_TABLE_METADATA.fields.map(
(settingsSkillTableMetadataField) => (
<SortableTableHeader
key={settingsSkillTableMetadataField.fieldName}
fieldName={settingsSkillTableMetadataField.fieldName}
label={t(settingsSkillTableMetadataField.fieldLabel)}
tableId={SETTINGS_SKILL_TABLE_METADATA.tableId}
align={settingsSkillTableMetadataField.align}
initialSort={SETTINGS_SKILL_TABLE_METADATA.initialSort}
/>
),
)}
<TableHeader />
</StyledTableHeaderRow>
{showSkeleton
? Array.from({ length: 3 }).map((_, index) => (
<Skeleton height={32} borderRadius={4} key={index} />
))
: filteredSkills.map((skill) => (
<SettingsSkillTableRow
key={skill.id}
skill={skill}
action={
skill.isActive ? (
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
) : (
<SettingsSkillInactiveMenuDropDown
isCustomSkill={skill.isCustom}
skillId={skill.id}
onActivate={() => handleActivate(skill.id)}
onDelete={() => handleDelete(skill.id)}
/>
)
}
link={
skill.isActive
? getSettingsPath(SettingsPath.AISkillDetail, {
skillId: skill.id,
})
: undefined
}
/>
))}
</StyledTable>
</Section>
);
};
@@ -0,0 +1,26 @@
import { type TableMetadata } from '@/ui/layout/table/types/TableMetadata';
import { msg } from '@lingui/core/macro';
import { type Skill } from '~/generated-metadata/graphql';
export const SETTINGS_SKILL_TABLE_METADATA: TableMetadata<Skill> = {
tableId: 'settingsSkill',
fields: [
{
fieldLabel: msg`Name`,
fieldName: 'name',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: msg`Type`,
fieldName: 'isCustom',
fieldType: 'string',
align: 'left',
},
],
initialSort: {
fieldName: 'name',
orderBy: 'AscNullsLast',
},
};
@@ -0,0 +1,43 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddSkillEntity1767003000000 implements MigrationInterface {
name = 'AddSkillEntity1767003000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."skill" ("universalIdentifier" uuid, "applicationId" uuid, "workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "standardId" uuid, "name" character varying NOT NULL, "label" character varying NOT NULL, "icon" character varying, "description" text, "content" text NOT NULL, "isCustom" boolean NOT NULL DEFAULT false, "isActive" boolean NOT NULL DEFAULT true, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_a5167c44f4d4e61423f7f5e43bf" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_e6398c21e6bb31b525272fac84" ON "core"."skill" ("workspaceId", "universalIdentifier")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_SKILL_ID_IS_ACTIVE" ON "core"."skill" ("id", "isActive")`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_SKILL_NAME_WORKSPACE_ID_UNIQUE" ON "core"."skill" ("name", "workspaceId") WHERE "isActive" = true`,
);
await queryRunner.query(
`ALTER TABLE "core"."skill" ADD CONSTRAINT "FK_b832ffda9048fae83e52fbe48a7" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."skill" ADD CONSTRAINT "FK_46f69b93b58666bb388c5c7785a" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."skill" DROP CONSTRAINT "FK_46f69b93b58666bb388c5c7785a"`,
);
await queryRunner.query(
`ALTER TABLE "core"."skill" DROP CONSTRAINT "FK_b832ffda9048fae83e52fbe48a7"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_SKILL_NAME_WORKSPACE_ID_UNIQUE"`,
);
await queryRunner.query(`DROP INDEX "core"."IDX_SKILL_ID_IS_ACTIVE"`);
await queryRunner.query(
`DROP INDEX "core"."IDX_e6398c21e6bb31b525272fac84"`,
);
await queryRunner.query(`DROP TABLE "core"."skill"`);
}
}
@@ -1,6 +0,0 @@
export type SkillDefinition = {
name: string;
label: string;
description: string;
content: string;
};
@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { SkillsService } from './skills.service';
@Module({
providers: [SkillsService],
exports: [SkillsService],
})
export class SkillsModule {}
@@ -1,66 +0,0 @@
import { Injectable } from '@nestjs/common';
import { SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
import { CODE_INTERPRETER_SKILL } from 'src/engine/core-modules/skills/skills/code-interpreter.skill';
import { DASHBOARD_BUILDING_SKILL } from 'src/engine/core-modules/skills/skills/dashboard-building.skill';
import { DATA_MANIPULATION_SKILL } from 'src/engine/core-modules/skills/skills/data-manipulation.skill';
import { DOCX_SKILL } from 'src/engine/core-modules/skills/skills/docx.skill';
import { METADATA_BUILDING_SKILL } from 'src/engine/core-modules/skills/skills/metadata-building.skill';
import { PDF_SKILL } from 'src/engine/core-modules/skills/skills/pdf.skill';
import { PPTX_SKILL } from 'src/engine/core-modules/skills/skills/pptx.skill';
import { RESEARCH_SKILL } from 'src/engine/core-modules/skills/skills/research.skill';
import { WORKFLOW_BUILDING_SKILL } from 'src/engine/core-modules/skills/skills/workflow-building.skill';
import { XLSX_SKILL } from 'src/engine/core-modules/skills/skills/xlsx.skill';
const SKILL_DEFINITIONS: SkillDefinition[] = [
WORKFLOW_BUILDING_SKILL,
DATA_MANIPULATION_SKILL,
DASHBOARD_BUILDING_SKILL,
METADATA_BUILDING_SKILL,
RESEARCH_SKILL,
CODE_INTERPRETER_SKILL,
XLSX_SKILL,
PDF_SKILL,
DOCX_SKILL,
PPTX_SKILL,
];
export type Skill = {
name: string;
label: string;
description: string;
content: string;
};
@Injectable()
export class SkillsService {
getAllSkills(): Skill[] {
return SKILL_DEFINITIONS.map((skill) => ({
name: skill.name,
label: skill.label,
description: skill.description,
content: skill.content,
}));
}
getSkillByName(name: string): Skill | undefined {
const skillDef = SKILL_DEFINITIONS.find((skill) => skill.name === name);
if (!skillDef) {
return undefined;
}
return {
name: skillDef.name,
label: skillDef.label,
description: skillDef.description,
content: skillDef.content,
};
}
getSkillsByNames(names: string[]): Skill[] {
return names
.map((name) => this.getSkillByName(name))
.filter((skill): skill is Skill => skill !== undefined);
}
}
@@ -1,105 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const CODE_INTERPRETER_SKILL: SkillDefinition = {
name: 'code-interpreter',
label: 'Code Interpreter',
description:
'Python code execution for data analysis, complex multi-step operations, and efficient bulk processing via MCP bridge',
content: `# Code Interpreter Skill
You have access to the \`code_interpreter\` tool to execute Python code in a sandboxed environment.
## How to Use
Call the \`code_interpreter\` tool with your Python code. The tool will execute the code and return stdout, stderr, and any generated files.
## Capabilities
- Analyze CSV, Excel, and JSON data files
- Create charts and visualizations (matplotlib, seaborn)
- Generate reports (PDF, PPTX, Excel)
- Perform calculations and data transformations
## Pre-installed Libraries
pandas, numpy, matplotlib, seaborn, scikit-learn, openpyxl, python-pptx
## Input Files
- User-uploaded files are available at \`/home/user/{filename}\`
- Always check the file exists before processing
## Output Files
- Charts: Save to \`/home/user/output/\` directory - these are automatically returned as downloadable URLs
- For matplotlib: \`plt.savefig('/home/user/output/chart.png')\`
- Generated files: Save to \`/home/user/output/{filename}\`
## Example: Create a Bar Chart
\`\`\`python
import matplotlib.pyplot as plt
import os
# Data
months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']
sales = [100, 150, 200, 175, 250, 300]
# Create chart
plt.figure(figsize=(10, 6))
plt.bar(months, sales, color='skyblue')
plt.title('Monthly Sales')
plt.xlabel('Month')
plt.ylabel('Sales')
plt.tight_layout()
# Save to output directory
os.makedirs('/home/user/output', exist_ok=True)
plt.savefig('/home/user/output/sales_chart.png')
print('Chart saved!')
\`\`\`
## Example: Analyze CSV
\`\`\`python
import pandas as pd
import matplotlib.pyplot as plt
import os
# Load data
df = pd.read_csv('/home/user/data.csv')
print(f"Loaded {len(df)} rows")
# Create visualization
plt.figure(figsize=(10, 6))
df.groupby('category')['value'].mean().plot(kind='bar')
plt.title('Average Value by Category')
plt.tight_layout()
os.makedirs('/home/user/output', exist_ok=True)
plt.savefig('/home/user/output/analysis.png')
print('Analysis complete!')
\`\`\`
## Calling Twenty Tools from Python (MCP Bridge)
A \`twenty\` helper is automatically available in your code. Use it to call any Twenty tool directly from Python:
\`\`\`python
# Find records
people = twenty.call_tool('find_person_records', {'limit': 10})
print(f"Found {len(people['edges'])} people")
# Create a record
result = twenty.call_tool('create_company_record', {
'data': {'name': 'Acme Corp', 'domainName': {'primaryLinkUrl': 'acme.com'}}
})
print(f"Created company: {result['id']}")
# Update a record
twenty.call_tool('update_person_record', {
'id': 'person-uuid',
'data': {'jobTitle': 'CEO'}
})
# List available tools
tools = twenty.list_tools()
for tool in tools:
print(f"- {tool['name']}: {tool['description']}")
\`\`\`
This allows you to orchestrate complex multi-step operations in a single code execution, which is more efficient than multiple tool calls.`,
};
@@ -1,78 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const DASHBOARD_BUILDING_SKILL: SkillDefinition = {
name: 'dashboard-building',
label: 'Dashboard Building',
description: 'Creating and managing dashboards with widgets and layouts',
content: `# Dashboard Building Skill
You help users create and manage dashboards with widgets.
## CRITICAL: Creating GRAPH Widgets
Before creating any GRAPH widget, you MUST:
1. Use list_object_metadata_items to get the objectMetadataId (e.g., for "opportunity", "company")
2. From the response, get the field IDs you need (aggregateFieldMetadataId, primaryAxisGroupByFieldMetadataId)
GRAPH widgets require real UUIDs from the workspace metadata, NOT made-up values.
## Widget Configuration
### GRAPH - AGGREGATE (KPI numbers)
Shows a single aggregated value (count, sum, average).
Required:
- objectMetadataId: UUID of the object (e.g., opportunity)
- configuration.graphType: "AGGREGATE"
- configuration.aggregateFieldMetadataId: UUID of field to aggregate
- configuration.aggregateOperation: "COUNT", "SUM", "AVG", "MIN", "MAX"
### GRAPH - BAR/LINE Charts
Shows data grouped by a dimension.
Required:
- objectMetadataId: UUID of the object
- configuration.graphType: "VERTICAL_BAR", "HORIZONTAL_BAR", or "LINE"
- configuration.aggregateFieldMetadataId: field to aggregate
- configuration.aggregateOperation: aggregation type
- configuration.primaryAxisGroupByFieldMetadataId: field to group by (x-axis)
### GRAPH - PIE Charts
Shows data distribution as slices.
Required:
- objectMetadataId: UUID of the object
- configuration.graphType: "PIE"
- configuration.aggregateFieldMetadataId: field to aggregate
- configuration.aggregateOperation: aggregation type
- configuration.groupByFieldMetadataId: field to slice by
### IFRAME
Embeds external content:
- configuration.url: "https://..."
### STANDALONE_RICH_TEXT
Text content widget:
- configuration.body: "Your text here"
## Grid System
- 12 columns (0-11)
- KPI widgets: rowSpan 2-4, columnSpan 3-4
- Charts: rowSpan 6-8, columnSpan 6-12
- Common layouts:
- 4 KPIs in a row: each { columnSpan: 3 }
- 2 charts side by side: each { columnSpan: 6 }
- Full width chart: { column: 0, columnSpan: 12 }
## Workflow
1. Ask user what data they want to visualize
2. Load list_object_metadata_items to discover available objects and fields
3. Create dashboard with appropriate widgets using real field IDs
4. Use get_dashboard to verify creation
## Best Practices
- Place KPIs at the top (row 0)
- Group related charts together
- Use consistent heights within rows
- Start simple, add complexity as needed`,
};
@@ -1,44 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const DATA_MANIPULATION_SKILL: SkillDefinition = {
name: 'data-manipulation',
label: 'Data Manipulation',
description:
'Searching, filtering, creating, and updating records across all objects',
content: `# Data Manipulation Skill
You explore and manage data across companies, people, opportunities, tasks, notes, and custom objects.
## Capabilities
- Search, filter, sort, create, update records
- Manage relationships between records
- Bulk operations and data analysis
## Constraints
- READ and WRITE access to all objects
- CANNOT delete records or access workflow objects
- CANNOT modify workspace settings
## Multi-step Approach
- Chain queries to solve complex requests (e.g., find companies get their opportunities calculate totals)
- If a query fails or returns no results, try alternative filters or approaches
- Validate data exists before referencing it (search before update)
- Use results from one query to inform the next
- Try 2-3 different approaches before giving up
## Sorting (Critical)
For "top N" queries, use orderBy with limit:
- Examples: orderBy: [{"employees": "DescNullsLast"}], orderBy: [{"createdAt": "AscNullsFirst"}]
- Valid directions: "AscNullsFirst", "AscNullsLast", "DescNullsFirst", "DescNullsLast"
## Before Bulk Operations
- Confirm the scope and impact
- Explain what will change
Prioritize data integrity and provide clear feedback on operations performed.`,
};
@@ -1,152 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const DOCX_SKILL: SkillDefinition = {
name: 'docx',
label: 'Word Documents',
description:
'Word document creation, editing, template processing, and OOXML manipulation',
content: `# Word Document Processing Skill
**IMPORTANT**: Save all output files to \`/home/user/output/\` for them to be downloadable.
## Pre-installed Scripts (OOXML Editing)
- \`python /home/user/scripts/docx/unpack.py <docx_file> <output_dir>\` - Unpack .docx to XML files for direct editing
- \`python /home/user/scripts/docx/pack.py <input_dir> <docx_file>\` - Repack XML files into .docx
- \`python /home/user/scripts/docx/validate.py <docx_file>\` - Validate document structure
### Validation Scripts
- \`/home/user/scripts/docx/validation/docx.py\` - DOCX validation module
- \`/home/user/scripts/docx/validation/redlining.py\` - Track changes/redline validation
## High-Level API (python-docx)
### Reading Documents
\`\`\`python
from docx import Document
doc = Document('document.docx')
# Read paragraphs
for para in doc.paragraphs:
print(para.text)
# Read tables
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
print(cell.text)
\`\`\`
### Creating Documents
\`\`\`python
from docx import Document
from docx.shared import Inches, Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH
doc = Document()
# Add heading
doc.add_heading('Document Title', 0)
# Add paragraph with formatting
para = doc.add_paragraph('Normal text. ')
run = para.add_run('Bold text.')
run.bold = True
# Add table
table = doc.add_table(rows=2, cols=2)
table.cell(0, 0).text = 'Header 1'
table.cell(0, 1).text = 'Header 2'
# Add image
doc.add_picture('image.png', width=Inches(4))
doc.save('/home/user/output/output.docx')
\`\`\`
## Low-Level OOXML Editing
For complex edits (tracked changes, custom XML), use the unpack/edit/pack workflow:
### Step 1: Unpack
\`\`\`bash
python /home/user/scripts/docx/unpack.py document.docx ./unpacked/
\`\`\`
### Step 2: Edit XML directly
\`\`\`python
import xml.etree.ElementTree as ET
tree = ET.parse('./unpacked/word/document.xml')
root = tree.getroot()
# Edit XML...
# Namespaces: w = http://schemas.openxmlformats.org/wordprocessingml/2006/main
tree.write('./unpacked/word/document.xml', xml_declaration=True, encoding='UTF-8')
\`\`\`
### Step 3: Validate & Repack
\`\`\`bash
python /home/user/scripts/docx/validate.py ./unpacked/
python /home/user/scripts/docx/pack.py ./unpacked/ /home/user/output/output.docx
\`\`\`
## Template Processing
### Find and Replace
\`\`\`python
from docx import Document
doc = Document('template.docx')
for para in doc.paragraphs:
if '{{name}}' in para.text:
para.text = para.text.replace('{{name}}', 'John Doe')
doc.save('/home/user/output/filled.docx')
\`\`\`
### Preserve Formatting During Replace
\`\`\`python
def replace_in_paragraph(para, old_text, new_text):
"""Replace text while preserving formatting"""
for run in para.runs:
if old_text in run.text:
run.text = run.text.replace(old_text, new_text)
for para in doc.paragraphs:
replace_in_paragraph(para, '{{name}}', 'John Doe')
\`\`\`
## Working with Styles
\`\`\`python
from docx.shared import Pt, RGBColor
# Set font
run.font.name = 'Arial'
run.font.size = Pt(12)
run.font.color.rgb = RGBColor(0, 0, 0)
# Paragraph formatting
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
para.paragraph_format.space_before = Pt(12)
para.paragraph_format.space_after = Pt(12)
\`\`\`
## Quick Reference
| Task | Tool | Example |
|------|------|---------|
| Read document | python-docx | \`Document('file.docx')\` |
| Create document | python-docx | \`Document()\` |
| Add heading | python-docx | \`doc.add_heading('Title', 0)\` |
| Add table | python-docx | \`doc.add_table(rows=2, cols=2)\` |
| Unpack for editing | script | \`python unpack.py doc.docx ./out/\` |
| Repack | script | \`python pack.py ./out/ doc.docx\` |
| Validate | script | \`python validate.py doc.docx\` |`,
};
@@ -1,64 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const METADATA_BUILDING_SKILL: SkillDefinition = {
name: 'metadata-building',
label: 'Metadata Building',
description:
'Managing the data model: creating objects, fields, and relations',
content: `# Metadata Building Skill
You help users manage their workspace data model by creating, updating, and organizing custom objects and fields.
## Capabilities
- Create new custom objects with appropriate naming and configuration
- Add fields to existing objects (text, number, date, select, relation, etc.)
- Update object and field properties (labels, descriptions, icons)
- Manage field settings (required, unique, default values)
- Create relations between objects
## Key Concepts
- **Objects**: Represent entities in the data model (e.g., Company, Person, Opportunity)
- **Fields**: Properties of objects with specific types (TEXT, NUMBER, DATE_TIME, SELECT, RELATION, etc.)
- **Relations**: Links between objects (one-to-many, many-to-one)
- **Labels vs Names**: Labels are for display, names are internal identifiers (camelCase)
## Field Types Available
- **TEXT**: Simple text fields
- **NUMBER**: Numeric values (integers or decimals)
- **BOOLEAN**: True/false values
- **DATE_TIME**: Date and time values
- **DATE**: Date only values
- **SELECT**: Single choice from options
- **MULTI_SELECT**: Multiple choices from options
- **LINK**: URL fields
- **LINKS**: Multiple URL fields
- **EMAIL**: Email address fields
- **EMAILS**: Multiple email fields
- **PHONE**: Phone number fields
- **PHONES**: Multiple phone fields
- **CURRENCY**: Monetary values
- **RATING**: Star ratings
- **RELATION**: Links to other objects
- **RICH_TEXT**: Formatted text content
## Best Practices
- Use clear, descriptive names for objects and fields
- Follow naming conventions: singular for object names, camelCase for field names
- Add helpful descriptions to objects and fields
- Choose appropriate field types for the data being stored
- Consider relationships between objects when designing the data model
## Approach
- Ask clarifying questions to understand the user's data modeling needs
- Suggest best practices for naming and organization
- Explain the impact of changes to the data model
- Verify object and field existence before making updates
- Provide clear feedback on operations performed
Prioritize data model integrity and user understanding.`,
};
@@ -1,131 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const PDF_SKILL: SkillDefinition = {
name: 'pdf',
label: 'PDF Processing',
description:
'PDF form filling, field extraction, table parsing, and validation',
content: `# PDF Processing Skill
**IMPORTANT**: Save all output files to \`/home/user/output/\` for them to be downloadable.
## Pre-installed Scripts
### Field Extraction
- \`python /home/user/scripts/pdf/extract_form_field_info.py <pdf_file>\` - Extract all fillable field names and types (JSON output)
- \`python /home/user/scripts/pdf/check_fillable_fields.py <pdf_file>\` - Check if PDF has fillable fields
### Form Filling
- \`python /home/user/scripts/pdf/fill_fillable_fields.py <pdf_file> <json_data> <output_file>\` - Fill PDF form fields
- \`python /home/user/scripts/pdf/fill_pdf_form_with_annotations.py <pdf_file> <json_data> <output_file>\` - Fill with annotation support
### Validation
- \`python /home/user/scripts/pdf/create_validation_image.py <pdf_file>\` - Create validation image of filled PDF
- \`python /home/user/scripts/pdf/check_bounding_boxes.py <pdf_file>\` - Check field boundaries
- \`python /home/user/scripts/pdf/convert_pdf_to_images.py <pdf_file>\` - Convert PDF pages to images
## Reading PDFs
\`\`\`python
import fitz # PyMuPDF
# Open PDF
doc = fitz.open('document.pdf')
# Extract text from all pages
for page in doc:
text = page.get_text()
print(text)
# Extract text from specific page
page = doc[0] # First page
text = page.get_text()
\`\`\`
## Extracting Tables
\`\`\`python
import pdfplumber
with pdfplumber.open('document.pdf') as pdf:
for page in pdf.pages:
tables = page.extract_tables()
for table in tables:
for row in table:
print(row)
\`\`\`
## Filling PDF Forms
### Step 1: Extract field information
\`\`\`bash
python /home/user/scripts/pdf/extract_form_field_info.py form.pdf > fields.json
\`\`\`
### Step 2: Create fill data JSON
\`\`\`json
{
"field_name_1": "value1",
"field_name_2": "value2",
"checkbox_field": true
}
\`\`\`
### Step 3: Fill the form
\`\`\`bash
python /home/user/scripts/pdf/fill_fillable_fields.py form.pdf fill_data.json /home/user/output/output.pdf
\`\`\`
### Step 4: Validate the output
\`\`\`bash
python /home/user/scripts/pdf/create_validation_image.py /home/user/output/output.pdf
\`\`\`
## Creating PDFs
\`\`\`python
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
c = canvas.Canvas('/home/user/output/output.pdf', pagesize=letter)
c.drawString(100, 750, 'Hello World!')
c.save()
\`\`\`
## Merging PDFs
\`\`\`python
from PyPDF2 import PdfMerger
merger = PdfMerger()
merger.append('file1.pdf')
merger.append('file2.pdf')
merger.write('/home/user/output/merged.pdf')
merger.close()
\`\`\`
## Splitting PDFs
\`\`\`python
from PyPDF2 import PdfReader, PdfWriter
reader = PdfReader('document.pdf')
# Extract specific pages
writer = PdfWriter()
writer.add_page(reader.pages[0]) # First page
writer.write('/home/user/output/page1.pdf')
\`\`\`
## Quick Reference
| Task | Tool | Command/Example |
|------|------|-----------------|
| Extract text | PyMuPDF | \`page.get_text()\` |
| Extract tables | pdfplumber | \`page.extract_tables()\` |
| List form fields | script | \`python extract_form_field_info.py form.pdf\` |
| Fill form | script | \`python fill_fillable_fields.py form.pdf data.json out.pdf\` |
| Validate fill | script | \`python create_validation_image.py filled.pdf\` |
| Create PDF | reportlab | \`canvas.Canvas('out.pdf')\` |
| Merge PDFs | PyPDF2 | \`PdfMerger()\` |`,
};
@@ -1,170 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const PPTX_SKILL: SkillDefinition = {
name: 'pptx',
label: 'PowerPoint',
description:
'PowerPoint creation, editing, templates, thumbnails, and slide manipulation',
content: `# PowerPoint Processing Skill
**IMPORTANT**: Save all output files to \`/home/user/output/\` for them to be downloadable.
## Pre-installed Scripts
- \`python /home/user/scripts/pptx/thumbnail.py <pptx_file> [output_dir]\` - Generate slide thumbnails
- \`python /home/user/scripts/pptx/rearrange.py <pptx_file> <slide_order_json> <output_file>\` - Reorder slides
- \`python /home/user/scripts/pptx/inventory.py <pptx_file>\` - List all slides and their content
- \`python /home/user/scripts/pptx/replace.py <pptx_file> <replacements_json> <output_file>\` - Find/replace text
## Reading Presentations
\`\`\`python
from pptx import Presentation
prs = Presentation('presentation.pptx')
# Iterate through slides
for slide in prs.slides:
for shape in slide.shapes:
if shape.has_text_frame:
print(shape.text)
\`\`\`
## Creating Presentations
\`\`\`python
from pptx import Presentation
from pptx.util import Inches, Pt
prs = Presentation()
# Add title slide
slide_layout = prs.slide_layouts[0] # Title layout
slide = prs.slides.add_slide(slide_layout)
title = slide.shapes.title
subtitle = slide.placeholders[1]
title.text = "Presentation Title"
subtitle.text = "Subtitle goes here"
# Add content slide
slide_layout = prs.slide_layouts[1] # Title and content
slide = prs.slides.add_slide(slide_layout)
title = slide.shapes.title
body = slide.placeholders[1]
title.text = "Slide Title"
tf = body.text_frame
tf.text = "First bullet"
p = tf.add_paragraph()
p.text = "Second bullet"
p.level = 1
prs.save('/home/user/output/output.pptx')
\`\`\`
## Adding Images
\`\`\`python
from pptx.util import Inches
slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout
slide.shapes.add_picture(
'image.png',
left=Inches(1),
top=Inches(1),
width=Inches(5)
)
\`\`\`
## Adding Tables
\`\`\`python
from pptx.util import Inches
slide = prs.slides.add_slide(prs.slide_layouts[6])
table = slide.shapes.add_table(
rows=3, cols=3,
left=Inches(1), top=Inches(1),
width=Inches(8), height=Inches(2)
).table
# Set cell values
table.cell(0, 0).text = "Header 1"
table.cell(0, 1).text = "Header 2"
table.cell(1, 0).text = "Data 1"
\`\`\`
## Adding Charts
\`\`\`python
from pptx.chart.data import CategoryChartData
from pptx.enum.chart import XL_CHART_TYPE
from pptx.util import Inches
chart_data = CategoryChartData()
chart_data.categories = ['East', 'West', 'Midwest']
chart_data.add_series('Series 1', (19.2, 21.4, 16.7))
slide = prs.slides.add_slide(prs.slide_layouts[6])
chart = slide.shapes.add_chart(
XL_CHART_TYPE.COLUMN_CLUSTERED,
Inches(1), Inches(1), Inches(8), Inches(5),
chart_data
).chart
\`\`\`
## Using Scripts
### Generate Thumbnails
\`\`\`bash
python /home/user/scripts/pptx/thumbnail.py presentation.pptx ./thumbnails/
# Creates: thumbnails/slide_1.png, slide_2.png, etc.
\`\`\`
### Get Slide Inventory
\`\`\`bash
python /home/user/scripts/pptx/inventory.py presentation.pptx
# Returns JSON with all slide content and shapes
\`\`\`
### Reorder Slides
\`\`\`bash
# Order: [3, 1, 2] means slide 3 becomes first, slide 1 second, etc.
python /home/user/scripts/pptx/rearrange.py input.pptx '[3, 1, 2]' output.pptx
\`\`\`
### Find and Replace Text
\`\`\`bash
python /home/user/scripts/pptx/replace.py input.pptx '{"{{company}}": "Acme Corp", "{{date}}": "2024"}' output.pptx
\`\`\`
## Template Processing Workflow
1. **Generate thumbnails** to understand slide structure:
\`\`\`bash
python /home/user/scripts/pptx/thumbnail.py template.pptx ./preview/
\`\`\`
2. **Get inventory** to find placeholder text:
\`\`\`bash
python /home/user/scripts/pptx/inventory.py template.pptx
\`\`\`
3. **Replace placeholders**:
\`\`\`bash
python /home/user/scripts/pptx/replace.py template.pptx '{"{{title}}": "Q4 Report"}' output.pptx
\`\`\`
## Quick Reference
| Task | Tool | Example |
|------|------|---------|
| Read presentation | python-pptx | \`Presentation('file.pptx')\` |
| Create presentation | python-pptx | \`Presentation()\` |
| Add slide | python-pptx | \`prs.slides.add_slide(layout)\` |
| Generate thumbnails | script | \`python thumbnail.py pres.pptx ./out/\` |
| Get slide inventory | script | \`python inventory.py pres.pptx\` |
| Reorder slides | script | \`python rearrange.py pres.pptx '[2,1,3]' out.pptx\` |
| Find/replace | script | \`python replace.py pres.pptx '{...}' out.pptx\` |`,
};
@@ -1,34 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const RESEARCH_SKILL: SkillDefinition = {
name: 'research',
label: 'Research',
description: 'Finding information and gathering facts from the web',
content: `# Research Skill
You find information and gather facts from the web.
## Capabilities
- Search for current information and facts
- Research companies, people, technologies, trends
- Gather competitive intelligence and market data
- Find contact details and verify information
## Research Strategy
- Try multiple search queries from different angles
- If initial searches fail, use alternative search terms
- Cross-reference information when possible
- Cite sources and provide context
## Present Findings
- Be thorough but concise
- Organize information logically
- Distinguish facts from speculation
- Note if information might be outdated
- Include relevant sources
Be persistent in finding accurate information.`,
};
@@ -1,62 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const WORKFLOW_BUILDING_SKILL: SkillDefinition = {
name: 'workflow-building',
label: 'Workflow Building',
description:
'Creating and managing automation workflows with triggers and steps',
content: `# Workflow Building Skill
You help users create and manage automation workflows.
## Capabilities
- Create workflows from scratch
- Modify existing workflows (add, remove, update steps)
- Explain workflow structure and suggest improvements
## Key Concepts
- **Triggers**: DATABASE_EVENT, MANUAL, CRON, WEBHOOK
- **Steps**: CREATE_RECORD, SEND_EMAIL, CODE, etc.
- **Data flow**: Use {{stepId.fieldName}} to reference previous step outputs
- **Relationships**: Use nested objects like {"company": {"id": "{{reference}}"}}
## CRON Trigger Settings Schema
For CRON triggers, settings.type must be one of these exact values:
1. **DAYS** - Daily schedule
- Requires: schedule: { day: number (1+), hour: number (0-23), minute: number (0-59) }
- Example: { type: "DAYS", schedule: { day: 1, hour: 9, minute: 0 }, outputSchema: {} }
2. **HOURS** - Hourly schedule (USE THIS FOR "EVERY HOUR")
- Requires: schedule: { hour: number (1+), minute: number (0-59) }
- Example: { type: "HOURS", schedule: { hour: 1, minute: 0 }, outputSchema: {} }
- This runs every X hours at Y minutes past the hour
3. **MINUTES** - Minute-based schedule
- Requires: schedule: { minute: number (1+) }
- Example: { type: "MINUTES", schedule: { minute: 15 }, outputSchema: {} }
4. **CUSTOM** - Custom cron pattern
- Requires: pattern: string (cron expression)
- Example: { type: "CUSTOM", pattern: "0 * * * *", outputSchema: {} }
## Critical Notes
Always rely on tool schema definitions:
- The workflow creation tool provides comprehensive schemas with examples
- Follow schema definitions exactly for field names, types, and structures
- Schema includes validation rules and common patterns
## Approach
- Ask clarifying questions to understand user needs
- Suggest appropriate actions for the use case
- Explain each step and why it's needed
- For modifications, understand current structure first
- Ensure workflow logic remains coherent
Prioritize user understanding and workflow effectiveness.`,
};
@@ -1,131 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const XLSX_SKILL: SkillDefinition = {
name: 'xlsx',
label: 'Excel & Spreadsheets',
description:
'Excel/spreadsheet creation, editing, and analysis with formulas, formatting, and visualization',
content: `# Excel Processing Skill
**IMPORTANT**: Save all output files to \`/home/user/output/\` for them to be downloadable.
## Pre-installed Scripts
- \`python /home/user/scripts/xlsx/recalc.py <excel_file> [timeout]\` - Recalculate formulas using LibreOffice
## Requirements
### Zero Formula Errors
Every Excel model MUST be delivered with ZERO formula errors (#REF!, #DIV/0!, #VALUE!, #N/A, #NAME?)
### Use Formulas, Not Hardcoded Values
**Always use Excel formulas instead of calculating values in Python and hardcoding them.**
\`\`\`python
# WRONG - Hardcoding
total = df['Sales'].sum()
sheet['B10'] = total
# CORRECT - Using formulas
sheet['B10'] = '=SUM(B2:B9)'
\`\`\`
## Reading and Analyzing Data
\`\`\`python
import pandas as pd
# Read Excel
df = pd.read_excel('file.xlsx')
all_sheets = pd.read_excel('file.xlsx', sheet_name=None) # All sheets as dict
# Analyze
df.head()
df.info()
df.describe()
\`\`\`
## Creating New Excel Files
\`\`\`python
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
wb = Workbook()
sheet = wb.active
# Add data
sheet['A1'] = 'Hello'
sheet.append(['Row', 'of', 'data'])
# Add formula
sheet['B2'] = '=SUM(A1:A10)'
# Formatting
sheet['A1'].font = Font(bold=True)
sheet['A1'].fill = PatternFill('solid', start_color='FFFF00')
sheet['A1'].alignment = Alignment(horizontal='center')
# Column width
sheet.column_dimensions['A'].width = 20
wb.save('/home/user/output/output.xlsx')
\`\`\`
## Editing Existing Files
\`\`\`python
from openpyxl import load_workbook
wb = load_workbook('existing.xlsx')
sheet = wb.active
# Modify cells
sheet['A1'] = 'New Value'
sheet.insert_rows(2)
wb.save('/home/user/output/modified.xlsx')
\`\`\`
## Recalculating Formulas (MANDATORY)
After creating/editing files with formulas, run:
\`\`\`bash
python /home/user/scripts/xlsx/recalc.py /home/user/output/output.xlsx
\`\`\`
The script returns JSON with error details:
\`\`\`json
{
"status": "success",
"total_errors": 0,
"total_formulas": 42,
"error_summary": {}
}
\`\`\`
If errors found, fix them and recalculate again.
## Financial Model Color Coding
- **Blue text**: Hardcoded inputs
- **Black text**: Formulas and calculations
- **Green text**: Links from other worksheets
- **Yellow background**: Key assumptions needing attention
## Number Formatting
- Years: Format as text ("2024" not "2,024")
- Currency: Use $#,##0 format
- Percentages: 0.0% format
- Negatives: Use parentheses (123) not minus -123
## Quick Reference
| Task | Tool | Example |
|------|------|---------|
| Read Excel | pandas | \`pd.read_excel('file.xlsx')\` |
| Create Excel | openpyxl | \`Workbook()\` |
| Add formula | openpyxl | \`sheet['B2'] = '=SUM(A1:A10)'\` |
| Recalculate | script | \`python /home/user/scripts/xlsx/recalc.py file.xlsx\` |`,
};
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { type Skill } from 'src/engine/core-modules/skills/skills.service';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
export const LOAD_SKILL_TOOL_NAME = 'load_skill';
@@ -25,7 +25,7 @@ export type LoadSkillResult = {
message: string;
};
export type LoadSkillFunction = (names: string[]) => Skill[];
export type LoadSkillFunction = (names: string[]) => Promise<FlatSkill[]>;
export const createLoadSkillTool = (loadSkills: LoadSkillFunction) => ({
description:
@@ -36,7 +36,7 @@ export const createLoadSkillTool = (loadSkills: LoadSkillFunction) => ({
}): Promise<LoadSkillResult> => {
const { skillNames } = parameters.input;
const skills = loadSkills(skillNames);
const skills = await loadSkills(skillNames);
if (skills.length === 0) {
return {
@@ -1,2 +1,2 @@
// Configuration: $0.00001 = 1 credit
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1_000_000; // 1 / 0.000001 = 1 000 000 credits per dollar
// Configuration: $0.000_001 = 1 credit
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1_000_000; // 1 / 0.000_001 = 1_000_000 credits per dollar
@@ -2,7 +2,7 @@ import { DOLLAR_TO_CREDIT_MULTIPLIER } from 'src/engine/metadata-modules/ai/ai-b
// Converts cost in cents to cost in credits
// Formula: credits = (cents / 100) * DOLLAR_TO_CREDIT_MULTIPLIER
// Where DOLLAR_TO_CREDIT_MULTIPLIER = 1000000 (so $0.00001 = 1 credit)
// Example: 1 cent = (1 / 100) * 1000000 = 10000 credits
// Where DOLLAR_TO_CREDIT_MULTIPLIER = 1_000_000 (so $0.000_001 = 1 credit)
// Example: 1 cent = (1 / 100) * 1_000_000 = 10_000 credits
export const convertCentsToBillingCredits = (cents: number): number =>
(cents / 100) * DOLLAR_TO_CREDIT_MULTIPLIER;
@@ -8,7 +8,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { SkillsModule } from 'src/engine/core-modules/skills/skills.module';
import { SkillModule } from 'src/engine/metadata-modules/skill/skill.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -44,7 +44,7 @@ import { ChatExecutionService } from './services/chat-execution.service';
FileUploadModule,
FileModule,
PermissionsModule,
SkillsModule,
SkillModule,
WorkspaceCacheStorageModule,
WorkspaceCacheModule,
WorkspaceDomainsModule,
@@ -10,6 +10,12 @@ Tool usage strategy:
- Don't give up after first failure - be persistent
- Validate assumptions before making changes
Database vs HTTP tools:
- Use database tools (find_*, create_*, update_*, delete_*) for ALL Twenty CRM data operations
- NEVER guess or construct API URLs - always use the appropriate database tool
- The \`http_request\` tool is ONLY for external third-party APIs (not for Twenty's own data)
- If you need to look up a record, load and use the corresponding find_one_* or find_many_* tool
Error recovery:
- Analyze error messages to understand what went wrong
- Adjust parameters or try different tools
@@ -17,7 +17,6 @@ import { getAppPath } from 'twenty-shared/utils';
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { SkillsService } from 'src/engine/core-modules/skills/skills.service';
import {
type ToolIndexEntry,
ToolRegistryService,
@@ -46,6 +45,8 @@ import {
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
export type ChatExecutionOptions = {
workspace: WorkspaceEntity;
@@ -61,7 +62,7 @@ export type ChatExecutionResult = {
modelConfig: AIModelConfig;
};
const COMMON_PRELOAD_TOOLS = ['http_request', 'search_help_center'];
const COMMON_PRELOAD_TOOLS = ['search_help_center'];
@Injectable()
export class ChatExecutionService {
@@ -69,7 +70,7 @@ export class ChatExecutionService {
constructor(
private readonly toolRegistry: ToolRegistryService,
private readonly skillsService: SkillsService,
private readonly skillService: SkillService,
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly aiBillingService: AIBillingService,
private readonly agentActorContextService: AgentActorContextService,
@@ -108,7 +109,9 @@ export class ChatExecutionService {
{ userId, userWorkspaceId },
);
const skillCatalog = this.skillsService.getAllSkills();
const skillCatalog = await this.skillService.findAllFlatSkills(
workspace.id,
);
this.logger.log(
`Built tool catalog with ${toolCatalog.length} tools, ${skillCatalog.length} skills available`,
@@ -150,7 +153,7 @@ export class ChatExecutionService {
},
),
[LOAD_SKILL_TOOL_NAME]: createLoadSkillTool((skillNames) =>
this.skillsService.getSkillsByNames(skillNames),
this.skillService.findFlatSkillsByNames(skillNames, workspace.id),
),
};
@@ -283,7 +286,7 @@ export class ChatExecutionService {
private buildSystemPrompt(
toolCatalog: ToolIndexEntry[],
skillCatalog: Array<{ name: string; label: string; description: string }>,
skillCatalog: FlatSkill[],
preloadedTools: string[],
contextString?: string,
storedFiles?: Array<{ filename: string; storagePath: string; url: string }>,
@@ -333,15 +336,15 @@ ${filesJson}
In your Python code, access files at \`/home/user/{filename}\`.`;
}
private buildSkillCatalogSection(
skillCatalog: Array<{ name: string; label: string; description: string }>,
): string {
private buildSkillCatalogSection(skillCatalog: FlatSkill[]): string {
if (skillCatalog.length === 0) {
return '';
}
const skillsList = skillCatalog
.map((skill) => `- \`${skill.name}\`: ${skill.description}`)
.map(
(skill) => `- \`${skill.name}\`: ${skill.description ?? skill.label}`,
)
.join('\n');
return `
@@ -1,2 +1,2 @@
// Configuration: $0.00001 = 1 credit
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1000000; // 1 / 0.000001 = 1000000 credits per dollar
// Configuration: $0.00_001 = 1 credit
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1_000_000; // 1 / 0.00_0001 = 1_000_000 credits per dollar
@@ -13,6 +13,7 @@ import { FLAT_ROLE_TARGET_EDITABLE_PROPERTIES } from 'src/engine/metadata-module
import { FLAT_ROLE_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-role/constants/flat-role-editable-properties.constant';
import { FLAT_ROW_LEVEL_PERMISSION_PREDICATE_GROUP_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-row-level-permission-predicate-group/constants/flat-row-level-permission-predicate-group-editable-properties.constant';
import { FLAT_ROW_LEVEL_PERMISSION_PREDICATE_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-row-level-permission-predicate/constants/flat-row-level-permission-predicate-editable-properties.constant';
import { FLAT_SKILL_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-skill/constants/flat-skill-editable-properties.constant';
import { FLAT_VIEW_FIELD_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-field/constants/flat-view-field-editable-properties.constant';
import { FLAT_VIEW_FILTER_GROUP_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-filter-group/constants/flat-view-filter-group-editable-properties.constant';
import { FLAT_VIEW_FILTER_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-filter/constants/flat-view-filter-editable-properties.constant';
@@ -135,6 +136,10 @@ export const ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY = {
],
propertiesToStringify: [],
},
skill: {
propertiesToCompare: [...FLAT_SKILL_EDITABLE_PROPERTIES],
propertiesToStringify: [],
},
rowLevelPermissionPredicate: {
propertiesToCompare: [
...FLAT_ROW_LEVEL_PERMISSION_PREDICATE_EDITABLE_PROPERTIES,
@@ -113,6 +113,7 @@ export const ALL_METADATA_RELATED_METADATA_BY_FOREIGN_KEY = {
},
},
agent: {},
skill: {},
pageLayout: {},
pageLayoutWidget: {
pageLayoutTabId: {
@@ -8,6 +8,10 @@ export const ALL_METADATA_RELATION_PROPERTIES = {
workspace: true,
application: true,
},
skill: {
workspace: true,
application: true,
},
fieldMetadata: {
relationTargetFieldMetadata: true,
relationTargetObjectMetadata: true,
@@ -62,6 +62,7 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = {
agent: {
role: true,
},
skill: {},
pageLayout: {},
pageLayoutTab: {
pageLayout: true,
@@ -13,6 +13,7 @@ import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page
import { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout.type';
import { type FlatRoleTarget } from 'src/engine/metadata-modules/flat-role-target/types/flat-role-target.type';
import { type FlatRole } from 'src/engine/metadata-modules/flat-role/types/flat-role.type';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { type FlatViewFilterGroup } from 'src/engine/metadata-modules/flat-view-filter-group/types/flat-view-filter-group.type';
import { type FlatViewFilter } from 'src/engine/metadata-modules/flat-view-filter/types/flat-view-filter.type';
@@ -33,6 +34,7 @@ import { type FlatRowLevelPermissionPredicateGroup } from 'src/engine/metadata-m
import { type FlatRowLevelPermissionPredicate } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate.type';
import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { type FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
import { type SkillEntity } from 'src/engine/metadata-modules/skill/entities/skill.entity';
import { type ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { type ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity';
import { type ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
@@ -113,6 +115,11 @@ import {
type DeleteServerlessFunctionAction,
type UpdateServerlessFunctionAction,
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/serverless-function/types/workspace-migration-serverless-function-action-v2.type';
import {
type CreateSkillAction,
type DeleteSkillAction,
type UpdateSkillAction,
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/skill/types/workspace-migration-v2-skill-action.type';
import {
type CreateViewFieldAction,
type DeleteViewFieldAction,
@@ -295,6 +302,15 @@ export type AllFlatEntityTypesByMetadataName = {
flatEntity: FlatAgent;
entity: AgentEntity;
};
skill: {
actions: {
created: CreateSkillAction;
updated: UpdateSkillAction;
deleted: DeleteSkillAction;
};
flatEntity: FlatSkill;
entity: SkillEntity;
};
pageLayout: {
actions: {
created: CreatePageLayoutAction;
@@ -0,0 +1,10 @@
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
export const FLAT_SKILL_EDITABLE_PROPERTIES = [
'name',
'label',
'icon',
'description',
'content',
'isActive',
] as const satisfies (keyof FlatSkill)[];
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { WorkspaceFlatSkillMapCacheService } from 'src/engine/metadata-modules/flat-skill/services/workspace-flat-skill-map-cache.service';
import { SkillEntity } from 'src/engine/metadata-modules/skill/entities/skill.entity';
@Module({
imports: [
TypeOrmModule.forFeature([SkillEntity]),
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
providers: [WorkspaceFlatSkillMapCacheService],
exports: [WorkspaceFlatSkillMapCacheService],
})
export class FlatSkillModule {}
@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
import { type FlatSkillMaps } from 'src/engine/metadata-modules/flat-skill/types/flat-skill-maps.type';
import { transformSkillEntityToFlatSkill } from 'src/engine/metadata-modules/flat-skill/utils/transform-skill-entity-to-flat-skill.util';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { SkillEntity } from 'src/engine/metadata-modules/skill/entities/skill.entity';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration-v2/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util';
@Injectable()
@WorkspaceCache('flatSkillMaps')
export class WorkspaceFlatSkillMapCacheService extends WorkspaceCacheProvider<FlatSkillMaps> {
constructor(
@InjectRepository(SkillEntity)
private readonly skillRepository: Repository<SkillEntity>,
) {
super();
}
async computeForCache(workspaceId: string): Promise<FlatSkillMaps> {
const skills = await this.skillRepository.find({
where: { workspaceId },
withDeleted: true,
});
const flatSkillMaps = createEmptyFlatEntityMaps();
for (const skillEntity of skills) {
const flatSkill = transformSkillEntityToFlatSkill(skillEntity);
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
flatEntity: flatSkill,
flatEntityMapsToMutate: flatSkillMaps,
});
}
return flatSkillMaps;
}
}
@@ -0,0 +1,4 @@
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
export type FlatSkillMaps = FlatEntityMaps<FlatSkill>;
@@ -0,0 +1,4 @@
import { type SkillEntity } from 'src/engine/metadata-modules/skill/entities/skill.entity';
import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity.type';
export type FlatSkill = FlatEntityFrom<SkillEntity>;
@@ -0,0 +1,45 @@
import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { type CreateSkillInput } from 'src/engine/metadata-modules/skill/dtos/create-skill.input';
export const fromCreateSkillInputToFlatSkillToCreate = ({
createSkillInput,
workspaceId,
applicationId,
}: {
createSkillInput: CreateSkillInput;
workspaceId: string;
applicationId: string;
}): FlatSkill => {
const now = new Date().toISOString();
const { name, label, icon, description } =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
createSkillInput,
['name', 'label', 'icon', 'description'],
);
// Content is markdown - only trim, don't collapse whitespace (preserve newlines)
const content = createSkillInput.content.trim();
const id = v4();
return {
id,
standardId: null,
name,
label,
icon: icon ?? null,
description: description ?? null,
content,
isCustom: true,
isActive: true,
workspaceId,
createdAt: now,
updatedAt: now,
universalIdentifier: id,
applicationId,
};
};
@@ -0,0 +1,38 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import {
SkillException,
SkillExceptionCode,
} from 'src/engine/metadata-modules/skill/skill.exception';
export const fromDeleteSkillInputToFlatSkillOrThrow = ({
flatSkillMaps,
skillId,
}: {
flatSkillMaps: FlatEntityMaps<FlatSkill>;
skillId: string;
}): FlatSkill => {
const existingFlatSkill = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: skillId,
flatEntityMaps: flatSkillMaps,
});
if (!isDefined(existingFlatSkill)) {
throw new SkillException(
'Skill not found',
SkillExceptionCode.SKILL_NOT_FOUND,
);
}
if (!existingFlatSkill.isCustom) {
throw new SkillException(
'Cannot delete standard skill',
SkillExceptionCode.SKILL_IS_STANDARD,
);
}
return existingFlatSkill;
};
@@ -0,0 +1,18 @@
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { type SkillDTO } from 'src/engine/metadata-modules/skill/dtos/skill.dto';
export const fromFlatSkillToSkillDto = (flatSkill: FlatSkill): SkillDTO => ({
id: flatSkill.id,
standardId: flatSkill.standardId,
name: flatSkill.name,
label: flatSkill.label,
icon: flatSkill.icon ?? undefined,
description: flatSkill.description ?? undefined,
content: flatSkill.content,
isCustom: flatSkill.isCustom,
isActive: flatSkill.isActive,
workspaceId: flatSkill.workspaceId,
applicationId: flatSkill.applicationId ?? undefined,
createdAt: new Date(flatSkill.createdAt),
updatedAt: new Date(flatSkill.updatedAt),
});
@@ -0,0 +1,50 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { FLAT_SKILL_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-skill/constants/flat-skill-editable-properties.constant';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { type UpdateSkillInput } from 'src/engine/metadata-modules/skill/dtos/update-skill.input';
import {
SkillException,
SkillExceptionCode,
} from 'src/engine/metadata-modules/skill/skill.exception';
import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util';
export const fromUpdateSkillInputToFlatSkillToUpdateOrThrow = ({
flatSkillMaps,
updateSkillInput,
}: {
flatSkillMaps: FlatEntityMaps<FlatSkill>;
updateSkillInput: UpdateSkillInput;
}): FlatSkill => {
const existingFlatSkill = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: updateSkillInput.id,
flatEntityMaps: flatSkillMaps,
});
if (!isDefined(existingFlatSkill)) {
throw new SkillException(
'Skill not found',
SkillExceptionCode.SKILL_NOT_FOUND,
);
}
if (!existingFlatSkill.isCustom) {
throw new SkillException(
'Cannot update standard skill',
SkillExceptionCode.SKILL_IS_STANDARD,
);
}
const { id: _id, ...updates } = updateSkillInput;
return {
...mergeUpdateInExistingRecord({
existing: existingFlatSkill,
properties: [...FLAT_SKILL_EDITABLE_PROPERTIES],
update: updates,
}),
updatedAt: new Date().toISOString(),
};
};
@@ -0,0 +1,23 @@
import { type SkillEntity } from 'src/engine/metadata-modules/skill/entities/skill.entity';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
export const transformSkillEntityToFlatSkill = (
skillEntity: SkillEntity,
): FlatSkill => {
return {
createdAt: skillEntity.createdAt.toISOString(),
updatedAt: skillEntity.updatedAt.toISOString(),
id: skillEntity.id,
standardId: skillEntity.standardId,
name: skillEntity.name,
label: skillEntity.label,
icon: skillEntity.icon,
description: skillEntity.description,
content: skillEntity.content,
workspaceId: skillEntity.workspaceId,
isCustom: skillEntity.isCustom,
isActive: skillEntity.isActive,
universalIdentifier: skillEntity.standardId || skillEntity.id,
applicationId: skillEntity.applicationId,
};
};
@@ -15,6 +15,7 @@ import { RouteTriggerModule } from 'src/engine/metadata-modules/route-trigger/ro
import { SearchFieldMetadataModule } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.module';
import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.module';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { SkillModule } from 'src/engine/metadata-modules/skill/skill.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
@@ -27,6 +28,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-
SearchFieldMetadataModule,
ServerlessFunctionModule,
ServerlessFunctionLayerModule,
SkillModule,
AiAgentModule,
AiAgentMonitorModule,
AiChatModule,
@@ -47,6 +49,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-
ObjectMetadataModule,
SearchFieldMetadataModule,
ServerlessFunctionModule,
SkillModule,
AiAgentModule,
AiChatModule,
ViewModule,
@@ -0,0 +1,31 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
@InputType()
export class CreateSkillInput {
@IsString()
@IsNotEmpty()
@Field()
name: string;
@IsString()
@IsNotEmpty()
@Field()
label: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
icon?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
description?: string;
@IsString()
@IsNotEmpty()
@Field()
content: string;
}
@@ -0,0 +1,65 @@
import { Field, HideField, ObjectType } from '@nestjs/graphql';
import {
IsBoolean,
IsDateString,
IsNotEmpty,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('Skill')
export class SkillDTO {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@Field(() => UUIDScalarType, { nullable: true })
standardId?: string | null;
@IsString()
@Field()
name: string;
@IsString()
@Field()
label: string;
@IsString()
@Field({ nullable: true })
icon?: string;
@IsString()
@Field({ nullable: true })
description?: string;
@IsString()
@IsNotEmpty()
@Field()
content: string;
@IsBoolean()
@Field()
isCustom: boolean;
@IsBoolean()
@Field()
isActive: boolean;
@HideField()
workspaceId: string;
@Field(() => UUIDScalarType, { nullable: true })
applicationId?: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
}
@@ -0,0 +1,49 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpdateSkillInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
name?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
label?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
icon?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
description?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
content?: string;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
isActive?: boolean;
}
@@ -0,0 +1,54 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface';
@Entity('skill')
@Index('IDX_SKILL_ID_IS_ACTIVE', ['id', 'isActive'])
@Index('IDX_SKILL_NAME_WORKSPACE_ID_UNIQUE', ['name', 'workspaceId'], {
unique: true,
where: '"isActive" = true',
})
export class SkillEntity
extends SyncableEntity
implements Required<SkillEntity>
{
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true, type: 'uuid' })
standardId: string | null;
@Column({ nullable: false })
name: string;
@Column({ nullable: false })
label: string;
@Column({ nullable: true, type: 'varchar' })
icon: string | null;
@Column({ nullable: true, type: 'text' })
description: string | null;
@Column({ nullable: false, type: 'text' })
content: string;
@Column({ default: false })
isCustom: boolean;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,20 @@
import {
type CallHandler,
type ExecutionContext,
Injectable,
type NestInterceptor,
} from '@nestjs/common';
import { type Observable, catchError } from 'rxjs';
import { skillGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/skill/utils/skill-graphql-api-exception-handler.util';
@Injectable()
export class SkillGraphqlApiExceptionInterceptor implements NestInterceptor {
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<unknown> {
return next.handle().pipe(catchError(skillGraphqlApiExceptionHandler));
}
}
@@ -0,0 +1,40 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum SkillExceptionCode {
SKILL_NOT_FOUND = 'SKILL_NOT_FOUND',
SKILL_ALREADY_EXISTS = 'SKILL_ALREADY_EXISTS',
SKILL_IS_STANDARD = 'SKILL_IS_STANDARD',
INVALID_SKILL_INPUT = 'INVALID_SKILL_INPUT',
}
const getSkillExceptionUserFriendlyMessage = (code: SkillExceptionCode) => {
switch (code) {
case SkillExceptionCode.SKILL_NOT_FOUND:
return msg`Skill not found.`;
case SkillExceptionCode.SKILL_ALREADY_EXISTS:
return msg`A skill with this name already exists.`;
case SkillExceptionCode.SKILL_IS_STANDARD:
return msg`Standard skills cannot be modified.`;
case SkillExceptionCode.INVALID_SKILL_INPUT:
return msg`Invalid skill input.`;
default:
assertUnreachable(code);
}
};
export class SkillException extends CustomException<SkillExceptionCode> {
constructor(
message: string,
code: SkillExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ?? getSkillExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FlatSkillModule } from 'src/engine/metadata-modules/flat-skill/flat-skill.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { SkillGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/skill/interceptors/skill-graphql-api-exception.interceptor';
import { SkillResolver } from 'src/engine/metadata-modules/skill/skill.resolver';
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
@Module({
imports: [
WorkspaceManyOrAllFlatEntityMapsCacheModule,
WorkspaceMigrationV2Module,
ApplicationModule,
PermissionsModule,
FlatSkillModule,
],
providers: [
SkillService,
SkillResolver,
SkillGraphqlApiExceptionInterceptor,
WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor,
],
exports: [SkillService],
})
export class SkillModule {}
@@ -0,0 +1,81 @@
import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateSkillInput } from 'src/engine/metadata-modules/skill/dtos/create-skill.input';
import { SkillDTO } from 'src/engine/metadata-modules/skill/dtos/skill.dto';
import { UpdateSkillInput } from 'src/engine/metadata-modules/skill/dtos/update-skill.input';
import { SkillGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/skill/interceptors/skill-graphql-api-exception.interceptor';
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
@UseGuards(WorkspaceAuthGuard, SettingsPermissionGuard(PermissionFlagType.AI))
@UseInterceptors(
WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor,
SkillGraphqlApiExceptionInterceptor,
)
@Resolver(() => SkillDTO)
export class SkillResolver {
constructor(private readonly skillService: SkillService) {}
@Query(() => [SkillDTO])
async skills(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SkillDTO[]> {
return this.skillService.findAll(workspace.id);
}
@Query(() => SkillDTO, { nullable: true })
async skill(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SkillDTO | null> {
return this.skillService.findById(id, workspace.id);
}
@Mutation(() => SkillDTO)
async createSkill(
@Args('input') input: CreateSkillInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SkillDTO> {
return this.skillService.create(input, workspace.id);
}
@Mutation(() => SkillDTO)
async updateSkill(
@Args('input') input: UpdateSkillInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SkillDTO> {
return this.skillService.update(input, workspace.id);
}
@Mutation(() => SkillDTO)
async deleteSkill(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SkillDTO> {
return this.skillService.delete(id, workspace.id);
}
@Mutation(() => SkillDTO)
async activateSkill(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SkillDTO> {
return this.skillService.activate(id, workspace.id);
}
@Mutation(() => SkillDTO)
async deactivateSkill(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SkillDTO> {
return this.skillService.deactivate(id, workspace.id);
}
}
@@ -0,0 +1,381 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { fromCreateSkillInputToFlatSkillToCreate } from 'src/engine/metadata-modules/flat-skill/utils/from-create-skill-input-to-flat-skill-to-create.util';
import { fromDeleteSkillInputToFlatSkillOrThrow } from 'src/engine/metadata-modules/flat-skill/utils/from-delete-skill-input-to-flat-skill-or-throw.util';
import { fromFlatSkillToSkillDto } from 'src/engine/metadata-modules/flat-skill/utils/from-flat-skill-to-skill-dto.util';
import { fromUpdateSkillInputToFlatSkillToUpdateOrThrow } from 'src/engine/metadata-modules/flat-skill/utils/from-update-skill-input-to-flat-skill-to-update-or-throw.util';
import { type CreateSkillInput } from 'src/engine/metadata-modules/skill/dtos/create-skill.input';
import { type SkillDTO } from 'src/engine/metadata-modules/skill/dtos/skill.dto';
import { type UpdateSkillInput } from 'src/engine/metadata-modules/skill/dtos/update-skill.input';
import {
SkillException,
SkillExceptionCode,
} from 'src/engine/metadata-modules/skill/skill.exception';
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service';
@Injectable()
export class SkillService {
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
) {}
async findAll(workspaceId: string): Promise<SkillDTO[]> {
const { flatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
return Object.values(flatSkillMaps.byId)
.filter(isDefined)
.sort((a, b) => a.label.localeCompare(b.label))
.map(fromFlatSkillToSkillDto);
}
async findById(id: string, workspaceId: string): Promise<SkillDTO | null> {
const { flatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
const flatSkill = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: id,
flatEntityMaps: flatSkillMaps,
});
if (!isDefined(flatSkill)) {
return null;
}
return fromFlatSkillToSkillDto(flatSkill);
}
async create(
input: CreateSkillInput,
workspaceId: string,
): Promise<SkillDTO> {
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const flatSkillToCreate = fromCreateSkillInputToFlatSkillToCreate({
createSkillInput: input,
workspaceId,
applicationId: workspaceCustomFlatApplication.id,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
skill: {
flatEntityToCreate: [flatSkillToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while creating skill',
);
}
const { flatSkillMaps: recomputedFlatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
return fromFlatSkillToSkillDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: flatSkillToCreate.id,
flatEntityMaps: recomputedFlatSkillMaps,
}),
);
}
async update(
input: UpdateSkillInput,
workspaceId: string,
): Promise<SkillDTO> {
const { flatSkillMaps: existingFlatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
const flatSkillToUpdate = fromUpdateSkillInputToFlatSkillToUpdateOrThrow({
flatSkillMaps: existingFlatSkillMaps,
updateSkillInput: input,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
skill: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatSkillToUpdate],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while updating skill',
);
}
const { flatSkillMaps: recomputedFlatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
return fromFlatSkillToSkillDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: input.id,
flatEntityMaps: recomputedFlatSkillMaps,
}),
);
}
async delete(id: string, workspaceId: string): Promise<SkillDTO> {
const { flatSkillMaps: existingFlatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
const flatSkillToDelete = fromDeleteSkillInputToFlatSkillOrThrow({
flatSkillMaps: existingFlatSkillMaps,
skillId: id,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
skill: {
flatEntityToCreate: [],
flatEntityToDelete: [flatSkillToDelete],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while deleting skill',
);
}
return fromFlatSkillToSkillDto(flatSkillToDelete);
}
async findAllFlatSkills(workspaceId: string): Promise<FlatSkill[]> {
const { flatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
return Object.values(flatSkillMaps.byId)
.filter(isDefined)
.filter((flatSkill) => flatSkill.isActive)
.sort((a, b) => a.label.localeCompare(b.label));
}
async findFlatSkillsByNames(
names: string[],
workspaceId: string,
): Promise<FlatSkill[]> {
if (names.length === 0) {
return [];
}
const { flatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
return Object.values(flatSkillMaps.byId)
.filter(isDefined)
.filter(
(flatSkill) => names.includes(flatSkill.name) && flatSkill.isActive,
);
}
async activate(id: string, workspaceId: string): Promise<SkillDTO> {
const { flatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
const existingFlatSkill = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: flatSkillMaps,
});
const flatSkillToUpdate: FlatSkill = {
...existingFlatSkill,
isActive: true,
updatedAt: new Date().toISOString(),
};
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
skill: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatSkillToUpdate],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while activating skill',
);
}
const { flatSkillMaps: recomputedFlatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
return fromFlatSkillToSkillDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedFlatSkillMaps,
}),
);
}
async deactivate(id: string, workspaceId: string): Promise<SkillDTO> {
const { flatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
const existingFlatSkill = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: flatSkillMaps,
});
const flatSkillToUpdate: FlatSkill = {
...existingFlatSkill,
isActive: false,
updatedAt: new Date().toISOString(),
};
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
skill: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatSkillToUpdate],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while deactivating skill',
);
}
const { flatSkillMaps: recomputedFlatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
return fromFlatSkillToSkillDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedFlatSkillMaps,
}),
);
}
async findByIdOrThrow(id: string, workspaceId: string): Promise<SkillDTO> {
const skill = await this.findById(id, workspaceId);
if (!isDefined(skill)) {
throw new SkillException(
'Skill not found',
SkillExceptionCode.SKILL_NOT_FOUND,
);
}
return skill;
}
}
@@ -0,0 +1,32 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
ConflictError,
ForbiddenError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
SkillException,
SkillExceptionCode,
} from 'src/engine/metadata-modules/skill/skill.exception';
export const skillGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof SkillException) {
switch (error.code) {
case SkillExceptionCode.SKILL_NOT_FOUND:
throw new NotFoundError(error);
case SkillExceptionCode.INVALID_SKILL_INPUT:
throw new UserInputError(error);
case SkillExceptionCode.SKILL_ALREADY_EXISTS:
throw new ConflictError(error);
case SkillExceptionCode.SKILL_IS_STANDARD:
throw new ForbiddenError(error);
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};
@@ -31,6 +31,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = {
flatRoleTargetMaps: 'flat-maps:role-target',
ORMEntityMetadatas: 'orm:entity-metadatas',
flatAgentMaps: 'flat-maps:agent',
flatSkillMaps: 'flat-maps:skill',
flatRoleTargetByAgentIdMaps: 'flat-maps:flatRoleTargetByAgentId',
flatPageLayoutMaps: 'flat-maps:page-layout',
flatPageLayoutWidgetMaps: 'flat-maps:page-layout-widget',
@@ -0,0 +1,37 @@
export const STANDARD_SKILL = {
'workflow-building': {
universalIdentifier: '20202020-6155-838a-b64e-44a791fbdc13',
},
'data-manipulation': {
universalIdentifier: '20202020-e225-f5c7-3d56-45feaa36f2e6',
},
'dashboard-building': {
universalIdentifier: '20202020-398f-0d7a-82db-4f43bc7e7044',
},
'metadata-building': {
universalIdentifier: '20202020-c66a-5fed-4a74-46e0b42a6332',
},
research: {
universalIdentifier: '20202020-db75-4fca-6813-4c7db0f964a0',
},
'code-interpreter': {
universalIdentifier: '20202020-5eb9-e775-cf4e-4f22be7be362',
},
xlsx: {
universalIdentifier: '20202020-2c7f-5b77-dfa4-494b84752ab7',
},
pdf: {
universalIdentifier: '20202020-c3d1-e0c9-2f93-45648b8bbd26',
},
docx: {
universalIdentifier: '20202020-6f15-2432-0537-4e23a2efd1cb',
},
pptx: {
universalIdentifier: '20202020-c81b-baf8-5255-4c34bd0eac9b',
},
} as const satisfies Record<
string,
{
universalIdentifier: string;
}
>;
@@ -10,4 +10,5 @@ export const TWENTY_STANDARD_ALL_METADATA_NAME = [
'view',
'role',
'agent',
'skill',
] as const satisfies AllMetadataName[];
@@ -0,0 +1,3 @@
import type { STANDARD_SKILL } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-skill.constant';
export type AllStandardSkillName = keyof typeof STANDARD_SKILL;
@@ -0,0 +1,25 @@
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
import { type CreateStandardSkillArgs } from 'src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-skill-flat-metadata.util';
import { STANDARD_FLAT_SKILL_METADATA_BUILDERS_BY_SKILL_NAME } from 'src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util';
export const buildStandardFlatSkillMetadataMaps = (
args: Omit<CreateStandardSkillArgs, 'context'>,
): FlatEntityMaps<FlatSkill> => {
const allSkillMetadatas: FlatSkill[] = Object.values(
STANDARD_FLAT_SKILL_METADATA_BUILDERS_BY_SKILL_NAME,
).map((builder) => builder(args));
let flatSkillMetadataMaps = createEmptyFlatEntityMaps();
for (const skillMetadata of allSkillMetadatas) {
flatSkillMetadataMaps = addFlatEntityToFlatEntityMapsOrThrow({
flatEntity: skillMetadata,
flatEntityMaps: flatSkillMetadataMaps,
});
}
return flatSkillMetadataMaps;
};
@@ -0,0 +1,46 @@
import { v4 } from 'uuid';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { STANDARD_SKILL } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-skill.constant';
import { type AllStandardSkillName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-skill-name.type';
import { type StandardBuilderArgs } from 'src/engine/workspace-manager/twenty-standard-application/types/metadata-standard-buillder-args.type';
export type CreateStandardSkillContext = {
skillName: AllStandardSkillName;
name: string;
label: string;
icon: string | null;
description: string | null;
content: string;
isCustom: boolean;
};
export type CreateStandardSkillArgs = StandardBuilderArgs<'skill'> & {
context: CreateStandardSkillContext;
};
export const createStandardSkillFlatMetadata = ({
context: { skillName, name, label, icon, description, content, isCustom },
workspaceId,
twentyStandardApplicationId,
now,
}: CreateStandardSkillArgs): FlatSkill => {
const universalIdentifier = STANDARD_SKILL[skillName].universalIdentifier;
return {
id: v4(),
universalIdentifier,
standardId: universalIdentifier,
name,
label,
icon,
description,
content,
isCustom,
isActive: true,
workspaceId,
applicationId: twentyStandardApplicationId,
createdAt: now,
updatedAt: now,
};
};
@@ -1,6 +1,7 @@
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { type TwentyStandardAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/types/twenty-standard-all-flat-entity-maps.type';
import { buildStandardFlatAgentMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/agent-metadata/build-standard-flat-agent-metadata-maps.util';
import { buildStandardFlatSkillMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/build-standard-flat-skill-metadata-maps.util';
import { buildStandardFlatFieldMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/build-standard-flat-field-metadata-maps.util';
import { getStandardObjectMetadataRelatedEntityIds } from 'src/engine/workspace-manager/twenty-standard-application/utils/get-standard-object-metadata-related-entity-ids.util';
import { buildStandardFlatIndexMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/build-standard-flat-index-metadata-maps.util';
@@ -122,6 +123,14 @@ export const computeTwentyStandardApplicationAllFlatEntityMaps = ({
},
});
const flatSkillMaps = buildStandardFlatSkillMetadataMaps({
now,
workspaceId,
twentyStandardApplicationId,
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps: undefined,
});
return {
flatViewFieldMaps,
flatViewFilterMaps,
@@ -132,5 +141,6 @@ export const computeTwentyStandardApplicationAllFlatEntityMaps = ({
flatObjectMetadataMaps,
flatRoleMaps,
flatAgentMaps,
flatSkillMaps,
};
};
@@ -18,6 +18,7 @@ export const fromWorkspaceMigrationBuilderExceptionToMetadataValidationResponseE
return {
summary: {
invalidAgent: 0,
invalidSkill: 0,
invalidViewFilter: 0,
invalidViewFilterGroup: 0,
invalidObjectMetadata: 0,
@@ -29,6 +29,7 @@ import { WorkspaceMigrationV2RouteTriggerActionsBuilderService } from 'src/engin
import { WorkspaceMigrationV2RowLevelPermissionPredicateGroupActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/row-level-permission-predicate-group/workspace-migration-v2-row-level-permission-predicate-group-actions-builder.service';
import { WorkspaceMigrationV2RowLevelPermissionPredicateActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/row-level-permission-predicate/workspace-migration-v2-row-level-permission-predicate-actions-builder.service';
import { WorkspaceMigrationV2ServerlessFunctionActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/serverless-function/workspace-migration-v2-serverless-function-actions-builder.service';
import { WorkspaceMigrationV2SkillActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/skill/workspace-migration-v2-skill-actions-builder.service';
import { WorkspaceMigrationV2ViewFieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/view-field/workspace-migration-v2-view-field-actions-builder.service';
import { WorkspaceMigrationV2ViewFilterGroupActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/view-filter-group/workspace-migration-v2-view-filter-group-actions-builder.service';
import { WorkspaceMigrationV2ViewFilterActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/view-filter/workspace-migration-v2-view-filter-actions-builder.service';
@@ -53,6 +54,7 @@ export class WorkspaceMigrationBuildOrchestratorService {
private readonly workspaceMigrationV2FieldActionsBuilderService: WorkspaceMigrationV2FieldActionsBuilderService,
private readonly workspaceMigrationV2RoleActionsBuilderService: WorkspaceMigrationV2RoleActionsBuilderService,
private readonly workspaceMigrationV2AgentActionsBuilderService: WorkspaceMigrationV2AgentActionsBuilderService,
private readonly workspaceMigrationV2SkillActionsBuilderService: WorkspaceMigrationV2SkillActionsBuilderService,
private readonly workspaceMigrationV2PageLayoutActionsBuilderService: WorkspaceMigrationV2PageLayoutActionsBuilderService,
private readonly workspaceMigrationV2PageLayoutWidgetActionsBuilderService: WorkspaceMigrationV2PageLayoutWidgetActionsBuilderService,
private readonly workspaceMigrationV2PageLayoutTabActionsBuilderService: WorkspaceMigrationV2PageLayoutTabActionsBuilderService,
@@ -153,6 +155,7 @@ export class WorkspaceMigrationBuildOrchestratorService {
flatRoleMaps,
flatRoleTargetMaps,
flatAgentMaps,
flatSkillMaps,
flatPageLayoutMaps,
flatPageLayoutWidgetMaps,
flatPageLayoutTabMaps,
@@ -781,6 +784,36 @@ export class WorkspaceMigrationBuildOrchestratorService {
}
}
if (isDefined(flatSkillMaps)) {
const { from: fromFlatSkillMaps, to: toFlatSkillMaps } = flatSkillMaps;
const skillResult =
await this.workspaceMigrationV2SkillActionsBuilderService.validateAndBuild(
{
additionalCacheDataMaps,
from: fromFlatSkillMaps,
to: toFlatSkillMaps,
buildOptions,
dependencyOptimisticFlatEntityMaps: undefined,
workspaceId,
},
);
this.mergeFlatEntityMapsAndRelatedFlatEntityMapsInAllFlatEntityMapsThroughMutation(
{
allFlatEntityMaps: optimisticAllFlatEntityMaps,
flatEntityMapsAndRelatedFlatEntityMaps:
skillResult.optimisticFlatEntityMapsAndRelatedFlatEntityMaps,
},
);
if (skillResult.status === 'fail') {
orchestratorFailureReport.skill.push(...skillResult.errors);
} else {
orchestratorActionsReport.skill = skillResult.actions;
}
}
if (isDefined(flatPageLayoutMaps)) {
const { from: fromFlatPageLayoutMaps, to: toFlatPageLayoutMaps } =
flatPageLayoutMaps;
@@ -988,6 +1021,12 @@ export class WorkspaceMigrationBuildOrchestratorService {
...aggregatedOrchestratorActionsReport.agent.updated,
///
// Skills
...aggregatedOrchestratorActionsReport.skill.deleted,
...aggregatedOrchestratorActionsReport.skill.created,
...aggregatedOrchestratorActionsReport.skill.updated,
///
// Page layouts
...aggregatedOrchestratorActionsReport.pageLayout.deleted,
...aggregatedOrchestratorActionsReport.pageLayout.created,
@@ -0,0 +1,18 @@
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { type FlatEntityPropertiesUpdates } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-properties-updates.type';
export type UpdateSkillAction = {
type: 'update_skill';
flatEntityId: string;
flatEntityUpdates: FlatEntityPropertiesUpdates<'skill'>;
};
export type CreateSkillAction = {
type: 'create_skill';
flatEntity: FlatSkill;
};
export type DeleteSkillAction = {
type: 'delete_skill';
flatEntityId: string;
};
@@ -0,0 +1,96 @@
import { Injectable } from '@nestjs/common';
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { UpdateSkillAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/skill/types/workspace-migration-v2-skill-action.type';
import { WorkspaceEntityMigrationBuilderV2Service } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/services/workspace-entity-migration-builder-v2.service';
import { FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-update-validation-args.type';
import { FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-validation-args.type';
import { FlatEntityValidationReturnType } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-validation-result.type';
import { FlatSkillValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-skill-validator.service';
@Injectable()
export class WorkspaceMigrationV2SkillActionsBuilderService extends WorkspaceEntityMigrationBuilderV2Service<
typeof ALL_METADATA_NAME.skill
> {
constructor(
private readonly flatSkillValidatorService: FlatSkillValidatorService,
) {
super(ALL_METADATA_NAME.skill);
}
protected validateFlatEntityCreation(
args: FlatEntityValidationArgs<typeof ALL_METADATA_NAME.skill>,
): FlatEntityValidationReturnType<typeof ALL_METADATA_NAME.skill, 'created'> {
const validationResult =
this.flatSkillValidatorService.validateFlatSkillCreation(args);
if (validationResult.errors.length > 0) {
return {
status: 'fail',
...validationResult,
};
}
const { flatEntityToValidate: flatSkillToValidate } = args;
return {
status: 'success',
action: {
type: 'create_skill',
flatEntity: flatSkillToValidate,
},
};
}
protected validateFlatEntityDeletion(
args: FlatEntityValidationArgs<typeof ALL_METADATA_NAME.skill>,
): FlatEntityValidationReturnType<typeof ALL_METADATA_NAME.skill, 'deleted'> {
const validationResult =
this.flatSkillValidatorService.validateFlatSkillDeletion(args);
if (validationResult.errors.length > 0) {
return {
status: 'fail',
...validationResult,
};
}
const { flatEntityToValidate: flatSkillToValidate } = args;
return {
status: 'success',
action: {
type: 'delete_skill',
flatEntityId: flatSkillToValidate.id,
},
};
}
protected validateFlatEntityUpdate(
args: FlatEntityUpdateValidationArgs<typeof ALL_METADATA_NAME.skill>,
): FlatEntityValidationReturnType<typeof ALL_METADATA_NAME.skill, 'updated'> {
const validationResult =
this.flatSkillValidatorService.validateFlatSkillUpdate(args);
if (validationResult.errors.length > 0) {
return {
status: 'fail',
...validationResult,
};
}
const { flatEntityId, flatEntityUpdates } = args;
const updateSkillAction: UpdateSkillAction = {
type: 'update_skill',
flatEntityId,
flatEntityUpdates,
};
return {
status: 'success',
action: updateSkillAction,
};
}
}
@@ -0,0 +1,217 @@
import { Injectable } from '@nestjs/common';
import { msg, t } from '@lingui/core/macro';
import { type ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { SkillExceptionCode } from 'src/engine/metadata-modules/skill/skill.exception';
import { isStandardMetadata } from 'src/engine/metadata-modules/utils/is-standard-metadata.util';
import { findFlatEntityPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration-v2/utils/find-flat-entity-property-update.util';
import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/types/failed-flat-entity-validation.type';
import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-update-validation-args.type';
import { type FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-validation-args.type';
import { validateSkillNameUniqueness } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/utils/validate-skill-name-uniqueness.util';
import {
validateSkillContentIsDefined,
validateSkillLabelIsDefined,
validateSkillRequiredProperties,
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/utils/validate-skill-required-properties.util';
import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/utils/from-flat-entity-properties-updates-to-partial-flat-entity';
@Injectable()
export class FlatSkillValidatorService {
public validateFlatSkillCreation({
flatEntityToValidate: flatSkill,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatSkillMaps: optimisticFlatSkillMaps,
},
}: FlatEntityValidationArgs<
typeof ALL_METADATA_NAME.skill
>): FailedFlatEntityValidation<FlatSkill> {
const validationResult: FailedFlatEntityValidation<FlatSkill> = {
type: 'create_skill',
errors: [],
flatEntityMinimalInformation: {
id: flatSkill.id,
name: flatSkill.name,
},
};
const existingSkills = Object.values(optimisticFlatSkillMaps.byId).filter(
isDefined,
);
validationResult.errors.push(
...validateSkillRequiredProperties({ flatSkill }),
);
validationResult.errors.push(
...validateSkillNameUniqueness({
name: flatSkill.name,
existingFlatSkills: existingSkills,
}),
);
return validationResult;
}
public validateFlatSkillDeletion({
flatEntityToValidate,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatSkillMaps: optimisticFlatSkillMaps,
},
buildOptions,
}: FlatEntityValidationArgs<
typeof ALL_METADATA_NAME.skill
>): FailedFlatEntityValidation<FlatSkill> {
const validationResult: FailedFlatEntityValidation<FlatSkill> = {
type: 'delete_skill',
errors: [],
flatEntityMinimalInformation: {
id: flatEntityToValidate.id,
name: flatEntityToValidate.name,
},
};
const existingSkill = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: flatEntityToValidate.id,
flatEntityMaps: optimisticFlatSkillMaps,
});
if (!isDefined(existingSkill)) {
validationResult.errors.push({
code: SkillExceptionCode.SKILL_NOT_FOUND,
message: t`Skill not found`,
userFriendlyMessage: msg`Skill not found`,
});
return validationResult;
}
if (!buildOptions.isSystemBuild && isStandardMetadata(existingSkill)) {
validationResult.errors.push({
code: SkillExceptionCode.SKILL_IS_STANDARD,
message: t`Cannot delete standard skill`,
userFriendlyMessage: msg`Cannot delete standard skill`,
});
}
return validationResult;
}
public validateFlatSkillUpdate({
flatEntityId,
flatEntityUpdates,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatSkillMaps: optimisticFlatSkillMaps,
},
buildOptions,
}: FlatEntityUpdateValidationArgs<
typeof ALL_METADATA_NAME.skill
>): FailedFlatEntityValidation<FlatSkill> {
const validationResult: FailedFlatEntityValidation<FlatSkill> = {
type: 'update_skill',
errors: [],
flatEntityMinimalInformation: {
id: flatEntityId,
},
};
const fromFlatSkill = findFlatEntityByIdInFlatEntityMaps({
flatEntityId,
flatEntityMaps: optimisticFlatSkillMaps,
});
if (!isDefined(fromFlatSkill)) {
validationResult.errors.push({
code: SkillExceptionCode.SKILL_NOT_FOUND,
message: t`Skill not found`,
userFriendlyMessage: msg`Skill not found`,
});
return validationResult;
}
// Standard skills can only have isActive toggled, not other properties
const isActiveUpdate = findFlatEntityPropertyUpdate({
flatEntityUpdates,
property: 'isActive',
});
const hasNonIsActiveUpdates = flatEntityUpdates.some(
(update) => update.property !== 'isActive',
);
if (
!buildOptions.isSystemBuild &&
isStandardMetadata(fromFlatSkill) &&
hasNonIsActiveUpdates
) {
validationResult.errors.push({
code: SkillExceptionCode.SKILL_IS_STANDARD,
message: t`Cannot update standard skill properties (only activation/deactivation allowed)`,
userFriendlyMessage: msg`Cannot update standard skill properties (only activation/deactivation allowed)`,
});
}
// If only isActive is being updated on a standard skill, allow it
if (
isStandardMetadata(fromFlatSkill) &&
isDefined(isActiveUpdate) &&
!hasNonIsActiveUpdates
) {
return validationResult;
}
const optimisticFlatSkill: FlatSkill = {
...fromFlatSkill,
...fromFlatEntityPropertiesUpdatesToPartialFlatEntity({
updates: flatEntityUpdates,
}),
};
const labelUpdate = findFlatEntityPropertyUpdate({
flatEntityUpdates,
property: 'label',
});
if (isDefined(labelUpdate)) {
validationResult.errors.push(
...validateSkillLabelIsDefined({ flatSkill: optimisticFlatSkill }),
);
}
const contentUpdate = findFlatEntityPropertyUpdate({
flatEntityUpdates,
property: 'content',
});
if (isDefined(contentUpdate)) {
validationResult.errors.push(
...validateSkillContentIsDefined({ flatSkill: optimisticFlatSkill }),
);
}
const nameUpdate = findFlatEntityPropertyUpdate({
flatEntityUpdates,
property: 'name',
});
if (isDefined(nameUpdate)) {
const existingSkills = Object.values(optimisticFlatSkillMaps.byId)
.filter(isDefined)
.filter((skill) => skill.id !== flatEntityId);
validationResult.errors.push(
...validateSkillNameUniqueness({
name: nameUpdate.to,
existingFlatSkills: existingSkills,
}),
);
}
return validationResult;
}
}
@@ -0,0 +1,25 @@
import { msg, t } from '@lingui/core/macro';
import { SkillExceptionCode } from 'src/engine/metadata-modules/skill/skill.exception';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { type FlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/types/failed-flat-entity-validation.type';
export const validateSkillNameUniqueness = ({
name,
existingFlatSkills,
}: {
name: string;
existingFlatSkills: FlatSkill[];
}): FlatEntityValidationError<SkillExceptionCode>[] => {
const errors: FlatEntityValidationError<SkillExceptionCode>[] = [];
if (existingFlatSkills.some((skill) => skill.name === name)) {
errors.push({
code: SkillExceptionCode.SKILL_ALREADY_EXISTS,
message: t`Skill with name "${name}" already exists`,
userFriendlyMessage: msg`A skill with this name already exists`,
});
}
return errors;
};
@@ -0,0 +1,51 @@
import { msg, t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { SkillExceptionCode } from 'src/engine/metadata-modules/skill/skill.exception';
import { type FlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/types/failed-flat-entity-validation.type';
export const validateSkillLabelIsDefined = ({
flatSkill,
}: {
flatSkill: FlatSkill;
}): FlatEntityValidationError<SkillExceptionCode>[] => {
if (isNonEmptyString(flatSkill.label)) {
return [];
}
return [
{
code: SkillExceptionCode.INVALID_SKILL_INPUT,
message: t`Label cannot be empty`,
userFriendlyMessage: msg`Label cannot be empty`,
},
];
};
export const validateSkillContentIsDefined = ({
flatSkill,
}: {
flatSkill: FlatSkill;
}): FlatEntityValidationError<SkillExceptionCode>[] => {
if (isNonEmptyString(flatSkill.content)) {
return [];
}
return [
{
code: SkillExceptionCode.INVALID_SKILL_INPUT,
message: t`Content cannot be empty`,
userFriendlyMessage: msg`Content cannot be empty`,
},
];
};
export const validateSkillRequiredProperties = ({
flatSkill,
}: {
flatSkill: FlatSkill;
}): FlatEntityValidationError<SkillExceptionCode>[] => [
...validateSkillLabelIsDefined({ flatSkill }),
...validateSkillContentIsDefined({ flatSkill }),
];
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service';
import { FlatAgentValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-agent-validator.service';
import { FlatSkillValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-skill-validator.service';
import { FlatCronTriggerValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-cron-trigger-validator.service';
import { FlatDatabaseEventTriggerValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-database-event-trigger-validator.service';
import { FlatFieldMetadataValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-field-metadata-validator.service';
@@ -42,6 +43,7 @@ import { FlatViewValidatorService } from 'src/engine/workspace-manager/workspace
FlatRoleValidatorService,
FlatRoleTargetValidatorService,
FlatAgentValidatorService,
FlatSkillValidatorService,
FlatPageLayoutValidatorService,
FlatPageLayoutWidgetValidatorService,
FlatPageLayoutTabValidatorService,
@@ -65,6 +67,7 @@ import { FlatViewValidatorService } from 'src/engine/workspace-manager/workspace
FlatRoleValidatorService,
FlatRoleTargetValidatorService,
FlatAgentValidatorService,
FlatSkillValidatorService,
FlatPageLayoutValidatorService,
FlatPageLayoutWidgetValidatorService,
FlatPageLayoutTabValidatorService,
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service';
import { WorkspaceMigrationV2AgentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/agent/workspace-migration-v2-agent-actions-builder.service';
import { WorkspaceMigrationV2SkillActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/skill/workspace-migration-v2-skill-actions-builder.service';
import { WorkspaceMigrationV2CronTriggerActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/cron-trigger/workspace-migration-v2-cron-trigger-action-builder.service';
import { WorkspaceMigrationV2DatabaseEventTriggerActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/database-event-trigger/workspace-migration-v2-database-event-trigger-actions-builder.service';
import { WorkspaceMigrationV2FieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/field/workspace-migration-v2-field-actions-builder.service';
@@ -43,6 +44,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace-
WorkspaceMigrationV2RoleActionsBuilderService,
WorkspaceMigrationV2RoleTargetActionsBuilderService,
WorkspaceMigrationV2AgentActionsBuilderService,
WorkspaceMigrationV2SkillActionsBuilderService,
WorkspaceMigrationV2PageLayoutActionsBuilderService,
WorkspaceMigrationV2PageLayoutWidgetActionsBuilderService,
WorkspaceMigrationV2PageLayoutTabActionsBuilderService,
@@ -65,6 +67,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace-
WorkspaceMigrationV2RoleActionsBuilderService,
WorkspaceMigrationV2RoleTargetActionsBuilderService,
WorkspaceMigrationV2AgentActionsBuilderService,
WorkspaceMigrationV2SkillActionsBuilderService,
WorkspaceMigrationV2PageLayoutActionsBuilderService,
WorkspaceMigrationV2PageLayoutWidgetActionsBuilderService,
WorkspaceMigrationV2PageLayoutTabActionsBuilderService,
@@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common';
import {
OptimisticallyApplyActionOnAllFlatEntityMapsArgs,
WorkspaceMigrationRunnerActionHandler,
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/interfaces/workspace-migration-runner-action-handler-service.interface';
import { SkillEntity } from 'src/engine/metadata-modules/skill/entities/skill.entity';
import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
import { CreateSkillAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/skill/types/workspace-migration-v2-skill-action.type';
import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/types/workspace-migration-action-runner-args.type';
@Injectable()
export class CreateSkillActionHandlerService extends WorkspaceMigrationRunnerActionHandler(
'create_skill',
) {
constructor() {
super();
}
optimisticallyApplyActionOnAllFlatEntityMaps({
action,
allFlatEntityMaps,
}: OptimisticallyApplyActionOnAllFlatEntityMapsArgs<CreateSkillAction>): Partial<AllFlatEntityMaps> {
const { flatSkillMaps } = allFlatEntityMaps;
const { flatEntity } = action;
const updatedFlatSkillMaps = addFlatEntityToFlatEntityMapsOrThrow({
flatEntity,
flatEntityMaps: flatSkillMaps,
});
return {
flatSkillMaps: updatedFlatSkillMaps,
};
}
async executeForMetadata(
context: WorkspaceMigrationActionRunnerArgs<CreateSkillAction>,
): Promise<void> {
const { action, queryRunner, workspaceId } = context;
const { flatEntity } = action;
const skillRepository =
queryRunner.manager.getRepository<SkillEntity>(SkillEntity);
await skillRepository.save({
...flatEntity,
workspaceId,
});
}
async executeForWorkspaceSchema(
_context: WorkspaceMigrationActionRunnerArgs<CreateSkillAction>,
): Promise<void> {
return;
}
}
@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import {
OptimisticallyApplyActionOnAllFlatEntityMapsArgs,
WorkspaceMigrationRunnerActionHandler,
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/interfaces/workspace-migration-runner-action-handler-service.interface';
import { SkillEntity } from 'src/engine/metadata-modules/skill/entities/skill.entity';
import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { deleteFlatEntityFromFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/delete-flat-entity-from-flat-entity-maps-or-throw.util';
import { DeleteSkillAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/skill/types/workspace-migration-v2-skill-action.type';
import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/types/workspace-migration-action-runner-args.type';
@Injectable()
export class DeleteSkillActionHandlerService extends WorkspaceMigrationRunnerActionHandler(
'delete_skill',
) {
constructor() {
super();
}
optimisticallyApplyActionOnAllFlatEntityMaps({
action,
allFlatEntityMaps,
}: OptimisticallyApplyActionOnAllFlatEntityMapsArgs<DeleteSkillAction>): Partial<AllFlatEntityMaps> {
const { flatSkillMaps } = allFlatEntityMaps;
const { flatEntityId } = action;
const updatedFlatSkillMaps = deleteFlatEntityFromFlatEntityMapsOrThrow({
entityToDeleteId: flatEntityId,
flatEntityMaps: flatSkillMaps,
});
return {
flatSkillMaps: updatedFlatSkillMaps,
};
}
async executeForMetadata(
context: WorkspaceMigrationActionRunnerArgs<DeleteSkillAction>,
): Promise<void> {
const { action, queryRunner, workspaceId } = context;
const { flatEntityId } = action;
const skillRepository =
queryRunner.manager.getRepository<SkillEntity>(SkillEntity);
await skillRepository.delete({ id: flatEntityId, workspaceId });
}
async executeForWorkspaceSchema(
_context: WorkspaceMigrationActionRunnerArgs<DeleteSkillAction>,
): Promise<void> {
return;
}
}
@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import {
OptimisticallyApplyActionOnAllFlatEntityMapsArgs,
WorkspaceMigrationRunnerActionHandler,
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/interfaces/workspace-migration-runner-action-handler-service.interface';
import { SkillEntity } from 'src/engine/metadata-modules/skill/entities/skill.entity';
import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { replaceFlatEntityInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/replace-flat-entity-in-flat-entity-maps-or-throw.util';
import { UpdateSkillAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/skill/types/workspace-migration-v2-skill-action.type';
import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/types/workspace-migration-action-runner-args.type';
import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/utils/from-flat-entity-properties-updates-to-partial-flat-entity';
@Injectable()
export class UpdateSkillActionHandlerService extends WorkspaceMigrationRunnerActionHandler(
'update_skill',
) {
optimisticallyApplyActionOnAllFlatEntityMaps({
action,
allFlatEntityMaps,
}: OptimisticallyApplyActionOnAllFlatEntityMapsArgs<UpdateSkillAction>): Partial<AllFlatEntityMaps> {
const { flatSkillMaps } = allFlatEntityMaps;
const { flatEntityId, flatEntityUpdates } = action;
const existingSkill = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId,
flatEntityMaps: flatSkillMaps,
});
const updatedSkill = {
...existingSkill,
...fromFlatEntityPropertiesUpdatesToPartialFlatEntity({
updates: flatEntityUpdates,
}),
};
const updatedFlatSkillMaps = replaceFlatEntityInFlatEntityMapsOrThrow({
flatEntity: updatedSkill,
flatEntityMaps: flatSkillMaps,
});
return {
flatSkillMaps: updatedFlatSkillMaps,
};
}
async executeForMetadata(
context: WorkspaceMigrationActionRunnerArgs<UpdateSkillAction>,
): Promise<void> {
const { action, queryRunner, workspaceId } = context;
const { flatEntityId, flatEntityUpdates } = action;
const skillRepository =
queryRunner.manager.getRepository<SkillEntity>(SkillEntity);
await skillRepository.update(
{ id: flatEntityId, workspaceId },
fromFlatEntityPropertiesUpdatesToPartialFlatEntity({
updates: flatEntityUpdates,
}),
);
}
async executeForWorkspaceSchema(
_context: WorkspaceMigrationActionRunnerArgs<UpdateSkillAction>,
): Promise<void> {
return;
}
}
@@ -4,6 +4,9 @@ import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-sc
import { CreateAgentActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/agent/services/create-agent-action-handler.service';
import { DeleteAgentActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/agent/services/delete-agent-action-handler.service';
import { UpdateAgentActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/agent/services/update-agent-action-handler.service';
import { CreateSkillActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/skill/services/create-skill-action-handler.service';
import { DeleteSkillActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/skill/services/delete-skill-action-handler.service';
import { UpdateSkillActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/skill/services/update-skill-action-handler.service';
import { CreateCronTriggerActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/cron-trigger/services/create-cron-trigger-action-handler.service';
import { DeleteCronTriggerActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/cron-trigger/services/delete-cron-trigger-action-handler.service';
import { UpdateCronTriggerActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/cron-trigger/services/update-cron-trigger-action-handler.service';
@@ -123,6 +126,10 @@ import { UpdateViewActionHandlerService } from 'src/engine/workspace-manager/wor
UpdateAgentActionHandlerService,
DeleteAgentActionHandlerService,
CreateSkillActionHandlerService,
UpdateSkillActionHandlerService,
DeleteSkillActionHandlerService,
CreatePageLayoutActionHandlerService,
UpdatePageLayoutActionHandlerService,
DeletePageLayoutActionHandlerService,
@@ -20,6 +20,7 @@ exports[`View Field Resolver - Failing Create Many Operations - v2 should accumu
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [
{
@@ -97,6 +98,7 @@ exports[`View Field Resolver - Failing Create Many Operations - v2 should accumu
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -20,6 +20,7 @@ exports[`View Field Resolver - Failing Create Operation - v2 Should fail to crea
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [
{
@@ -60,6 +61,7 @@ exports[`View Field Resolver - Failing Create Operation - v2 Should fail to crea
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -94,6 +96,7 @@ exports[`View Field Resolver - Failing Create Operation - v2 should fail to crea
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [
{
@@ -134,6 +137,7 @@ exports[`View Field Resolver - Failing Create Operation - v2 should fail to crea
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -168,6 +172,7 @@ exports[`View Field Resolver - Failing Create Operation - v2 should fail to crea
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [
{
@@ -208,6 +213,7 @@ exports[`View Field Resolver - Failing Create Operation - v2 should fail to crea
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -20,6 +20,7 @@ exports[`View Field Resolver - Successful object metadata identifier update side
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [
{
@@ -60,6 +61,7 @@ exports[`View Field Resolver - Successful object metadata identifier update side
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -94,6 +96,7 @@ exports[`View Field Resolver - Successful object metadata identifier update side
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [
{
@@ -134,6 +137,7 @@ exports[`View Field Resolver - Successful object metadata identifier update side
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -168,6 +172,7 @@ exports[`View Field Resolver - Successful object metadata identifier update side
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [
{
@@ -206,6 +211,7 @@ exports[`View Field Resolver - Successful object metadata identifier update side
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -240,6 +246,7 @@ exports[`View Field Resolver - Successful object metadata identifier update side
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [
{
@@ -280,6 +287,7 @@ exports[`View Field Resolver - Successful object metadata identifier update side
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -314,6 +322,7 @@ exports[`View Field Resolver - Successful object metadata identifier update side
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [
{
@@ -354,6 +363,7 @@ exports[`View Field Resolver - Successful object metadata identifier update side
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -36,6 +36,7 @@ exports[`Agent creation should fail when computed name already exists 1`] = `
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -59,6 +60,7 @@ exports[`Agent creation should fail when computed name already exists 1`] = `
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -109,6 +111,7 @@ exports[`Agent creation should fail when label is empty string 1`] = `
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -132,6 +135,7 @@ exports[`Agent creation should fail when label is empty string 1`] = `
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -196,6 +200,7 @@ exports[`Agent creation should fail when modelId is empty string 1`] = `
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -219,6 +224,7 @@ exports[`Agent creation should fail when modelId is empty string 1`] = `
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -283,6 +289,7 @@ exports[`Agent creation should fail when prompt is empty string 1`] = `
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -306,6 +313,7 @@ exports[`Agent creation should fail when prompt is empty string 1`] = `
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -370,6 +378,7 @@ exports[`Agent creation should fail when responseFormat has invalid type 1`] = `
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -393,6 +402,7 @@ exports[`Agent creation should fail when responseFormat has invalid type 1`] = `
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -443,6 +453,7 @@ exports[`Agent creation should fail when responseFormat type is json but schema
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -466,6 +477,7 @@ exports[`Agent creation should fail when responseFormat type is json but schema
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -516,6 +528,7 @@ exports[`Agent creation should fail when responseFormat type is text but schema
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -539,6 +552,7 @@ exports[`Agent creation should fail when responseFormat type is text but schema
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -36,6 +36,7 @@ exports[`Agent deletion should fail should fail when attempting to delete a stan
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -59,6 +60,7 @@ exports[`Agent deletion should fail should fail when attempting to delete a stan
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -35,6 +35,7 @@ exports[`Agent update should fail should fail when attempting to update a standa
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -58,6 +59,7 @@ exports[`Agent update should fail should fail when attempting to update a standa
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -129,6 +131,7 @@ exports[`Agent update should fail when settings null required properties 1`] = `
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -152,6 +155,7 @@ exports[`Agent update should fail when settings null required properties 1`] = `
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -201,6 +205,7 @@ exports[`Agent update should fail when updating label to one that already exists
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -224,6 +229,7 @@ exports[`Agent update should fail when updating label to one that already exists
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -273,6 +279,7 @@ exports[`Agent update should fail when updating responseFormat type to json with
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -296,6 +303,7 @@ exports[`Agent update should fail when updating responseFormat type to json with
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -345,6 +353,7 @@ exports[`Agent update should fail when updating responseFormat type to text with
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -368,6 +377,7 @@ exports[`Agent update should fail when updating responseFormat type to text with
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -417,6 +427,7 @@ exports[`Agent update should fail when updating responseFormat with invalid type
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -440,6 +451,7 @@ exports[`Agent update should fail when updating responseFormat with invalid type
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -489,6 +501,7 @@ exports[`Agent update should fail when updating with empty label 1`] = `
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -512,6 +525,7 @@ exports[`Agent update should fail when updating with empty label 1`] = `
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -561,6 +575,7 @@ exports[`Agent update should fail when updating with empty modelId 1`] = `
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -584,6 +599,7 @@ exports[`Agent update should fail when updating with empty modelId 1`] = `
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -633,6 +649,7 @@ exports[`Agent update should fail when updating with empty prompt 1`] = `
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -656,6 +673,7 @@ exports[`Agent update should fail when updating with empty prompt 1`] = `
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -38,6 +38,7 @@ exports[`createOne FieldMetadataService name/label sync should return an error w
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -61,6 +62,7 @@ exports[`createOne FieldMetadataService name/label sync should return an error w
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -39,6 +39,7 @@ exports[`Failing create field metadata tests suite should fail to create NUMERIC
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -62,6 +63,7 @@ exports[`Failing create field metadata tests suite should fail to create NUMERIC
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -116,6 +118,7 @@ exports[`Failing create field metadata tests suite should fail to create POSITIO
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -139,6 +142,7 @@ exports[`Failing create field metadata tests suite should fail to create POSITIO
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -186,6 +190,7 @@ exports[`Failing create field metadata tests suite should fail to create TS_VECT
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -209,6 +214,7 @@ exports[`Failing create field metadata tests suite should fail to create TS_VECT
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -38,6 +38,7 @@ exports[`updateOne failing update should not update a field name if it is not sy
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -61,6 +62,7 @@ exports[`updateOne failing update should not update a field name if it is not sy
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
@@ -120,6 +122,7 @@ exports[`updateOne failing update should throw if the field name is not availabl
"rowLevelPermissionPredicate": [],
"rowLevelPermissionPredicateGroup": [],
"serverlessFunction": [],
"skill": [],
"view": [],
"viewField": [],
"viewFilter": [],
@@ -143,6 +146,7 @@ exports[`updateOne failing update should throw if the field name is not availabl
"invalidRowLevelPermissionPredicate": 0,
"invalidRowLevelPermissionPredicateGroup": 0,
"invalidServerlessFunction": 0,
"invalidSkill": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,

Some files were not shown because too many files have changed in this diff Show More