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:
+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