From 5f4f4c0af83657e4858f786a5f8bb86557b1d7d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Fri, 12 Dec 2025 07:35:14 +0100 Subject: [PATCH] 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 --- .../skills/skills/dashboard-building.skill.ts | 102 ++++---- .../constants/dashboard-tool-service.token.ts | 1 + .../tool-provider/enums/tool-category.enum.ts | 1 + .../providers/dashboard-tool.provider.ts | 49 ++++ .../services/tool-registry.service.ts | 17 +- .../tool-provider/tool-provider.module.ts | 17 +- .../ai/ai-chat/ai-chat.module.ts | 2 + .../services/chat-execution.service.ts | 3 + .../tools/add-dashboard-widget.tool.ts | 77 ++++++ .../tools/create-complete-dashboard.tool.ts | 219 ++++++++++++++++++ .../dashboard/tools/dashboard-tools.module.ts | 28 +++ .../tools/delete-dashboard-widget.tool.ts | 47 ++++ .../dashboard/tools/get-dashboard.tool.ts | 101 ++++++++ .../dashboard/tools/list-dashboards.tool.ts | 66 ++++++ .../dashboard/tools/schemas/widget.schema.ts | 119 ++++++++++ .../dashboard-tool.workspace-service.ts | 73 ++++++ .../types/dashboard-tool-dependencies.type.ts | 24 ++ .../tools/update-dashboard-widget.tool.ts | 85 +++++++ 18 files changed, 976 insertions(+), 55 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/constants/dashboard-tool-service.token.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/providers/dashboard-tool.provider.ts create mode 100644 packages/twenty-server/src/modules/dashboard/tools/add-dashboard-widget.tool.ts create mode 100644 packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts create mode 100644 packages/twenty-server/src/modules/dashboard/tools/dashboard-tools.module.ts create mode 100644 packages/twenty-server/src/modules/dashboard/tools/delete-dashboard-widget.tool.ts create mode 100644 packages/twenty-server/src/modules/dashboard/tools/get-dashboard.tool.ts create mode 100644 packages/twenty-server/src/modules/dashboard/tools/list-dashboards.tool.ts create mode 100644 packages/twenty-server/src/modules/dashboard/tools/schemas/widget.schema.ts create mode 100644 packages/twenty-server/src/modules/dashboard/tools/services/dashboard-tool.workspace-service.ts create mode 100644 packages/twenty-server/src/modules/dashboard/tools/types/dashboard-tool-dependencies.type.ts create mode 100644 packages/twenty-server/src/modules/dashboard/tools/update-dashboard-widget.tool.ts diff --git a/packages/twenty-server/src/engine/core-modules/skills/skills/dashboard-building.skill.ts b/packages/twenty-server/src/engine/core-modules/skills/skills/dashboard-building.skill.ts index 76223083f3..e0828c1e1e 100644 --- a/packages/twenty-server/src/engine/core-modules/skills/skills/dashboard-building.skill.ts +++ b/packages/twenty-server/src/engine/core-modules/skills/skills/dashboard-building.skill.ts @@ -8,59 +8,71 @@ export const DASHBOARD_BUILDING_SKILL: SkillDefinition = { You help users create and manage dashboards with widgets. -## Capabilities +## CRITICAL: Creating GRAPH Widgets -- Create new dashboards from scratch -- Add, modify, and remove widgets from dashboards -- Configure widget types (VIEW, GRAPH, FIELDS, TIMELINE, TASKS, NOTES, FILES, EMAILS, CALENDAR, RICH_TEXT, IFRAME, WORKFLOW) -- Manage dashboard tabs and layouts -- Position widgets in a grid system (12-column layout) +Before creating any GRAPH widget, you MUST: +1. Use list_object_metadata_items to get the objectMetadataId (e.g., for "opportunity", "company") +2. From the response, get the field IDs you need (aggregateFieldMetadataId, primaryAxisGroupByFieldMetadataId) -## Dashboard Structure +GRAPH widgets require real UUIDs from the workspace metadata, NOT made-up values. -- **Dashboard**: Container with a title and pageLayout -- **PageLayout**: Contains tabs (type: DASHBOARD) -- **PageLayoutTab**: Contains widgets with a title, position, and layoutMode (grid/vertical-list/canvas) -- **PageLayoutWidget**: Individual widget with type, title, gridPosition, and optional configuration +## Widget Configuration + +### GRAPH - AGGREGATE (KPI numbers) +Shows a single aggregated value (count, sum, average). +Required: +- objectMetadataId: UUID of the object (e.g., opportunity) +- configuration.graphType: "AGGREGATE" +- configuration.aggregateFieldMetadataId: UUID of field to aggregate +- configuration.aggregateOperation: "COUNT", "SUM", "AVG", "MIN", "MAX" + +### GRAPH - BAR/LINE Charts +Shows data grouped by a dimension. +Required: +- objectMetadataId: UUID of the object +- configuration.graphType: "VERTICAL_BAR", "HORIZONTAL_BAR", or "LINE" +- configuration.aggregateFieldMetadataId: field to aggregate +- configuration.aggregateOperation: aggregation type +- configuration.primaryAxisGroupByFieldMetadataId: field to group by (x-axis) + +### GRAPH - PIE Charts +Shows data distribution as slices. +Required: +- objectMetadataId: UUID of the object +- configuration.graphType: "PIE" +- configuration.aggregateFieldMetadataId: field to aggregate +- configuration.aggregateOperation: aggregation type +- configuration.groupByFieldMetadataId: field to slice by + +### IFRAME +Embeds external content: +- configuration.url: "https://..." + +### STANDALONE_RICH_TEXT +Text content widget: +- configuration.body: "Your text here" ## Grid System -- 12 columns total -- Grid positions: { row, column, rowSpan, columnSpan } -- Common sizes: Full width (columnSpan: 12), Half width (columnSpan: 6), Quarter width (columnSpan: 3) -- Typical heights: Small (rowSpan: 4), Medium (rowSpan: 6), Large (rowSpan: 8) +- 12 columns (0-11) +- KPI widgets: rowSpan 2-4, columnSpan 3-4 +- Charts: rowSpan 6-8, columnSpan 6-12 +- Common layouts: + - 4 KPIs in a row: each { columnSpan: 3 } + - 2 charts side by side: each { columnSpan: 6 } + - Full width chart: { column: 0, columnSpan: 12 } -## Widget Types Explained +## Workflow -- **VIEW**: Display a filtered view of records (companies, people, opportunities, etc.) -- **GRAPH**: Show charts and visualizations of data -- **FIELDS**: Display specific fields from a record -- **TIMELINE**: Show activity timeline -- **TASKS**: Display tasks list -- **NOTES**: Show notes -- **FILES**: Display file attachments -- **EMAILS**: Show email communications -- **CALENDAR**: Display calendar events -- **RICH_TEXT**: Custom text content -- **IFRAME**: Embed external content -- **WORKFLOW**: Display workflow information +1. Ask user what data they want to visualize +2. Load list_object_metadata_items to discover available objects and fields +3. Create dashboard with appropriate widgets using real field IDs +4. Use get_dashboard to verify creation -## Approach +## Best Practices -- Ask clarifying questions about dashboard purpose and desired widgets -- Suggest appropriate widget types and layouts for the use case -- Create well-organized, visually balanced dashboards -- For modifications, first understand current structure -- Explain widget placement and purpose -- Consider responsive design (widgets stack on smaller screens) - -## Layout Best Practices - -- Place most important information at the top -- Group related widgets together -- Use consistent widget sizes when possible -- Leave some whitespace for visual clarity -- Consider logical reading order (left to right, top to bottom) - -Prioritize user needs and dashboard usability.`, +- Place KPIs at the top (row 0) +- Group related charts together +- Use consistent heights within rows +- Start simple, add complexity as needed`, }; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/constants/dashboard-tool-service.token.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/constants/dashboard-tool-service.token.ts new file mode 100644 index 0000000000..c470225abc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/constants/dashboard-tool-service.token.ts @@ -0,0 +1 @@ +export const DASHBOARD_TOOL_SERVICE_TOKEN = Symbol('DASHBOARD_TOOL_SERVICE'); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/enums/tool-category.enum.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/enums/tool-category.enum.ts index 5ebd380de9..51d577a233 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/enums/tool-category.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/enums/tool-category.enum.ts @@ -5,4 +5,5 @@ export enum ToolCategory { METADATA = 'METADATA', NATIVE_MODEL = 'NATIVE_MODEL', VIEW = 'VIEW', + DASHBOARD = 'DASHBOARD', } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/dashboard-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/dashboard-tool.provider.ts new file mode 100644 index 0000000000..e197f57cd5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/dashboard-tool.provider.ts @@ -0,0 +1,49 @@ +import { Inject, Injectable, Optional } from '@nestjs/common'; + +import { type ToolSet } from 'ai'; +import { PermissionFlagType } from 'twenty-shared/constants'; + +import { + type ToolProvider, + type ToolProviderContext, +} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; + +import { DASHBOARD_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/dashboard-tool-service.token'; +import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; +import type { DashboardToolWorkspaceService } from 'src/modules/dashboard/tools/services/dashboard-tool.workspace-service'; + +@Injectable() +export class DashboardToolProvider implements ToolProvider { + readonly category = ToolCategory.DASHBOARD; + + constructor( + @Optional() + @Inject(DASHBOARD_TOOL_SERVICE_TOKEN) + private readonly dashboardToolService: DashboardToolWorkspaceService | null, + private readonly permissionsService: PermissionsService, + ) {} + + async isAvailable(context: ToolProviderContext): Promise { + if (!this.dashboardToolService) { + return false; + } + + return this.permissionsService.checkRolesPermissions( + context.rolePermissionConfig, + context.workspaceId, + PermissionFlagType.LAYOUTS, + ); + } + + async generateTools(context: ToolProviderContext): Promise { + if (!this.dashboardToolService) { + return {}; + } + + return this.dashboardToolService.generateDashboardTools( + context.workspaceId, + context.rolePermissionConfig, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts index 3389f5bc18..1b633771e1 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts @@ -15,14 +15,26 @@ import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-perm export type ToolIndexEntry = { name: string; description: string; - category: 'database' | 'action' | 'workflow' | 'metadata' | 'view'; + category: + | 'database' + | 'action' + | 'workflow' + | 'metadata' + | 'view' + | 'dashboard'; objectName?: string; operation?: string; }; export type ToolSearchOptions = { limit?: number; - category?: 'database' | 'action' | 'workflow' | 'metadata' | 'view'; + category?: + | 'database' + | 'action' + | 'workflow' + | 'metadata' + | 'view' + | 'dashboard'; }; export type ToolContext = { @@ -174,6 +186,7 @@ export class ToolRegistryService { METADATA: 'metadata', NATIVE_MODEL: 'action', VIEW: 'view', + DASHBOARD: 'dashboard', }; return Object.entries(tools).map(([name, tool]) => ({ diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts index cbf0a244ad..209c073150 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts @@ -4,6 +4,7 @@ import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-cru import { ToolGeneratorModule } from 'src/engine/core-modules/tool-generator/tool-generator.module'; import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token'; import { ActionToolProvider } from 'src/engine/core-modules/tool-provider/providers/action-tool.provider'; +import { DashboardToolProvider } from 'src/engine/core-modules/tool-provider/providers/dashboard-tool.provider'; import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider'; import { MetadataToolProvider } from 'src/engine/core-modules/tool-provider/providers/metadata-tool.provider'; import { ViewToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-tool.provider'; @@ -21,14 +22,10 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache import { ToolProviderService } from './services/tool-provider.service'; import { ToolRegistryService } from './services/tool-registry.service'; -// NOTE: This module does NOT import WorkflowToolsModule to avoid circular dependency: -// ToolProviderModule -> WorkflowToolsModule -> WorkflowTriggerModule -// -> WorkflowRunnerModule -> WorkflowExecutorModule -> AiAgentActionModule -// -> AiAgentExecutionModule -> ToolProviderModule -// -// Instead, WorkflowToolsModule is a @Global() module that provides WORKFLOW_TOOL_SERVICE_TOKEN. -// When WorkflowToolsModule is imported anywhere in the app (e.g., AiChatModule), -// the token becomes available globally to WorkflowToolProvider via @Optional() injection. +// NOTE: This module does NOT import WorkflowToolsModule or DashboardToolsModule to avoid +// circular dependencies. Instead, they are @Global() modules that provide their tokens. +// When imported anywhere in the app (e.g., AiChatModule), the tokens become available +// globally to their respective providers via @Optional() injection. @Module({ imports: [ @@ -46,6 +43,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; ], providers: [ ActionToolProvider, + DashboardToolProvider, DatabaseToolProvider, MetadataToolProvider, ViewToolProvider, @@ -54,12 +52,14 @@ import { ToolRegistryService } from './services/tool-registry.service'; provide: TOOL_PROVIDERS, useFactory: ( actionProvider: ActionToolProvider, + dashboardProvider: DashboardToolProvider, databaseProvider: DatabaseToolProvider, metadataProvider: MetadataToolProvider, viewProvider: ViewToolProvider, workflowProvider: WorkflowToolProvider, ) => [ actionProvider, + dashboardProvider, databaseProvider, metadataProvider, viewProvider, @@ -67,6 +67,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; ], inject: [ ActionToolProvider, + DashboardToolProvider, DatabaseToolProvider, MetadataToolProvider, ViewToolProvider, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts index 6115e32abb..aad174d5da 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts @@ -18,6 +18,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; +import { DashboardToolsModule } from 'src/modules/dashboard/tools/dashboard-tools.module'; import { WorkflowToolsModule } from 'src/modules/workflow/workflow-tools/workflow-tools.module'; import { AgentChatController } from './controllers/agent-chat.controller'; @@ -50,6 +51,7 @@ import { ChatExecutionService } from './services/chat-execution.service'; UserWorkspaceModule, AiBillingModule, ToolProviderModule, + DashboardToolsModule, WorkflowToolsModule, ], controllers: [AgentChatController], diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts index 403ed2a4d9..5d41b466e6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -317,6 +317,7 @@ ${preloadedTools.length > 0 ? preloadedTools.map((t) => `- \`${t}\` ✓`).join(' 'database', 'action', 'workflow', + 'dashboard', 'metadata', 'view', ]; @@ -362,6 +363,8 @@ ${tools return 'Metadata Tools (schema management)'; case 'view': return 'View Tools (query views)'; + case 'dashboard': + return 'Dashboard Tools (create/manage dashboards)'; default: return category; } diff --git a/packages/twenty-server/src/modules/dashboard/tools/add-dashboard-widget.tool.ts b/packages/twenty-server/src/modules/dashboard/tools/add-dashboard-widget.tool.ts new file mode 100644 index 0000000000..a6108e9625 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/add-dashboard-widget.tool.ts @@ -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, + 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; + }) => { + 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, + }; + } + }, +}); diff --git a/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts b/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts new file mode 100644 index 0000000000..60133f68bf --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts @@ -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: "", configuration: { graphType: "AGGREGATE", aggregateFieldMetadataId: "", aggregateOperation: "SUM" } } + +2. GRAPH with graphType "VERTICAL_BAR" or "HORIZONTAL_BAR": + - Additional required: configuration.primaryAxisGroupByFieldMetadataId + - Example: { graphType: "VERTICAL_BAR", aggregateFieldMetadataId: "", aggregateOperation: "COUNT", primaryAxisGroupByFieldMetadataId: "" } + +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: "", aggregateOperation: "COUNT", groupByFieldMetadataId: "" } + +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; + }>; + }) => { + 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 => { + 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; + }, + ); +}; diff --git a/packages/twenty-server/src/modules/dashboard/tools/dashboard-tools.module.ts b/packages/twenty-server/src/modules/dashboard/tools/dashboard-tools.module.ts new file mode 100644 index 0000000000..9432ce87c0 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/dashboard-tools.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/modules/dashboard/tools/delete-dashboard-widget.tool.ts b/packages/twenty-server/src/modules/dashboard/tools/delete-dashboard-widget.tool.ts new file mode 100644 index 0000000000..b6c0ae1ee8 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/delete-dashboard-widget.tool.ts @@ -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, + 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, + }; + } + }, +}); diff --git a/packages/twenty-server/src/modules/dashboard/tools/get-dashboard.tool.ts b/packages/twenty-server/src/modules/dashboard/tools/get-dashboard.tool.ts new file mode 100644 index 0000000000..d1853fc2c7 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/get-dashboard.tool.ts @@ -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, + }; + } + }, +}); diff --git a/packages/twenty-server/src/modules/dashboard/tools/list-dashboards.tool.ts b/packages/twenty-server/src/modules/dashboard/tools/list-dashboards.tool.ts new file mode 100644 index 0000000000..aacd2e869f --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/list-dashboards.tool.ts @@ -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, + 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, + }; + } + }, +}); diff --git a/packages/twenty-server/src/modules/dashboard/tools/schemas/widget.schema.ts b/packages/twenty-server/src/modules/dashboard/tools/schemas/widget.schema.ts new file mode 100644 index 0000000000..654859de9b --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/schemas/widget.schema.ts @@ -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 }; diff --git a/packages/twenty-server/src/modules/dashboard/tools/services/dashboard-tool.workspace-service.ts b/packages/twenty-server/src/modules/dashboard/tools/services/dashboard-tool.workspace-service.ts new file mode 100644 index 0000000000..1a1ad54bb1 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/services/dashboard-tool.workspace-service.ts @@ -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, + }; + } +} diff --git a/packages/twenty-server/src/modules/dashboard/tools/types/dashboard-tool-dependencies.type.ts b/packages/twenty-server/src/modules/dashboard/tools/types/dashboard-tool-dependencies.type.ts new file mode 100644 index 0000000000..86c1c66071 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/types/dashboard-tool-dependencies.type.ts @@ -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; +}; diff --git a/packages/twenty-server/src/modules/dashboard/tools/update-dashboard-widget.tool.ts b/packages/twenty-server/src/modules/dashboard/tools/update-dashboard-widget.tool.ts new file mode 100644 index 0000000000..b68c28970b --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/update-dashboard-widget.tool.ts @@ -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, + 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; + }) => { + 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, + }; + } + }, +});