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:
+21
-3
@@ -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
|
||||
|
||||
+1
@@ -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`,
|
||||
|
||||
+14
-3
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user