feat: Serverless Functions as AI Tools (#16919)
## Summary This PR enables serverless functions to be exposed as AI tools, allowing them to be used by AI agents. ### Changes - Added new `SERVERLESS_FUNCTION` tool category - Added `toolDescription`, `toolInputSchema`, and `toolOutputSchema` fields to serverless functions - Created database migration for the new schema columns - Added tool index query and resolver for fetching available tools - Added Settings AI page tabs (Skills, Tools, Settings) with new tools table - Added utility to convert tool schema to JSON schema format - Updated frontend to display tools in the settings page ### Implementation Details - Serverless functions can now define tool metadata (description, input/output schemas) - These functions are automatically registered in the tool registry - The tool index endpoint allows querying available tools with their schemas - Settings page now has a dedicated Tools tab showing all available tools
This commit is contained in:
+25
-17
@@ -12,9 +12,10 @@ import { type ActorMetadata } from 'twenty-shared/types';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
|
||||
|
||||
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
|
||||
import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
|
||||
import {
|
||||
AgentException,
|
||||
@@ -40,7 +41,7 @@ export class AgentAsyncExecutorService {
|
||||
constructor(
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly agentModelConfigService: AgentModelConfigService,
|
||||
private readonly toolProvider: ToolProviderService,
|
||||
private readonly toolRegistry: ToolRegistryService,
|
||||
@InjectRepository(RoleTargetEntity)
|
||||
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
|
||||
) {}
|
||||
@@ -119,21 +120,28 @@ export class AgentAsyncExecutorService {
|
||||
|
||||
// Workflow context: DATABASE_CRUD, ACTION, and NATIVE_MODEL tools only
|
||||
// Workflow tools are excluded to prevent circular dependencies
|
||||
tools = await this.toolProvider.getTools({
|
||||
workspaceId: agent.workspaceId,
|
||||
categories: [
|
||||
ToolCategory.DATABASE_CRUD,
|
||||
ToolCategory.ACTION,
|
||||
ToolCategory.NATIVE_MODEL,
|
||||
],
|
||||
rolePermissionConfig: effectiveRoleConfig,
|
||||
authContext,
|
||||
actorContext,
|
||||
agent: agent as unknown as Parameters<
|
||||
typeof this.toolProvider.getTools
|
||||
>[0]['agent'],
|
||||
wrapWithErrorContext: false,
|
||||
});
|
||||
const roleId = this.extractRoleIds(effectiveRoleConfig)[0] ?? '';
|
||||
|
||||
tools = await this.toolRegistry.getToolsByCategories(
|
||||
{
|
||||
workspaceId: agent.workspaceId,
|
||||
roleId,
|
||||
rolePermissionConfig: effectiveRoleConfig ?? { unionOf: [] },
|
||||
authContext,
|
||||
actorContext,
|
||||
agent: agent as unknown as ToolProviderContext['agent'],
|
||||
userId: authContext?.user?.id,
|
||||
userWorkspaceId: authContext?.userWorkspaceId,
|
||||
},
|
||||
{
|
||||
categories: [
|
||||
ToolCategory.DATABASE_CRUD,
|
||||
ToolCategory.ACTION,
|
||||
ToolCategory.NATIVE_MODEL,
|
||||
],
|
||||
wrapWithErrorContext: false,
|
||||
},
|
||||
);
|
||||
|
||||
providerOptions = this.agentModelConfigService.getProviderOptions(
|
||||
registeredModel,
|
||||
|
||||
+15
-12
@@ -387,12 +387,13 @@ ${preloadedTools.length > 0 ? preloadedTools.map((t) => `- \`${t}\` ✓`).join('
|
||||
### Tool Catalog by Category`);
|
||||
|
||||
const categoryOrder = [
|
||||
'database',
|
||||
'action',
|
||||
'workflow',
|
||||
'dashboard',
|
||||
'metadata',
|
||||
'view',
|
||||
'DATABASE',
|
||||
'ACTION',
|
||||
'WORKFLOW',
|
||||
'DASHBOARD',
|
||||
'METADATA',
|
||||
'VIEW',
|
||||
'SERVERLESS_FUNCTION',
|
||||
];
|
||||
|
||||
for (const category of categoryOrder) {
|
||||
@@ -426,18 +427,20 @@ ${tools
|
||||
|
||||
private getCategoryLabel(category: string): string {
|
||||
switch (category) {
|
||||
case 'database':
|
||||
case 'DATABASE':
|
||||
return 'Database Tools (CRUD operations)';
|
||||
case 'action':
|
||||
case 'ACTION':
|
||||
return 'Action Tools (HTTP, Email, etc.)';
|
||||
case 'workflow':
|
||||
case 'WORKFLOW':
|
||||
return 'Workflow Tools (create/manage workflows)';
|
||||
case 'metadata':
|
||||
case 'METADATA':
|
||||
return 'Metadata Tools (schema management)';
|
||||
case 'view':
|
||||
case 'VIEW':
|
||||
return 'View Tools (query views)';
|
||||
case 'dashboard':
|
||||
case 'DASHBOARD':
|
||||
return 'Dashboard Tools (create/manage dashboards)';
|
||||
case 'SERVERLESS_FUNCTION':
|
||||
return 'Serverless Functions (custom tools)';
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
|
||||
+115
-155
@@ -10,134 +10,98 @@ import { fromFlatFieldMetadataToFieldMetadataDto } from 'src/engine/metadata-mod
|
||||
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
|
||||
|
||||
const GetFieldMetadataInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
id: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe(
|
||||
'Unique identifier for the field metadata. If provided, returns a single field.',
|
||||
),
|
||||
objectMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('Filter fields by object metadata ID.'),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.default(100)
|
||||
.describe('Maximum number of fields to return.'),
|
||||
}),
|
||||
.describe(
|
||||
'Unique identifier for the field metadata. If provided, returns a single field.',
|
||||
),
|
||||
objectMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('Filter fields by object metadata ID.'),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.default(100)
|
||||
.describe('Maximum number of fields to return.'),
|
||||
});
|
||||
|
||||
const CreateFieldMetadataInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
objectMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('ID of the object to add the field to'),
|
||||
type: z
|
||||
.nativeEnum(FieldMetadataType)
|
||||
.describe(
|
||||
'Field type (e.g., TEXT, NUMBER, BOOLEAN, DATE_TIME, RELATION, etc.)',
|
||||
),
|
||||
name: z.string().describe('Internal name of the field (camelCase)'),
|
||||
label: z.string().describe('Display label of the field'),
|
||||
description: z.string().optional().describe('Description of the field'),
|
||||
icon: z.string().optional().describe('Icon identifier for the field'),
|
||||
isNullable: z.boolean().optional().describe('Whether the field can be null'),
|
||||
isUnique: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
objectMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('ID of the object to add the field to'),
|
||||
type: z
|
||||
.nativeEnum(FieldMetadataType)
|
||||
.describe(
|
||||
'Field type (e.g., TEXT, NUMBER, BOOLEAN, DATE_TIME, RELATION, etc.)',
|
||||
),
|
||||
name: z.string().describe('Internal name of the field (camelCase)'),
|
||||
label: z.string().describe('Display label of the field'),
|
||||
description: z.string().optional().describe('Description of the field'),
|
||||
icon: z.string().optional().describe('Icon identifier for the field'),
|
||||
isNullable: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether the field can be null'),
|
||||
isUnique: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether the field value must be unique'),
|
||||
defaultValue: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Default value for the field'),
|
||||
options: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Options for SELECT/MULTI_SELECT fields'),
|
||||
settings: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Additional settings for the field'),
|
||||
isLabelSyncedWithName: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether label should sync with name changes'),
|
||||
isRemoteCreation: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether this is a remote field creation'),
|
||||
relationCreationPayload: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Payload for creating relation fields'),
|
||||
}),
|
||||
.describe('Whether the field value must be unique'),
|
||||
defaultValue: z.unknown().optional().describe('Default value for the field'),
|
||||
options: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Options for SELECT/MULTI_SELECT fields'),
|
||||
settings: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Additional settings for the field'),
|
||||
isLabelSyncedWithName: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether label should sync with name changes'),
|
||||
isRemoteCreation: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether this is a remote field creation'),
|
||||
relationCreationPayload: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Payload for creating relation fields'),
|
||||
});
|
||||
|
||||
const UpdateFieldMetadataInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
id: z.string().uuid().describe('ID of the field to update'),
|
||||
name: z.string().optional().describe('Internal name of the field'),
|
||||
label: z.string().optional().describe('Display label of the field'),
|
||||
description: z.string().optional().describe('Description of the field'),
|
||||
icon: z.string().optional().describe('Icon identifier for the field'),
|
||||
isActive: z.boolean().optional().describe('Whether the field is active'),
|
||||
isNullable: z.boolean().optional().describe('Whether the field can be null'),
|
||||
isUnique: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
id: z.string().uuid().describe('ID of the field to update'),
|
||||
name: z.string().optional().describe('Internal name of the field'),
|
||||
label: z.string().optional().describe('Display label of the field'),
|
||||
description: z.string().optional().describe('Description of the field'),
|
||||
icon: z.string().optional().describe('Icon identifier for the field'),
|
||||
isActive: z.boolean().optional().describe('Whether the field is active'),
|
||||
isNullable: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether the field can be null'),
|
||||
isUnique: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether the field value must be unique'),
|
||||
defaultValue: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Default value for the field'),
|
||||
options: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Options for SELECT/MULTI_SELECT fields'),
|
||||
settings: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Additional settings for the field'),
|
||||
isLabelSyncedWithName: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether label should sync with name changes'),
|
||||
}),
|
||||
.describe('Whether the field value must be unique'),
|
||||
defaultValue: z.unknown().optional().describe('Default value for the field'),
|
||||
options: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Options for SELECT/MULTI_SELECT fields'),
|
||||
settings: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Additional settings for the field'),
|
||||
isLabelSyncedWithName: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether label should sync with name changes'),
|
||||
});
|
||||
|
||||
const DeleteFieldMetadataInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
id: z.string().uuid().describe('ID of the field to delete'),
|
||||
}),
|
||||
id: z.string().uuid().describe('ID of the field to delete'),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
@@ -151,21 +115,21 @@ export class FieldMetadataToolsFactory {
|
||||
'Find fields metadata. Retrieve information about the fields of objects in the workspace data model.',
|
||||
inputSchema: GetFieldMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: { id?: string; objectMetadataId?: string; limit?: number };
|
||||
id?: string;
|
||||
objectMetadataId?: string;
|
||||
limit?: number;
|
||||
}) => {
|
||||
return this.fieldMetadataService.query({
|
||||
filter: {
|
||||
workspaceId: { eq: workspaceId },
|
||||
...(parameters.input.id
|
||||
? { id: { eq: parameters.input.id } }
|
||||
: {}),
|
||||
...(parameters.input.objectMetadataId
|
||||
...(parameters.id ? { id: { eq: parameters.id } } : {}),
|
||||
...(parameters.objectMetadataId
|
||||
? {
|
||||
objectMetadataId: { eq: parameters.input.objectMetadataId },
|
||||
objectMetadataId: { eq: parameters.objectMetadataId },
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
paging: { limit: parameters.input.limit ?? 100 },
|
||||
paging: { limit: parameters.limit ?? 100 },
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -174,27 +138,25 @@ export class FieldMetadataToolsFactory {
|
||||
'Create a new field metadata on an object. Specify the objectMetadataId and field properties.',
|
||||
inputSchema: CreateFieldMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: {
|
||||
objectMetadataId: string;
|
||||
type: FieldMetadataType;
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
isNullable?: boolean;
|
||||
isUnique?: boolean;
|
||||
defaultValue?: unknown;
|
||||
options?: unknown;
|
||||
settings?: unknown;
|
||||
isLabelSyncedWithName?: boolean;
|
||||
isRemoteCreation?: boolean;
|
||||
relationCreationPayload?: unknown;
|
||||
};
|
||||
objectMetadataId: string;
|
||||
type: FieldMetadataType;
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
isNullable?: boolean;
|
||||
isUnique?: boolean;
|
||||
defaultValue?: unknown;
|
||||
options?: unknown;
|
||||
settings?: unknown;
|
||||
isLabelSyncedWithName?: boolean;
|
||||
isRemoteCreation?: boolean;
|
||||
relationCreationPayload?: unknown;
|
||||
}) => {
|
||||
try {
|
||||
const flatFieldMetadata =
|
||||
await this.fieldMetadataService.createOneField({
|
||||
createFieldInput: parameters.input as Parameters<
|
||||
createFieldInput: parameters as Parameters<
|
||||
typeof this.fieldMetadataService.createOneField
|
||||
>[0]['createFieldInput'],
|
||||
workspaceId,
|
||||
@@ -214,23 +176,21 @@ export class FieldMetadataToolsFactory {
|
||||
'Update an existing field metadata. Provide the field ID and the properties to update.',
|
||||
inputSchema: UpdateFieldMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: {
|
||||
id: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
isActive?: boolean;
|
||||
isNullable?: boolean;
|
||||
isUnique?: boolean;
|
||||
defaultValue?: unknown;
|
||||
options?: unknown;
|
||||
settings?: unknown;
|
||||
isLabelSyncedWithName?: boolean;
|
||||
};
|
||||
id: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
isActive?: boolean;
|
||||
isNullable?: boolean;
|
||||
isUnique?: boolean;
|
||||
defaultValue?: unknown;
|
||||
options?: unknown;
|
||||
settings?: unknown;
|
||||
isLabelSyncedWithName?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const { id, ...update } = parameters.input;
|
||||
const { id, ...update } = parameters;
|
||||
|
||||
const flatFieldMetadata =
|
||||
await this.fieldMetadataService.updateOneField({
|
||||
@@ -252,11 +212,11 @@ export class FieldMetadataToolsFactory {
|
||||
delete_field_metadata: {
|
||||
description: 'Delete a field metadata by its ID.',
|
||||
inputSchema: DeleteFieldMetadataInputSchema,
|
||||
execute: async (parameters: { input: { id: string } }) => {
|
||||
execute: async (parameters: { id: string }) => {
|
||||
try {
|
||||
const flatFieldMetadata =
|
||||
await this.fieldMetadataService.deleteOneField({
|
||||
deleteOneFieldInput: { id: parameters.input.id },
|
||||
deleteOneFieldInput: { id: parameters.id },
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ export const ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY = {
|
||||
),
|
||||
'deletedAt',
|
||||
],
|
||||
propertiesToStringify: [],
|
||||
propertiesToStringify: ['toolInputSchema'],
|
||||
},
|
||||
cronTrigger: {
|
||||
propertiesToCompare: [...FLAT_CRON_TRIGGER_EDITABLE_PROPERTIES],
|
||||
|
||||
+83
-127
@@ -9,112 +9,76 @@ import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metada
|
||||
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
|
||||
|
||||
const GetObjectMetadataInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
id: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe(
|
||||
'Unique identifier for the object metadata. If provided, returns a single object.',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.default(100)
|
||||
.describe('Maximum number of objects to return.'),
|
||||
}),
|
||||
.describe(
|
||||
'Unique identifier for the object metadata. If provided, returns a single object.',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.default(100)
|
||||
.describe('Maximum number of objects to return.'),
|
||||
});
|
||||
|
||||
const CreateObjectMetadataInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
nameSingular: z
|
||||
.string()
|
||||
.describe('Singular name for the object (e.g., "company")'),
|
||||
namePlural: z
|
||||
.string()
|
||||
.describe('Plural name for the object (e.g., "companies")'),
|
||||
labelSingular: z
|
||||
.string()
|
||||
.describe('Display label in singular form (e.g., "Company")'),
|
||||
labelPlural: z
|
||||
.string()
|
||||
.describe('Display label in plural form (e.g., "Companies")'),
|
||||
description: z.string().optional().describe('Description of the object'),
|
||||
icon: z.string().optional().describe('Icon identifier for the object'),
|
||||
shortcut: z.string().optional().describe('Keyboard shortcut for the object'),
|
||||
isRemote: z.boolean().optional().describe('Whether this is a remote object'),
|
||||
isLabelSyncedWithName: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
nameSingular: z
|
||||
.string()
|
||||
.describe('Singular name for the object (e.g., "company")'),
|
||||
namePlural: z
|
||||
.string()
|
||||
.describe('Plural name for the object (e.g., "companies")'),
|
||||
labelSingular: z
|
||||
.string()
|
||||
.describe('Display label in singular form (e.g., "Company")'),
|
||||
labelPlural: z
|
||||
.string()
|
||||
.describe('Display label in plural form (e.g., "Companies")'),
|
||||
description: z.string().optional().describe('Description of the object'),
|
||||
icon: z.string().optional().describe('Icon identifier for the object'),
|
||||
shortcut: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Keyboard shortcut for the object'),
|
||||
isRemote: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether this is a remote object'),
|
||||
isLabelSyncedWithName: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether label should sync with name changes'),
|
||||
}),
|
||||
.describe('Whether label should sync with name changes'),
|
||||
});
|
||||
|
||||
const UpdateObjectMetadataInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
id: z.string().uuid().describe('ID of the object to update'),
|
||||
labelSingular: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
id: z.string().uuid().describe('ID of the object to update'),
|
||||
labelSingular: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Display label in singular form'),
|
||||
labelPlural: z.string().optional().describe('Display label in plural form'),
|
||||
nameSingular: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Singular name for the object'),
|
||||
namePlural: z.string().optional().describe('Plural name for the object'),
|
||||
description: z.string().optional().describe('Description of the object'),
|
||||
icon: z.string().optional().describe('Icon identifier for the object'),
|
||||
shortcut: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Keyboard shortcut for the object'),
|
||||
isActive: z.boolean().optional().describe('Whether the object is active'),
|
||||
labelIdentifierFieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('ID of the field used as label identifier'),
|
||||
imageIdentifierFieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('ID of the field used as image identifier'),
|
||||
isLabelSyncedWithName: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether label should sync with name changes'),
|
||||
}),
|
||||
.describe('Display label in singular form'),
|
||||
labelPlural: z.string().optional().describe('Display label in plural form'),
|
||||
nameSingular: z.string().optional().describe('Singular name for the object'),
|
||||
namePlural: z.string().optional().describe('Plural name for the object'),
|
||||
description: z.string().optional().describe('Description of the object'),
|
||||
icon: z.string().optional().describe('Icon identifier for the object'),
|
||||
shortcut: z.string().optional().describe('Keyboard shortcut for the object'),
|
||||
isActive: z.boolean().optional().describe('Whether the object is active'),
|
||||
labelIdentifierFieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('ID of the field used as label identifier'),
|
||||
imageIdentifierFieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('ID of the field used as image identifier'),
|
||||
isLabelSyncedWithName: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether label should sync with name changes'),
|
||||
});
|
||||
|
||||
const DeleteObjectMetadataInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
id: z.string().uuid().describe('ID of the object to delete'),
|
||||
}),
|
||||
id: z.string().uuid().describe('ID of the object to delete'),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
@@ -127,17 +91,13 @@ export class ObjectMetadataToolsFactory {
|
||||
description:
|
||||
'Find objects metadata. Retrieve information about the data model objects in the workspace.',
|
||||
inputSchema: GetObjectMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: { id?: string; limit?: number };
|
||||
}) => {
|
||||
execute: async (parameters: { id?: string; limit?: number }) => {
|
||||
const flatObjectMetadatas =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(
|
||||
workspaceId,
|
||||
{
|
||||
...(parameters.input.id
|
||||
? { where: { id: parameters.input.id } }
|
||||
: {}),
|
||||
take: parameters.input.limit ?? 100,
|
||||
...(parameters.id ? { where: { id: parameters.id } } : {}),
|
||||
take: parameters.limit ?? 100,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -151,22 +111,20 @@ export class ObjectMetadataToolsFactory {
|
||||
'Create a new object metadata in the workspace data model.',
|
||||
inputSchema: CreateObjectMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: {
|
||||
nameSingular: string;
|
||||
namePlural: string;
|
||||
labelSingular: string;
|
||||
labelPlural: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
shortcut?: string;
|
||||
isRemote?: boolean;
|
||||
isLabelSyncedWithName?: boolean;
|
||||
};
|
||||
nameSingular: string;
|
||||
namePlural: string;
|
||||
labelSingular: string;
|
||||
labelPlural: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
shortcut?: string;
|
||||
isRemote?: boolean;
|
||||
isLabelSyncedWithName?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const flatObjectMetadata =
|
||||
await this.objectMetadataService.createOneObject({
|
||||
createObjectInput: parameters.input as Parameters<
|
||||
createObjectInput: parameters as Parameters<
|
||||
typeof this.objectMetadataService.createOneObject
|
||||
>[0]['createObjectInput'],
|
||||
workspaceId,
|
||||
@@ -188,23 +146,21 @@ export class ObjectMetadataToolsFactory {
|
||||
'Update an existing object metadata. Provide the object ID and the fields to update.',
|
||||
inputSchema: UpdateObjectMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: {
|
||||
id: string;
|
||||
labelSingular?: string;
|
||||
labelPlural?: string;
|
||||
nameSingular?: string;
|
||||
namePlural?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
shortcut?: string;
|
||||
isActive?: boolean;
|
||||
labelIdentifierFieldMetadataId?: string;
|
||||
imageIdentifierFieldMetadataId?: string;
|
||||
isLabelSyncedWithName?: boolean;
|
||||
};
|
||||
id: string;
|
||||
labelSingular?: string;
|
||||
labelPlural?: string;
|
||||
nameSingular?: string;
|
||||
namePlural?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
shortcut?: string;
|
||||
isActive?: boolean;
|
||||
labelIdentifierFieldMetadataId?: string;
|
||||
imageIdentifierFieldMetadataId?: string;
|
||||
isLabelSyncedWithName?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const { id, ...update } = parameters.input;
|
||||
const { id, ...update } = parameters;
|
||||
|
||||
const flatObjectMetadata =
|
||||
await this.objectMetadataService.updateOneObject({
|
||||
@@ -227,11 +183,11 @@ export class ObjectMetadataToolsFactory {
|
||||
description:
|
||||
'Delete an object metadata by its ID. This will also delete all associated fields.',
|
||||
inputSchema: DeleteObjectMetadataInputSchema,
|
||||
execute: async (parameters: { input: { id: string } }) => {
|
||||
execute: async (parameters: { id: string }) => {
|
||||
try {
|
||||
const flatObjectMetadata =
|
||||
await this.objectMetadataService.deleteOneObject({
|
||||
deleteObjectInput: { id: parameters.input.id },
|
||||
deleteObjectInput: { id: parameters.id },
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// Default tool input schema matching the base-typescript-project template
|
||||
// Template params: { a: string; b: number; }
|
||||
export const DEFAULT_TOOL_INPUT_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'string' },
|
||||
b: { type: 'number' },
|
||||
},
|
||||
};
|
||||
+2
@@ -8,4 +8,6 @@ export const FLAT_SERVERLESS_FUNCTION_EDITABLE_PROPERTIES = [
|
||||
'code',
|
||||
'handlerPath',
|
||||
'handlerName',
|
||||
'toolInputSchema',
|
||||
'isTool',
|
||||
] as const satisfies (keyof FlatServerlessFunction)[];
|
||||
|
||||
+11
@@ -1,6 +1,7 @@
|
||||
import { Field, HideField, InputType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
@@ -54,4 +55,14 @@ export class CreateServerlessFunctionInput {
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
handlerPath?: string;
|
||||
|
||||
@Field(() => graphqlTypeJson, { nullable: true })
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
toolInputSchema?: object;
|
||||
|
||||
@IsBoolean()
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
isTool?: boolean;
|
||||
}
|
||||
|
||||
+18
@@ -7,12 +7,16 @@ import {
|
||||
} from '@ptc-org/nestjs-query-graphql';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import graphqlTypeJson from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { CronTriggerDTO } from 'src/engine/metadata-modules/cron-trigger/dtos/cron-trigger.dto';
|
||||
@@ -69,6 +73,15 @@ export class ServerlessFunctionDTO {
|
||||
@Field(() => [String], { nullable: false })
|
||||
publishedVersions: string[];
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@Field(() => graphqlTypeJson, { nullable: true })
|
||||
toolInputSchema?: object;
|
||||
|
||||
@IsBoolean()
|
||||
@Field()
|
||||
isTool: boolean;
|
||||
|
||||
@Field(() => [CronTriggerDTO], { nullable: true })
|
||||
cronTriggers?: CronTriggerDTO[];
|
||||
|
||||
@@ -78,6 +91,11 @@ export class ServerlessFunctionDTO {
|
||||
@Field(() => [RouteTriggerDTO], { nullable: true })
|
||||
routeTriggers?: RouteTriggerDTO[];
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
applicationId?: string;
|
||||
|
||||
@HideField()
|
||||
workspaceId: string;
|
||||
|
||||
|
||||
+11
@@ -2,6 +2,7 @@ import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
@@ -50,6 +51,16 @@ class UpdateServerlessFunctionInputUpdates {
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
handlerPath?: string;
|
||||
|
||||
@Field(() => graphqlTypeJson, { nullable: true })
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
toolInputSchema?: object;
|
||||
|
||||
@IsBoolean()
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
isTool?: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
||||
+6
@@ -66,6 +66,12 @@ export class ServerlessFunctionEntity
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
checksum: string | null;
|
||||
|
||||
@Column({ nullable: true, type: 'jsonb' })
|
||||
toolInputSchema: object | null;
|
||||
|
||||
@Column({ nullable: false, default: false })
|
||||
isTool: boolean;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
serverlessFunctionLayerId: string;
|
||||
|
||||
|
||||
+17
-8
@@ -4,26 +4,29 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { join } from 'path';
|
||||
|
||||
import deepEqual from 'deep-equal';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
import { Sources } from 'twenty-shared/types';
|
||||
import {
|
||||
DEFAULT_API_KEY_NAME,
|
||||
DEFAULT_API_URL_NAME,
|
||||
} from 'twenty-shared/application';
|
||||
import { Sources } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
import { type ServerlessExecuteResult } from 'src/engine/core-modules/serverless/drivers/interfaces/serverless-driver.interface';
|
||||
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { SERVERLESS_FUNCTION_EXECUTED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/serverless-function/serverless-function-executed';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
|
||||
import { getBaseTypescriptProjectFiles } from 'src/engine/core-modules/serverless/drivers/utils/get-base-typescript-project-files';
|
||||
import { ServerlessService } from 'src/engine/core-modules/serverless/serverless.service';
|
||||
import { getServerlessFolder } from 'src/engine/core-modules/serverless/utils/serverless-get-folder.utils';
|
||||
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.service';
|
||||
import { DEFAULT_TOOL_INPUT_SCHEMA } from 'src/engine/metadata-modules/serverless-function/constants/default-tool-input-schema.constant';
|
||||
import { CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input';
|
||||
import { type UpdateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input';
|
||||
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
||||
@@ -31,15 +34,13 @@ import {
|
||||
ServerlessFunctionException,
|
||||
ServerlessFunctionExceptionCode,
|
||||
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
|
||||
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
|
||||
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
|
||||
import {
|
||||
WorkflowVersionStepException,
|
||||
WorkflowVersionStepExceptionCode,
|
||||
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
|
||||
import { cleanServerUrl } from 'src/utils/clean-server-url';
|
||||
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
|
||||
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
|
||||
|
||||
const MIN_TOKEN_EXPIRATION_IN_SECONDS = 5;
|
||||
|
||||
@@ -334,6 +335,8 @@ export class ServerlessFunctionService {
|
||||
name: serverlessFunctionInput.update.name,
|
||||
description: serverlessFunctionInput.update.description,
|
||||
timeoutSeconds: serverlessFunctionInput.update.timeoutSeconds,
|
||||
toolInputSchema: serverlessFunctionInput.update.toolInputSchema,
|
||||
isTool: serverlessFunctionInput.update.isTool,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -405,8 +408,14 @@ export class ServerlessFunctionService {
|
||||
serverlessFunctionLayerId: serverlessFunctionToCreateLayerId,
|
||||
};
|
||||
|
||||
// If no toolInputSchema is provided, use the default schema
|
||||
// (because the default template will be used for the code)
|
||||
const toolInputSchema = isDefined(serverlessFunctionInput.toolInputSchema)
|
||||
? serverlessFunctionInput.toolInputSchema
|
||||
: DEFAULT_TOOL_INPUT_SCHEMA;
|
||||
|
||||
const serverlessFunctionToCreate = this.serverlessFunctionRepository.create(
|
||||
{ ...createServerlessFunctionInput, workspaceId },
|
||||
{ ...createServerlessFunctionInput, workspaceId, toolInputSchema },
|
||||
);
|
||||
|
||||
const createdServerlessFunction =
|
||||
|
||||
+12
@@ -1,5 +1,7 @@
|
||||
import { v4 } from 'uuid';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { DEFAULT_TOOL_INPUT_SCHEMA } from 'src/engine/metadata-modules/serverless-function/constants/default-tool-input-schema.constant';
|
||||
import { type CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input';
|
||||
import {
|
||||
DEFAULT_HANDLER_NAME,
|
||||
@@ -55,5 +57,15 @@ export const fromCreateServerlessFunctionInputToFlatServerlessFunction = ({
|
||||
JSON.stringify(rawCreateServerlessFunctionInput.code),
|
||||
)
|
||||
: null,
|
||||
// If no schema provided and no code provided, use default schema
|
||||
// (because the default template will be used)
|
||||
toolInputSchema: isDefined(
|
||||
rawCreateServerlessFunctionInput?.toolInputSchema,
|
||||
)
|
||||
? rawCreateServerlessFunctionInput.toolInputSchema
|
||||
: !isDefined(rawCreateServerlessFunctionInput?.code)
|
||||
? DEFAULT_TOOL_INPUT_SCHEMA
|
||||
: null,
|
||||
isTool: rawCreateServerlessFunctionInput?.isTool ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
+18
-27
@@ -120,7 +120,7 @@ describe('ViewToolsFactory', () => {
|
||||
);
|
||||
|
||||
const result = await callExecute(tools['get_views'], {
|
||||
input: { limit: 50 },
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
expect(viewService.findByWorkspaceId).toHaveBeenCalledWith(
|
||||
@@ -150,7 +150,8 @@ describe('ViewToolsFactory', () => {
|
||||
);
|
||||
|
||||
const result = await callExecute(tools['get_views'], {
|
||||
input: { objectNameSingular: mockObjectNameSingular, limit: 50 },
|
||||
objectNameSingular: mockObjectNameSingular,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
expect(viewService.findByObjectMetadataId).toHaveBeenCalledWith(
|
||||
@@ -173,7 +174,7 @@ describe('ViewToolsFactory', () => {
|
||||
const tools = viewToolsFactory.generateReadTools(mockWorkspaceId);
|
||||
|
||||
const result = await callExecute(tools['get_views'], {
|
||||
input: { limit: 2 },
|
||||
limit: 2,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
@@ -201,7 +202,7 @@ describe('ViewToolsFactory', () => {
|
||||
);
|
||||
|
||||
const result = await callExecute(tools['get_view_query_parameters'], {
|
||||
input: { viewId: mockViewId },
|
||||
viewId: mockViewId,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -244,11 +245,9 @@ describe('ViewToolsFactory', () => {
|
||||
);
|
||||
|
||||
const result = await callExecute(tools['create_view'], {
|
||||
input: {
|
||||
name: 'New View',
|
||||
objectNameSingular: mockObjectNameSingular,
|
||||
icon: 'IconTable',
|
||||
},
|
||||
name: 'New View',
|
||||
objectNameSingular: mockObjectNameSingular,
|
||||
icon: 'IconTable',
|
||||
});
|
||||
|
||||
expect(viewService.createOne).toHaveBeenCalledWith({
|
||||
@@ -293,10 +292,8 @@ describe('ViewToolsFactory', () => {
|
||||
);
|
||||
|
||||
const result = await callExecute(tools['update_view'], {
|
||||
input: {
|
||||
id: mockViewId,
|
||||
name: 'Updated Name',
|
||||
},
|
||||
id: mockViewId,
|
||||
name: 'Updated Name',
|
||||
});
|
||||
|
||||
expect(viewService.updateOne).toHaveBeenCalled();
|
||||
@@ -323,10 +320,8 @@ describe('ViewToolsFactory', () => {
|
||||
);
|
||||
|
||||
const result = await callExecute(tools['update_view'], {
|
||||
input: {
|
||||
id: mockViewId,
|
||||
name: 'Updated Name',
|
||||
},
|
||||
id: mockViewId,
|
||||
name: 'Updated Name',
|
||||
});
|
||||
|
||||
expect(result.name).toBe('Updated Name');
|
||||
@@ -348,10 +343,8 @@ describe('ViewToolsFactory', () => {
|
||||
|
||||
await expect(
|
||||
callExecute(tools['update_view'], {
|
||||
input: {
|
||||
id: mockViewId,
|
||||
name: 'Updated Name',
|
||||
},
|
||||
id: mockViewId,
|
||||
name: 'Updated Name',
|
||||
}),
|
||||
).rejects.toThrow('You can only update your own unlisted views');
|
||||
});
|
||||
@@ -363,10 +356,8 @@ describe('ViewToolsFactory', () => {
|
||||
|
||||
await expect(
|
||||
callExecute(tools['update_view'], {
|
||||
input: {
|
||||
id: 'non-existent-id',
|
||||
name: 'Updated Name',
|
||||
},
|
||||
id: 'non-existent-id',
|
||||
name: 'Updated Name',
|
||||
}),
|
||||
).rejects.toThrow('View with id non-existent-id not found');
|
||||
});
|
||||
@@ -392,7 +383,7 @@ describe('ViewToolsFactory', () => {
|
||||
);
|
||||
|
||||
const result = await callExecute(tools['delete_view'], {
|
||||
input: { id: mockViewId },
|
||||
id: mockViewId,
|
||||
});
|
||||
|
||||
expect(viewService.deleteOne).toHaveBeenCalledWith({
|
||||
@@ -422,7 +413,7 @@ describe('ViewToolsFactory', () => {
|
||||
|
||||
await expect(
|
||||
callExecute(tools['delete_view'], {
|
||||
input: { id: mockViewId },
|
||||
id: mockViewId,
|
||||
}),
|
||||
).rejects.toThrow('You can only delete your own unlisted views');
|
||||
});
|
||||
|
||||
+67
-101
@@ -12,90 +12,60 @@ import { ViewService } from 'src/engine/metadata-modules/view/services/view.serv
|
||||
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
|
||||
|
||||
const GetViewsInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
objectNameSingular: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
objectNameSingular: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Filter views by object name (e.g., "task", "person", "company"). If omitted, returns all views.',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.default(50)
|
||||
.describe('Maximum views to return.'),
|
||||
}),
|
||||
.describe(
|
||||
'Filter views by object name (e.g., "task", "person", "company"). If omitted, returns all views.',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.default(50)
|
||||
.describe('Maximum views to return.'),
|
||||
});
|
||||
|
||||
const GetViewQueryParamsInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
viewId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
viewId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('ID of the view to get query parameters for.'),
|
||||
}),
|
||||
.uuid()
|
||||
.describe('ID of the view to get query parameters for.'),
|
||||
});
|
||||
|
||||
const CreateViewInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
name: z.string().describe('View name'),
|
||||
objectNameSingular: z
|
||||
.string()
|
||||
.describe(
|
||||
'Object name this view is for (e.g., "task", "person", "company")',
|
||||
),
|
||||
icon: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
name: z.string().describe('View name'),
|
||||
objectNameSingular: z
|
||||
.string()
|
||||
.describe(
|
||||
'Object name this view is for (e.g., "task", "person", "company")',
|
||||
),
|
||||
icon: z
|
||||
.string()
|
||||
.optional()
|
||||
.default('IconList')
|
||||
.describe('Icon identifier (e.g., "IconList", "IconCheckbox")'),
|
||||
type: z
|
||||
.enum([ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR])
|
||||
.optional()
|
||||
.default(ViewType.TABLE)
|
||||
.describe('View type'),
|
||||
visibility: z
|
||||
.enum([ViewVisibility.WORKSPACE, ViewVisibility.UNLISTED])
|
||||
.optional()
|
||||
.default(ViewVisibility.WORKSPACE)
|
||||
.describe('View visibility'),
|
||||
}),
|
||||
.default('IconList')
|
||||
.describe('Icon identifier (e.g., "IconList", "IconCheckbox")'),
|
||||
type: z
|
||||
.enum([ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR])
|
||||
.optional()
|
||||
.default(ViewType.TABLE)
|
||||
.describe('View type'),
|
||||
visibility: z
|
||||
.enum([ViewVisibility.WORKSPACE, ViewVisibility.UNLISTED])
|
||||
.optional()
|
||||
.default(ViewVisibility.WORKSPACE)
|
||||
.describe('View visibility'),
|
||||
});
|
||||
|
||||
const UpdateViewInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
id: z.string().uuid().describe('View ID to update'),
|
||||
name: z.string().optional().describe('New view name'),
|
||||
icon: z.string().optional().describe('New icon identifier'),
|
||||
}),
|
||||
id: z.string().uuid().describe('View ID to update'),
|
||||
name: z.string().optional().describe('New view name'),
|
||||
icon: z.string().optional().describe('New icon identifier'),
|
||||
});
|
||||
|
||||
const DeleteViewInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
id: z.string().uuid().describe('View ID to delete'),
|
||||
}),
|
||||
id: z.string().uuid().describe('View ID to delete'),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
@@ -142,14 +112,15 @@ export class ViewToolsFactory {
|
||||
'List views in the workspace. Views define how records are displayed, filtered, and sorted.',
|
||||
inputSchema: GetViewsInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: { objectNameSingular?: string; limit?: number };
|
||||
objectNameSingular?: string;
|
||||
limit?: number;
|
||||
}) => {
|
||||
let views;
|
||||
|
||||
if (parameters.input.objectNameSingular) {
|
||||
if (parameters.objectNameSingular) {
|
||||
const objectMetadataId = await this.resolveObjectMetadataId(
|
||||
workspaceId,
|
||||
parameters.input.objectNameSingular,
|
||||
parameters.objectNameSingular,
|
||||
);
|
||||
|
||||
views = await this.viewService.findByObjectMetadataId(
|
||||
@@ -164,7 +135,7 @@ export class ViewToolsFactory {
|
||||
);
|
||||
}
|
||||
|
||||
const limitedViews = views.slice(0, parameters.input.limit ?? 50);
|
||||
const limitedViews = views.slice(0, parameters.limit ?? 50);
|
||||
|
||||
return limitedViews.map((view) => ({
|
||||
id: view.id,
|
||||
@@ -181,9 +152,9 @@ export class ViewToolsFactory {
|
||||
description:
|
||||
'Get filter and sort parameters from a view. Use these parameters with find_* tools to query records matching the view.',
|
||||
inputSchema: GetViewQueryParamsInputSchema,
|
||||
execute: async (parameters: { input: { viewId: string } }) => {
|
||||
execute: async (parameters: { viewId: string }) => {
|
||||
return this.viewQueryParamsService.resolveViewToQueryParams(
|
||||
parameters.input.viewId,
|
||||
parameters.viewId,
|
||||
workspaceId,
|
||||
currentWorkspaceMemberId,
|
||||
);
|
||||
@@ -199,28 +170,25 @@ export class ViewToolsFactory {
|
||||
'Create a new view for an object. Views define how records are displayed.',
|
||||
inputSchema: CreateViewInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: {
|
||||
name: string;
|
||||
objectNameSingular: string;
|
||||
icon?: string;
|
||||
type?: ViewType;
|
||||
visibility?: ViewVisibility;
|
||||
};
|
||||
name: string;
|
||||
objectNameSingular: string;
|
||||
icon?: string;
|
||||
type?: ViewType;
|
||||
visibility?: ViewVisibility;
|
||||
}) => {
|
||||
try {
|
||||
const objectMetadataId = await this.resolveObjectMetadataId(
|
||||
workspaceId,
|
||||
parameters.input.objectNameSingular,
|
||||
parameters.objectNameSingular,
|
||||
);
|
||||
|
||||
const view = await this.viewService.createOne({
|
||||
createViewInput: {
|
||||
name: parameters.input.name,
|
||||
name: parameters.name,
|
||||
objectMetadataId,
|
||||
icon: parameters.input.icon ?? 'IconList',
|
||||
type: parameters.input.type ?? ViewType.TABLE,
|
||||
visibility:
|
||||
parameters.input.visibility ?? ViewVisibility.WORKSPACE,
|
||||
icon: parameters.icon ?? 'IconList',
|
||||
type: parameters.type ?? ViewType.TABLE,
|
||||
visibility: parameters.visibility ?? ViewVisibility.WORKSPACE,
|
||||
},
|
||||
workspaceId,
|
||||
createdByUserWorkspaceId: userWorkspaceId,
|
||||
@@ -229,7 +197,7 @@ export class ViewToolsFactory {
|
||||
return {
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
objectNameSingular: parameters.input.objectNameSingular,
|
||||
objectNameSingular: parameters.objectNameSingular,
|
||||
type: view.type,
|
||||
icon: view.icon,
|
||||
visibility: view.visibility,
|
||||
@@ -247,20 +215,18 @@ export class ViewToolsFactory {
|
||||
'Update an existing view. You can change the name and icon.',
|
||||
inputSchema: UpdateViewInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: {
|
||||
id: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
};
|
||||
id: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
}) => {
|
||||
try {
|
||||
const existingView = await this.viewService.findById(
|
||||
parameters.input.id,
|
||||
parameters.id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!existingView) {
|
||||
throw new Error(`View with id ${parameters.input.id} not found`);
|
||||
throw new Error(`View with id ${parameters.id} not found`);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -272,9 +238,9 @@ export class ViewToolsFactory {
|
||||
|
||||
const view = await this.viewService.updateOne({
|
||||
updateViewInput: {
|
||||
id: parameters.input.id,
|
||||
name: parameters.input.name,
|
||||
icon: parameters.input.icon,
|
||||
id: parameters.id,
|
||||
name: parameters.name,
|
||||
icon: parameters.icon,
|
||||
},
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
@@ -299,15 +265,15 @@ export class ViewToolsFactory {
|
||||
delete_view: {
|
||||
description: 'Delete a view by its ID.',
|
||||
inputSchema: DeleteViewInputSchema,
|
||||
execute: async (parameters: { input: { id: string } }) => {
|
||||
execute: async (parameters: { id: string }) => {
|
||||
try {
|
||||
const existingView = await this.viewService.findById(
|
||||
parameters.input.id,
|
||||
parameters.id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!existingView) {
|
||||
throw new Error(`View with id ${parameters.input.id} not found`);
|
||||
throw new Error(`View with id ${parameters.id} not found`);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -318,7 +284,7 @@ export class ViewToolsFactory {
|
||||
}
|
||||
|
||||
const view = await this.viewService.deleteOne({
|
||||
deleteViewInput: { id: parameters.input.id },
|
||||
deleteViewInput: { id: parameters.id },
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user