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:
Félix Malfait
2025-12-12 07:35:14 +01:00
committed by GitHub
parent 70a78aafe9
commit 5f4f4c0af8
18 changed files with 976 additions and 55 deletions
@@ -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;
},
);
};