feat(ai): add dashboard tools for AI chat (#16517)
## Summary - Implements real tools for the dashboard-building skill to create and manage dashboards through the AI chat interface - Adds 6 new dashboard tools: `create_complete_dashboard`, `list_dashboards`, `get_dashboard`, `add_dashboard_widget`, `update_dashboard_widget`, `delete_dashboard_widget` - Improves widget configuration robustness with typed Zod schemas and discriminated unions for graph types ## Key Changes **New Dashboard Tools:** - `create_complete_dashboard` - Creates a dashboard with layout, tab, and widgets in a single call - `list_dashboards` - Lists all dashboards in the workspace - `get_dashboard` - Gets full dashboard details including tabs and widget configurations - `add_dashboard_widget` - Adds a widget to an existing dashboard tab - `update_dashboard_widget` - Updates widget properties or configuration - `delete_dashboard_widget` - Removes a widget from a dashboard **Widget Configuration Improvements:** - Typed Zod schemas for each chart type (AGGREGATE, BAR, LINE, PIE) - Discriminated union validation based on `graphType` - Widget-level error handling for partial success when creating dashboards - Clear documentation about required `objectMetadataId` and field UUIDs **Skill Documentation Updates:** - Updated `dashboard-building.skill.ts` with critical guidance about looking up field metadata first - Added workflow instructions: use `list_object_metadata_items` before creating GRAPH widgets - Practical grid layout recommendations ## Test plan - [ ] Create a new dashboard via AI chat - [ ] Verify widgets display data correctly when proper field IDs are provided - [ ] Test adding/updating/deleting widgets on existing dashboards - [ ] Verify error messages are helpful when configuration is incorrect
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type WidgetType } from 'src/engine/metadata-modules/page-layout/enums/widget-type.enum';
|
||||
import {
|
||||
gridPositionSchema,
|
||||
widgetConfigurationSchema,
|
||||
widgetTypeSchema,
|
||||
} from 'src/modules/dashboard/tools/schemas/widget.schema';
|
||||
import {
|
||||
type DashboardToolContext,
|
||||
type DashboardToolDependencies,
|
||||
} from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type';
|
||||
|
||||
const addDashboardWidgetSchema = z.object({
|
||||
pageLayoutTabId: z.string().uuid().describe('Tab UUID from get_dashboard'),
|
||||
title: z.string().describe('Widget title'),
|
||||
type: widgetTypeSchema.describe('Widget type'),
|
||||
gridPosition: gridPositionSchema.describe('Position in 12-column grid'),
|
||||
objectMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('Required for GRAPH widgets: object UUID to aggregate'),
|
||||
configuration: widgetConfigurationSchema,
|
||||
});
|
||||
|
||||
export const createAddDashboardWidgetTool = (
|
||||
deps: Pick<DashboardToolDependencies, 'pageLayoutWidgetService'>,
|
||||
context: DashboardToolContext,
|
||||
) => ({
|
||||
name: 'add_dashboard_widget' as const,
|
||||
description: `Add a widget to an existing dashboard tab.
|
||||
|
||||
Use get_dashboard first to get pageLayoutTabId and existing widget positions.
|
||||
Use list_object_metadata_items to get objectMetadataId and field IDs for GRAPH widgets.
|
||||
|
||||
See create_complete_dashboard for configuration examples.`,
|
||||
inputSchema: addDashboardWidgetSchema,
|
||||
execute: async (parameters: {
|
||||
pageLayoutTabId: string;
|
||||
title: string;
|
||||
type: WidgetType;
|
||||
gridPosition: {
|
||||
row: number;
|
||||
column: number;
|
||||
rowSpan: number;
|
||||
columnSpan: number;
|
||||
};
|
||||
objectMetadataId?: string;
|
||||
configuration?: Record<string, unknown>;
|
||||
}) => {
|
||||
try {
|
||||
const widget = await deps.pageLayoutWidgetService.create(
|
||||
parameters,
|
||||
context.workspaceId,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Widget "${parameters.title}" added`,
|
||||
result: {
|
||||
widgetId: widget.id,
|
||||
title: widget.title,
|
||||
type: widget.type,
|
||||
gridPosition: widget.gridPosition,
|
||||
pageLayoutTabId: parameters.pageLayoutTabId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to add widget: ${error.message}`,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
|
||||
import { type WidgetType } from 'src/engine/metadata-modules/page-layout/enums/widget-type.enum';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import {
|
||||
gridPositionSchema,
|
||||
widgetConfigurationSchema,
|
||||
widgetTypeSchema,
|
||||
} from 'src/modules/dashboard/tools/schemas/widget.schema';
|
||||
import {
|
||||
type DashboardToolContext,
|
||||
type DashboardToolDependencies,
|
||||
} from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type';
|
||||
|
||||
const widgetSchema = z.object({
|
||||
title: z.string().describe('Widget title displayed in the header'),
|
||||
type: widgetTypeSchema.describe('Widget type'),
|
||||
gridPosition: gridPositionSchema.describe('Position in 12-column grid'),
|
||||
objectMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe(
|
||||
'REQUIRED for GRAPH widgets: UUID of the object to aggregate (e.g., opportunity, company)',
|
||||
),
|
||||
configuration: widgetConfigurationSchema,
|
||||
});
|
||||
|
||||
const createCompleteDashboardSchema = z.object({
|
||||
title: z.string().describe('Dashboard title'),
|
||||
tabTitle: z
|
||||
.string()
|
||||
.optional()
|
||||
.default('Main')
|
||||
.describe('Title of the first tab'),
|
||||
widgets: z
|
||||
.array(widgetSchema)
|
||||
.optional()
|
||||
.default([])
|
||||
.describe('Widgets to add'),
|
||||
});
|
||||
|
||||
export const createCreateCompleteDashboardTool = (
|
||||
deps: DashboardToolDependencies,
|
||||
context: DashboardToolContext,
|
||||
) => ({
|
||||
name: 'create_complete_dashboard' as const,
|
||||
description: `Create a dashboard with layout, tab, and widgets.
|
||||
|
||||
IMPORTANT: Before creating GRAPH widgets, you MUST use list_object_metadata_items to get valid objectMetadataId and field IDs.
|
||||
|
||||
GRID SYSTEM:
|
||||
- 12 columns (0-11), rows start at 0
|
||||
- Full width: columnSpan: 12, Half: columnSpan: 6, Third: columnSpan: 4
|
||||
- Row spans: 2-4 (KPI), 6-8 (charts)
|
||||
|
||||
WIDGET TYPES:
|
||||
|
||||
1. GRAPH with graphType "AGGREGATE" (KPI number):
|
||||
- Requires: objectMetadataId, configuration.graphType, configuration.aggregateFieldMetadataId, configuration.aggregateOperation
|
||||
- Example: { type: "GRAPH", objectMetadataId: "<opportunity-object-uuid>", configuration: { graphType: "AGGREGATE", aggregateFieldMetadataId: "<amount-field-uuid>", aggregateOperation: "SUM" } }
|
||||
|
||||
2. GRAPH with graphType "VERTICAL_BAR" or "HORIZONTAL_BAR":
|
||||
- Additional required: configuration.primaryAxisGroupByFieldMetadataId
|
||||
- Example: { graphType: "VERTICAL_BAR", aggregateFieldMetadataId: "<count-field-uuid>", aggregateOperation: "COUNT", primaryAxisGroupByFieldMetadataId: "<stage-field-uuid>" }
|
||||
|
||||
3. GRAPH with graphType "LINE":
|
||||
- Same as bar charts, good for time series
|
||||
|
||||
4. GRAPH with graphType "PIE":
|
||||
- Requires: objectMetadataId, aggregateFieldMetadataId, aggregateOperation, groupByFieldMetadataId
|
||||
- Example: { graphType: "PIE", aggregateFieldMetadataId: "<id-field-uuid>", aggregateOperation: "COUNT", groupByFieldMetadataId: "<stage-field-uuid>" }
|
||||
|
||||
5. IFRAME: { type: "IFRAME", configuration: { url: "https://..." } }
|
||||
|
||||
6. STANDALONE_RICH_TEXT: { type: "STANDALONE_RICH_TEXT", configuration: { body: "..." } }
|
||||
|
||||
AGGREGATION OPERATIONS: COUNT, SUM, AVG, MIN, MAX, COUNT_EMPTY, COUNT_NOT_EMPTY`,
|
||||
inputSchema: createCompleteDashboardSchema,
|
||||
execute: async (parameters: {
|
||||
title: string;
|
||||
tabTitle?: string;
|
||||
widgets?: Array<{
|
||||
title: string;
|
||||
type: WidgetType;
|
||||
gridPosition: {
|
||||
row: number;
|
||||
column: number;
|
||||
rowSpan: number;
|
||||
columnSpan: number;
|
||||
};
|
||||
objectMetadataId?: string;
|
||||
configuration?: Record<string, unknown>;
|
||||
}>;
|
||||
}) => {
|
||||
try {
|
||||
const tabTitle = parameters.tabTitle ?? 'Main';
|
||||
const widgets = parameters.widgets ?? [];
|
||||
|
||||
const pageLayout = await deps.pageLayoutService.create(
|
||||
{ name: parameters.title, type: PageLayoutType.DASHBOARD },
|
||||
context.workspaceId,
|
||||
);
|
||||
|
||||
const pageLayoutTab = await deps.pageLayoutTabService.create(
|
||||
{ title: tabTitle, pageLayoutId: pageLayout.id, position: 0 },
|
||||
context.workspaceId,
|
||||
);
|
||||
|
||||
const createdWidgets = [];
|
||||
const widgetErrors = [];
|
||||
|
||||
for (const widget of widgets) {
|
||||
try {
|
||||
const createdWidget = await deps.pageLayoutWidgetService.create(
|
||||
{ ...widget, pageLayoutTabId: pageLayoutTab.id },
|
||||
context.workspaceId,
|
||||
);
|
||||
|
||||
createdWidgets.push({
|
||||
id: createdWidget.id,
|
||||
title: createdWidget.title,
|
||||
type: createdWidget.type,
|
||||
});
|
||||
} catch (widgetError) {
|
||||
widgetErrors.push({
|
||||
title: widget.title,
|
||||
error: widgetError.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const dashboardId = await createDashboardRecord(
|
||||
deps,
|
||||
context,
|
||||
parameters.title,
|
||||
pageLayout.id,
|
||||
);
|
||||
|
||||
const result = {
|
||||
dashboardId,
|
||||
pageLayoutId: pageLayout.id,
|
||||
pageLayoutTabId: pageLayoutTab.id,
|
||||
title: parameters.title,
|
||||
widgets: createdWidgets,
|
||||
};
|
||||
|
||||
if (widgetErrors.length > 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: `Dashboard created with ${createdWidgets.length} widgets. ${widgetErrors.length} widget(s) failed.`,
|
||||
result,
|
||||
widgetErrors,
|
||||
recordReferences: [
|
||||
{
|
||||
objectNameSingular: 'dashboard',
|
||||
recordId: dashboardId,
|
||||
displayName: parameters.title,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Dashboard "${parameters.title}" created with ${createdWidgets.length} widgets`,
|
||||
result,
|
||||
recordReferences: [
|
||||
{
|
||||
objectNameSingular: 'dashboard',
|
||||
recordId: dashboardId,
|
||||
displayName: parameters.title,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to create dashboard: ${error.message}`,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const createDashboardRecord = async (
|
||||
deps: DashboardToolDependencies,
|
||||
context: DashboardToolContext,
|
||||
title: string,
|
||||
pageLayoutId: string,
|
||||
): Promise<string> => {
|
||||
const authContext = buildSystemAuthContext(context.workspaceId);
|
||||
|
||||
return deps.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
authContext,
|
||||
async () => {
|
||||
const dashboardRepository =
|
||||
await deps.globalWorkspaceOrmManager.getRepository(
|
||||
context.workspaceId,
|
||||
'dashboard',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const position = await deps.recordPositionService.buildRecordPosition({
|
||||
value: 'first',
|
||||
objectMetadata: { isCustom: false, nameSingular: 'dashboard' },
|
||||
workspaceId: context.workspaceId,
|
||||
});
|
||||
|
||||
const dashboard = { id: uuidv4(), title, pageLayoutId, position };
|
||||
|
||||
await dashboardRepository.insert(dashboard);
|
||||
|
||||
return dashboard.id;
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
|
||||
import { DASHBOARD_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/dashboard-tool-service.token';
|
||||
import { PageLayoutModule } from 'src/engine/metadata-modules/page-layout/page-layout.module';
|
||||
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
||||
|
||||
import { DashboardToolWorkspaceService } from './services/dashboard-tool.workspace-service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
PageLayoutModule,
|
||||
RecordPositionModule,
|
||||
TwentyORMModule,
|
||||
ApplicationModule,
|
||||
],
|
||||
providers: [
|
||||
DashboardToolWorkspaceService,
|
||||
{
|
||||
provide: DASHBOARD_TOOL_SERVICE_TOKEN,
|
||||
useExisting: DashboardToolWorkspaceService,
|
||||
},
|
||||
],
|
||||
exports: [DashboardToolWorkspaceService, DASHBOARD_TOOL_SERVICE_TOKEN],
|
||||
})
|
||||
export class DashboardToolsModule {}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type DashboardToolContext,
|
||||
type DashboardToolDependencies,
|
||||
} from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type';
|
||||
|
||||
const deleteDashboardWidgetSchema = z.object({
|
||||
widgetId: z.string().uuid().describe('The UUID of the widget to delete'),
|
||||
});
|
||||
|
||||
export const createDeleteDashboardWidgetTool = (
|
||||
deps: Pick<DashboardToolDependencies, 'pageLayoutWidgetService'>,
|
||||
context: DashboardToolContext,
|
||||
) => ({
|
||||
name: 'delete_dashboard_widget' as const,
|
||||
description: `Delete a widget from a dashboard. Use get_dashboard first to find the widgetId.`,
|
||||
inputSchema: deleteDashboardWidgetSchema,
|
||||
execute: async (parameters: { widgetId: string }) => {
|
||||
try {
|
||||
const widget = await deps.pageLayoutWidgetService.findByIdOrThrow(
|
||||
parameters.widgetId,
|
||||
context.workspaceId,
|
||||
);
|
||||
|
||||
await deps.pageLayoutWidgetService.destroy(
|
||||
parameters.widgetId,
|
||||
context.workspaceId,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Widget "${widget.title}" deleted`,
|
||||
result: {
|
||||
deletedWidgetId: parameters.widgetId,
|
||||
deletedWidgetTitle: widget.title,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to delete widget: ${error.message}`,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import {
|
||||
type DashboardToolContext,
|
||||
type DashboardToolDependencies,
|
||||
} from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type';
|
||||
|
||||
const getDashboardSchema = z.object({
|
||||
dashboardId: z.string().uuid().describe('The UUID of the dashboard to fetch'),
|
||||
});
|
||||
|
||||
export const createGetDashboardTool = (
|
||||
deps: Pick<
|
||||
DashboardToolDependencies,
|
||||
'pageLayoutService' | 'globalWorkspaceOrmManager'
|
||||
>,
|
||||
context: DashboardToolContext,
|
||||
) => ({
|
||||
name: 'get_dashboard' as const,
|
||||
description: `Get a dashboard with its full layout structure including tabs and widgets.`,
|
||||
inputSchema: getDashboardSchema,
|
||||
execute: async (parameters: { dashboardId: string }) => {
|
||||
try {
|
||||
const authContext = buildSystemAuthContext(context.workspaceId);
|
||||
|
||||
const dashboard =
|
||||
await deps.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
authContext,
|
||||
async () => {
|
||||
const repo = await deps.globalWorkspaceOrmManager.getRepository(
|
||||
context.workspaceId,
|
||||
'dashboard',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
return repo.findOne({ where: { id: parameters.dashboardId } });
|
||||
},
|
||||
);
|
||||
|
||||
if (!isDefined(dashboard)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Dashboard "${parameters.dashboardId}" not found`,
|
||||
error: 'DASHBOARD_NOT_FOUND',
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDefined(dashboard.pageLayoutId)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Dashboard "${dashboard.title}" has no page layout`,
|
||||
error: 'PAGE_LAYOUT_NOT_FOUND',
|
||||
};
|
||||
}
|
||||
|
||||
const pageLayout = await deps.pageLayoutService.findByIdOrThrow(
|
||||
dashboard.pageLayoutId,
|
||||
context.workspaceId,
|
||||
);
|
||||
|
||||
const tabs =
|
||||
pageLayout.tabs?.map((tab) => ({
|
||||
id: tab.id,
|
||||
title: tab.title,
|
||||
position: tab.position,
|
||||
widgets:
|
||||
tab.widgets?.map((w) => ({
|
||||
id: w.id,
|
||||
title: w.title,
|
||||
type: w.type,
|
||||
gridPosition: w.gridPosition,
|
||||
objectMetadataId: w.objectMetadataId,
|
||||
configuration: w.configuration,
|
||||
})) ?? [],
|
||||
})) ?? [];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Retrieved dashboard "${dashboard.title}" with ${tabs.length} tab(s)`,
|
||||
result: {
|
||||
dashboard: {
|
||||
id: dashboard.id,
|
||||
title: dashboard.title,
|
||||
pageLayoutId: dashboard.pageLayoutId,
|
||||
createdAt: dashboard.createdAt,
|
||||
updatedAt: dashboard.updatedAt,
|
||||
},
|
||||
layout: { id: pageLayout.id, name: pageLayout.name, tabs },
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to get dashboard: ${error.message}`,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import {
|
||||
type DashboardToolContext,
|
||||
type DashboardToolDependencies,
|
||||
} from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type';
|
||||
|
||||
const listDashboardsSchema = z.object({
|
||||
limit: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.default(20)
|
||||
.describe('Maximum number of dashboards to return (default: 20, max: 100)'),
|
||||
});
|
||||
|
||||
export const createListDashboardsTool = (
|
||||
deps: Pick<DashboardToolDependencies, 'globalWorkspaceOrmManager'>,
|
||||
context: DashboardToolContext,
|
||||
) => ({
|
||||
name: 'list_dashboards' as const,
|
||||
description: `List all dashboards in the workspace. Use get_dashboard to retrieve full layout structure.`,
|
||||
inputSchema: listDashboardsSchema,
|
||||
execute: async (parameters: { limit?: number }) => {
|
||||
try {
|
||||
const limit = parameters.limit ?? 20;
|
||||
const authContext = buildSystemAuthContext(context.workspaceId);
|
||||
|
||||
const dashboards =
|
||||
await deps.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
authContext,
|
||||
async () => {
|
||||
const repo = await deps.globalWorkspaceOrmManager.getRepository(
|
||||
context.workspaceId,
|
||||
'dashboard',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
return repo.find({ take: limit, order: { position: 'ASC' } });
|
||||
},
|
||||
);
|
||||
|
||||
const dashboardList = dashboards.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
pageLayoutId: d.pageLayoutId,
|
||||
createdAt: d.createdAt,
|
||||
updatedAt: d.updatedAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Found ${dashboardList.length} dashboard(s)`,
|
||||
result: { dashboards: dashboardList, count: dashboardList.length },
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to list dashboards: ${error.message}`,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
import { GraphType } from 'src/engine/metadata-modules/page-layout/enums/graph-type.enum';
|
||||
import { WidgetType } from 'src/engine/metadata-modules/page-layout/enums/widget-type.enum';
|
||||
|
||||
export const gridPositionSchema = z.object({
|
||||
row: z.number().min(0).describe('Row position (0-based)'),
|
||||
column: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(11)
|
||||
.describe('Column position (0-11 for 12-column grid)'),
|
||||
rowSpan: z.number().min(1).describe('Number of rows the widget spans'),
|
||||
columnSpan: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(12)
|
||||
.describe('Number of columns the widget spans (1-12)'),
|
||||
});
|
||||
|
||||
export const widgetTypeSchema = z.enum([
|
||||
WidgetType.VIEW,
|
||||
WidgetType.GRAPH,
|
||||
WidgetType.IFRAME,
|
||||
WidgetType.STANDALONE_RICH_TEXT,
|
||||
]);
|
||||
|
||||
// Graph configuration schema for AGGREGATE type (KPI numbers)
|
||||
const aggregateChartConfigSchema = z.object({
|
||||
graphType: z.literal(GraphType.AGGREGATE),
|
||||
aggregateFieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe(
|
||||
'Field UUID to aggregate (must be from the widget objectMetadataId)',
|
||||
),
|
||||
aggregateOperation: z
|
||||
.nativeEnum(AggregateOperations)
|
||||
.describe('Aggregation operation: COUNT, SUM, AVG, MIN, MAX, etc.'),
|
||||
displayDataLabel: z.boolean().optional().default(true),
|
||||
label: z.string().optional(),
|
||||
prefix: z.string().optional(),
|
||||
suffix: z.string().optional(),
|
||||
filter: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
// Graph configuration schema for BAR charts
|
||||
const barChartConfigSchema = z.object({
|
||||
graphType: z.enum([GraphType.VERTICAL_BAR, GraphType.HORIZONTAL_BAR]),
|
||||
aggregateFieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('Field UUID to aggregate'),
|
||||
aggregateOperation: z.nativeEnum(AggregateOperations),
|
||||
primaryAxisGroupByFieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('Field UUID to group by on primary axis'),
|
||||
secondaryAxisGroupByFieldMetadataId: z.string().uuid().optional(),
|
||||
primaryAxisOrderBy: z
|
||||
.enum(['FIELD_ASC', 'FIELD_DESC', 'VALUE_ASC', 'VALUE_DESC'])
|
||||
.optional(),
|
||||
displayDataLabel: z.boolean().optional().default(false),
|
||||
displayLegend: z.boolean().optional().default(true),
|
||||
filter: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
// Graph configuration schema for LINE charts
|
||||
const lineChartConfigSchema = z.object({
|
||||
graphType: z.literal(GraphType.LINE),
|
||||
aggregateFieldMetadataId: z.string().uuid(),
|
||||
aggregateOperation: z.nativeEnum(AggregateOperations),
|
||||
primaryAxisGroupByFieldMetadataId: z.string().uuid(),
|
||||
secondaryAxisGroupByFieldMetadataId: z.string().uuid().optional(),
|
||||
primaryAxisOrderBy: z
|
||||
.enum(['FIELD_ASC', 'FIELD_DESC', 'VALUE_ASC', 'VALUE_DESC'])
|
||||
.optional(),
|
||||
displayDataLabel: z.boolean().optional().default(false),
|
||||
filter: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
// Graph configuration schema for PIE charts
|
||||
const pieChartConfigSchema = z.object({
|
||||
graphType: z.literal(GraphType.PIE),
|
||||
aggregateFieldMetadataId: z.string().uuid(),
|
||||
aggregateOperation: z.nativeEnum(AggregateOperations),
|
||||
groupByFieldMetadataId: z.string().uuid().describe('Field UUID to slice by'),
|
||||
orderBy: z
|
||||
.enum(['FIELD_ASC', 'FIELD_DESC', 'VALUE_ASC', 'VALUE_DESC'])
|
||||
.optional(),
|
||||
displayDataLabel: z.boolean().optional().default(true),
|
||||
filter: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
// Iframe configuration
|
||||
const iframeConfigSchema = z.object({
|
||||
url: z.string().url().describe('URL to embed'),
|
||||
});
|
||||
|
||||
// Rich text configuration
|
||||
const richTextConfigSchema = z.object({
|
||||
body: z.string().optional().describe('Rich text content'),
|
||||
});
|
||||
|
||||
export const graphConfigurationSchema = z.discriminatedUnion('graphType', [
|
||||
aggregateChartConfigSchema,
|
||||
barChartConfigSchema,
|
||||
lineChartConfigSchema,
|
||||
pieChartConfigSchema,
|
||||
]);
|
||||
|
||||
export const widgetConfigurationSchema = z
|
||||
.union([graphConfigurationSchema, iframeConfigSchema, richTextConfigSchema])
|
||||
.optional()
|
||||
.describe('Widget configuration - structure depends on widget type');
|
||||
|
||||
// Export enums for documentation
|
||||
export { AggregateOperations, GraphType };
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type ToolSet } from 'ai';
|
||||
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
|
||||
import { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout/services/page-layout-tab.service';
|
||||
import { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout/services/page-layout-widget.service';
|
||||
import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
import { createAddDashboardWidgetTool } from 'src/modules/dashboard/tools/add-dashboard-widget.tool';
|
||||
import { createCreateCompleteDashboardTool } from 'src/modules/dashboard/tools/create-complete-dashboard.tool';
|
||||
import { createDeleteDashboardWidgetTool } from 'src/modules/dashboard/tools/delete-dashboard-widget.tool';
|
||||
import { createGetDashboardTool } from 'src/modules/dashboard/tools/get-dashboard.tool';
|
||||
import { createListDashboardsTool } from 'src/modules/dashboard/tools/list-dashboards.tool';
|
||||
import { type DashboardToolDependencies } from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type';
|
||||
import { createUpdateDashboardWidgetTool } from 'src/modules/dashboard/tools/update-dashboard-widget.tool';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardToolWorkspaceService {
|
||||
private readonly deps: DashboardToolDependencies;
|
||||
|
||||
constructor(
|
||||
pageLayoutService: PageLayoutService,
|
||||
pageLayoutTabService: PageLayoutTabService,
|
||||
pageLayoutWidgetService: PageLayoutWidgetService,
|
||||
globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
recordPositionService: RecordPositionService,
|
||||
applicationService: ApplicationService,
|
||||
) {
|
||||
this.deps = {
|
||||
pageLayoutService,
|
||||
pageLayoutTabService,
|
||||
pageLayoutWidgetService,
|
||||
globalWorkspaceOrmManager,
|
||||
recordPositionService,
|
||||
applicationService,
|
||||
};
|
||||
}
|
||||
|
||||
generateDashboardTools(
|
||||
workspaceId: string,
|
||||
_rolePermissionConfig: RolePermissionConfig,
|
||||
): ToolSet {
|
||||
const context = { workspaceId };
|
||||
|
||||
const createCompleteDashboard = createCreateCompleteDashboardTool(
|
||||
this.deps,
|
||||
context,
|
||||
);
|
||||
const listDashboards = createListDashboardsTool(this.deps, context);
|
||||
const getDashboard = createGetDashboardTool(this.deps, context);
|
||||
const addDashboardWidget = createAddDashboardWidgetTool(this.deps, context);
|
||||
const updateDashboardWidget = createUpdateDashboardWidgetTool(
|
||||
this.deps,
|
||||
context,
|
||||
);
|
||||
const deleteDashboardWidget = createDeleteDashboardWidgetTool(
|
||||
this.deps,
|
||||
context,
|
||||
);
|
||||
|
||||
return {
|
||||
[createCompleteDashboard.name]: createCompleteDashboard,
|
||||
[listDashboards.name]: listDashboards,
|
||||
[getDashboard.name]: getDashboard,
|
||||
[addDashboardWidget.name]: addDashboardWidget,
|
||||
[updateDashboardWidget.name]: updateDashboardWidget,
|
||||
[deleteDashboardWidget.name]: deleteDashboardWidget,
|
||||
};
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import type { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import type { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
|
||||
import type { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout/services/page-layout-tab.service';
|
||||
import type { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout/services/page-layout-widget.service';
|
||||
import type { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service';
|
||||
import type { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import type { RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
|
||||
export type DashboardToolDependencies = {
|
||||
pageLayoutService: PageLayoutService;
|
||||
pageLayoutTabService: PageLayoutTabService;
|
||||
pageLayoutWidgetService: PageLayoutWidgetService;
|
||||
globalWorkspaceOrmManager: GlobalWorkspaceOrmManager;
|
||||
recordPositionService: RecordPositionService;
|
||||
applicationService: ApplicationService;
|
||||
};
|
||||
|
||||
export type DashboardToolContext = {
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
export type DashboardToolContextWithPermissions = DashboardToolContext & {
|
||||
rolePermissionConfig: RolePermissionConfig;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type WidgetType } from 'src/engine/metadata-modules/page-layout/enums/widget-type.enum';
|
||||
import {
|
||||
gridPositionSchema,
|
||||
widgetConfigurationSchema,
|
||||
widgetTypeSchema,
|
||||
} from 'src/modules/dashboard/tools/schemas/widget.schema';
|
||||
import {
|
||||
type DashboardToolContext,
|
||||
type DashboardToolDependencies,
|
||||
} from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type';
|
||||
|
||||
const updateDashboardWidgetSchema = z.object({
|
||||
widgetId: z.string().uuid().describe('The UUID of the widget to update'),
|
||||
title: z.string().optional().describe('New widget title'),
|
||||
type: widgetTypeSchema.optional().describe('New widget type'),
|
||||
gridPosition: gridPositionSchema
|
||||
.optional()
|
||||
.describe('New position and size in the grid layout'),
|
||||
objectMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('New object metadata ID'),
|
||||
configuration: widgetConfigurationSchema,
|
||||
});
|
||||
|
||||
export const createUpdateDashboardWidgetTool = (
|
||||
deps: Pick<DashboardToolDependencies, 'pageLayoutWidgetService'>,
|
||||
context: DashboardToolContext,
|
||||
) => ({
|
||||
name: 'update_dashboard_widget' as const,
|
||||
description: `Update an existing widget's properties, position, or configuration.
|
||||
|
||||
Use get_dashboard first to find the widgetId.
|
||||
|
||||
Only provide fields you want to change - others remain unchanged.`,
|
||||
inputSchema: updateDashboardWidgetSchema,
|
||||
execute: async (parameters: {
|
||||
widgetId: string;
|
||||
title?: string;
|
||||
type?: WidgetType;
|
||||
gridPosition?: {
|
||||
row: number;
|
||||
column: number;
|
||||
rowSpan: number;
|
||||
columnSpan: number;
|
||||
};
|
||||
objectMetadataId?: string;
|
||||
configuration?: Record<string, unknown>;
|
||||
}) => {
|
||||
try {
|
||||
const { widgetId, ...updates } = parameters;
|
||||
const updateData = Object.fromEntries(
|
||||
Object.entries(updates).filter(([, value]) => isDefined(value)),
|
||||
);
|
||||
|
||||
const widget = await deps.pageLayoutWidgetService.update(
|
||||
widgetId,
|
||||
context.workspaceId,
|
||||
updateData,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Widget "${widget.title}" updated`,
|
||||
result: {
|
||||
widgetId: widget.id,
|
||||
title: widget.title,
|
||||
type: widget.type,
|
||||
gridPosition: widget.gridPosition,
|
||||
configuration: widget.configuration,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to update widget: ${error.message}`,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user