Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/src/common/data-injection.tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ export enum UseCaseType {
UPDATE_DASHBOARD_WIDGET = 'UPDATE_DASHBOARD_WIDGET',
DELETE_DASHBOARD_WIDGET = 'DELETE_DASHBOARD_WIDGET',
GENERATE_WIDGET_WITH_AI = 'GENERATE_WIDGET_WITH_AI',
GENERATE_TABLE_DASHBOARD_WITH_AI = 'GENERATE_TABLE_DASHBOARD_WITH_AI',

IS_CONFIGURED = 'IS_CONFIGURED',
CREATE_INITIAL_USER = 'CREATE_INITIAL_USER',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export class GenerateTableDashboardWithAiDs {
connectionId: string;
masterPassword: string;
userId: string;

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

userId is part of the data structure, but the corresponding use case implementation doesn’t read it. Consider removing it from the DS/controller payload if it’s not needed, or using it for audit/ownership checks to avoid misleading/unused fields in the API contract.

Suggested change
userId: string;

Copilot uses AI. Check for mistakes.
table_name: string;
max_panels?: number;
dashboard_name?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';

export class GenerateTableDashboardWithAiDto {
@ApiPropertyOptional({
type: Number,
description: 'Maximum number of panels to generate (1-10)',
default: 6,
minimum: 1,
maximum: 10,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(10)
max_panels?: number;

@ApiPropertyOptional({
type: String,
description: 'Optional name for the dashboard. If not provided, AI will generate one.',
})
@IsOptional()
@IsString()
@MaxLength(255)
dashboard_name?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ export class GeneratedPanelPositionDto {
@ApiProperty({ description: 'Position Y in grid' })
position_y: number;

@ApiProperty({ description: 'Widget width in grid units' })
@ApiProperty({ description: 'Panel width in grid units' })
width: number;

@ApiProperty({ description: 'Widget height in grid units' })
@ApiProperty({ description: 'Panel height in grid units' })
height: number;

@ApiProperty({ description: 'Dashboard ID' })
dashboard_id: string;
@ApiProperty({ description: 'Dashboard ID', nullable: true })
dashboard_id: string | null;
}

export class GeneratedPanelWithPositionDto {
Expand All @@ -25,14 +25,14 @@ export class GeneratedPanelWithPositionDto {
@ApiPropertyOptional({ description: 'Panel description' })
description: string | null;

@ApiProperty({ description: 'Widget type', enum: DashboardWidgetTypeEnum })
widget_type: DashboardWidgetTypeEnum;
@ApiProperty({ description: 'Panel type', enum: DashboardWidgetTypeEnum })
panel_type: DashboardWidgetTypeEnum;

@ApiPropertyOptional({ description: 'Chart type for chart widgets' })
@ApiPropertyOptional({ description: 'Chart type for chart panels' })
chart_type: string | null;
Comment on lines +28 to 32

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GeneratedPanelWithPositionDto now exposes panel_type/panel_options, while other visualization APIs still expose widget_type/widget_options (e.g., saved query DTOs). This makes /dashboard/:dashboardId/widget/generate/:connectionId inconsistent and is a breaking response change for existing clients. Consider keeping the existing property names (or returning both sets temporarily) to avoid breaking consumers and to keep naming consistent across endpoints.

Copilot uses AI. Check for mistakes.

@ApiPropertyOptional({ description: 'Visualization options' })
widget_options: Record<string, unknown> | null;
panel_options: Record<string, unknown> | null;

@ApiProperty({ description: 'AI-generated SQL query text' })
query_text: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,27 @@ import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse,
import { UseCaseType } from '../../../common/data-injection.tokens.js';
import { MasterPassword } from '../../../decorators/master-password.decorator.js';
import { SlugUuid } from '../../../decorators/slug-uuid.decorator.js';
import { Timeout } from '../../../decorators/timeout.decorator.js';
import { Timeout, TimeoutDefaults } from '../../../decorators/timeout.decorator.js';
import { UserId } from '../../../decorators/user-id.decorator.js';
import { InTransactionEnum } from '../../../enums/in-transaction.enum.js';
import { ConnectionEditGuard } from '../../../guards/connection-edit.guard.js';
import { SentryInterceptor } from '../../../interceptors/sentry.interceptor.js';
import { CreatePanelPositionDs } from './data-structures/create-panel-position.ds.js';
import { DeletePanelPositionDs } from './data-structures/delete-panel-position.ds.js';
import { GeneratePanelPositionWithAiDs } from './data-structures/generate-panel-position-with-ai.ds.js';
import { GenerateTableDashboardWithAiDs } from './data-structures/generate-table-dashboard-with-ai.ds.js';
import { UpdatePanelPositionDs } from './data-structures/update-panel-position.ds.js';
import { CreatePanelPositionDto } from './dto/create-panel-position.dto.js';
import { FoundPanelPositionDto } from './dto/found-panel-position.dto.js';
import { GeneratedPanelWithPositionDto } from './dto/generated-panel-with-position.dto.js';
import { GeneratePanelPositionWithAiDto } from './dto/generate-panel-position-with-ai.dto.js';
import { GenerateTableDashboardWithAiDto } from './dto/generate-table-dashboard-with-ai.dto.js';
import { UpdatePanelPositionDto } from './dto/update-panel-position.dto.js';
import {
ICreatePanelPositionWidget,
IDeletePanelPosition,
IGeneratePanelPositionWithAi,
IGenerateTableDashboardWithAi,
IUpdatePanelPosition,
} from './use-cases/panel-position-use-cases.interface.js';

Expand All @@ -52,6 +55,8 @@ export class DashboardWidgetController {
private readonly deleteDashboardWidgetUseCase: IDeletePanelPosition,
@Inject(UseCaseType.GENERATE_WIDGET_WITH_AI)
private readonly generateWidgetWithAiUseCase: IGeneratePanelPositionWithAi,
@Inject(UseCaseType.GENERATE_TABLE_DASHBOARD_WITH_AI)
private readonly generateTableDashboardWithAiUseCase: IGenerateTableDashboardWithAi,
) {}

@ApiOperation({ summary: 'Create a new widget in a dashboard' })
Expand Down Expand Up @@ -150,19 +155,19 @@ export class DashboardWidgetController {
}

@ApiOperation({
summary: 'Generate a widget using AI',
summary: 'Generate a panel using AI',
description:
'Creates a new widget with SQL query and chart configuration generated by AI based on natural language description',
'Creates a new panel with SQL query and chart configuration generated by AI based on natural language description',
})
@ApiResponse({
status: 201,
description: 'Widget configuration generated by AI (not yet saved).',
description: 'Panel configuration generated by AI (not yet saved).',
type: GeneratedPanelWithPositionDto,
})
@ApiBody({ type: GeneratePanelPositionWithAiDto })
@ApiParam({ name: 'dashboardId', required: true })
@ApiParam({ name: 'connectionId', required: true })
@ApiQuery({ name: 'tableName', required: true, description: 'The table name to generate the widget for' })
@ApiQuery({ name: 'tableName', required: true, description: 'The table name to generate the panel for' })
@UseGuards(ConnectionEditGuard)
@Post('/dashboard/:dashboardId/widget/generate/:connectionId')
async generateWidgetWithAi(
Expand All @@ -184,4 +189,37 @@ export class DashboardWidgetController {
};
return await this.generateWidgetWithAiUseCase.execute(inputData, InTransactionEnum.OFF);
}

@ApiOperation({
summary: 'Generate a full table dashboard using AI',
description:
'Analyzes a table structure and auto-generates multiple panel configurations for a dashboard using AI',
})
@ApiResponse({
status: 201,
description: 'Dashboard with panels generated by AI and saved to database.',
})
@ApiBody({ type: GenerateTableDashboardWithAiDto })
@ApiParam({ name: 'connectionId', required: true })
@ApiQuery({ name: 'tableName', required: true, description: 'The table name to generate the dashboard for' })
@UseGuards(ConnectionEditGuard)
@Timeout(TimeoutDefaults.AI)
@Post('/dashboard/generate-table-dashboard/:connectionId')
async generateTableDashboardWithAi(
@SlugUuid('connectionId') connectionId: string,
@Query('tableName') tableName: string,
@MasterPassword() masterPwd: string,
@UserId() userId: string,
@Body() generateDto: GenerateTableDashboardWithAiDto,
Comment on lines +209 to +213

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The required tableName query parameter is not validated before being used to build the DS. This currently relies on downstream DAO failures to produce a 400, which can yield unclear messages. Add an explicit check (or a validation pipe) to return a BadRequestException when tableName is missing/empty.

Copilot uses AI. Check for mistakes.
): Promise<{ success: boolean }> {
const inputData: GenerateTableDashboardWithAiDs = {
connectionId,
masterPassword: masterPwd,
userId,
table_name: tableName,
max_panels: generateDto.max_panels,
dashboard_name: generateDto.dashboard_name,
};
return await this.generateTableDashboardWithAiUseCase.execute(inputData, InTransactionEnum.ON);

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint executes the use case with InTransactionEnum.ON, which starts a global DB transaction before the use case runs. Since the use case performs multiple AI calls (and potentially multiple EXPLAIN calls) before persisting anything, this can keep a transaction open for a long time and increase lock/connection contention. Prefer running the AI generation outside a transaction and only wrapping the persistence section in a transaction (e.g., execute with OFF and manage a shorter transaction around the saves).

Suggested change
return await this.generateTableDashboardWithAiUseCase.execute(inputData, InTransactionEnum.ON);
return await this.generateTableDashboardWithAiUseCase.execute(inputData, InTransactionEnum.OFF);

Copilot uses AI. Check for mistakes.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { DashboardWidgetController } from './panel-position.controller.js';
import { CreatePanelPositionUseCase } from './use-cases/create-panel-position.use.case.js';
import { DeletePanelPositionUseCase } from './use-cases/delete-panel-position.use.case.js';
import { GeneratePanelPositionWithAiUseCase } from './use-cases/generate-panel-position-with-ai.use.case.js';
import { GenerateTableDashboardWithAiUseCase } from './use-cases/generate-table-dashboard-with-ai.use.case.js';
import { UpdateDashboardWidgetUseCase } from './use-cases/update-panel-position.use.case.js';

@Module({
Expand All @@ -35,6 +36,10 @@ import { UpdateDashboardWidgetUseCase } from './use-cases/update-panel-position.
provide: UseCaseType.GENERATE_WIDGET_WITH_AI,
useClass: GeneratePanelPositionWithAiUseCase,
},
{
provide: UseCaseType.GENERATE_TABLE_DASHBOARD_WITH_AI,
useClass: GenerateTableDashboardWithAiUseCase,
},
],
controllers: [DashboardWidgetController],
exports: [],
Expand All @@ -46,6 +51,7 @@ export class PanelPositionModule {
.forRoutes(
{ path: '/dashboard/:dashboardId/widget/:connectionId', method: RequestMethod.POST },
{ path: '/dashboard/:dashboardId/widget/generate/:connectionId', method: RequestMethod.POST },
{ path: '/dashboard/generate-table-dashboard/:connectionId', method: RequestMethod.POST },
{ path: '/dashboard/:dashboardId/widget/:widgetId/:connectionId', method: RequestMethod.PUT },
{ path: '/dashboard/:dashboardId/widget/:widgetId/:connectionId', method: RequestMethod.DELETE },
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ import { GeneratedPanelWithPositionDto } from '../dto/generated-panel-with-posit
import { IGeneratePanelPositionWithAi } from './panel-position-use-cases.interface.js';
import { validateQuerySafety } from '../../panel/utils/check-query-is-safe.util.js';

interface AIGeneratedWidgetResponse {
interface AIGeneratedPanelResponse {
name: string;
description: string;
query_text: string;
widget_type: 'chart' | 'table' | 'counter' | 'text';
panel_type: 'chart' | 'table' | 'counter' | 'text';
chart_type?: 'bar' | 'line' | 'pie' | 'doughnut' | 'polarArea';
widget_options?: {
panel_options?: {
label_column?: string;
value_column?: string;
series?: Array<{
Expand Down Expand Up @@ -129,27 +129,27 @@ export class GeneratePanelPositionWithAiUseCase
temperature: 0.3,
});

const generatedWidget = this.parseAIResponse(aiResponse);
const generatedPanel = this.parseAIResponse(aiResponse);

validateQuerySafety(generatedWidget.query_text, foundConnection.type as ConnectionTypesEnum);
validateQuerySafety(generatedPanel.query_text, foundConnection.type as ConnectionTypesEnum);

const refinedWidget = await this.validateAndRefineQueryWithExplain(
const refinedPanel = await this.validateAndRefineQueryWithExplain(
dao,
generatedWidget,
generatedPanel,
tableInfo,
foundConnection.type as ConnectionTypesEnum,
chart_description,
);

return {
name: name || refinedWidget.name,
description: refinedWidget.description || null,
widget_type: this.mapWidgetType(refinedWidget.widget_type),
chart_type: refinedWidget.chart_type || null,
widget_options: refinedWidget.widget_options
? (refinedWidget.widget_options as unknown as Record<string, unknown>)
name: name || refinedPanel.name,
description: refinedPanel.description || null,
panel_type: this.mapPanelType(refinedPanel.panel_type),
chart_type: refinedPanel.chart_type || null,
panel_options: refinedPanel.panel_options
? (refinedPanel.panel_options as unknown as Record<string, unknown>)
: null,
query_text: refinedWidget.query_text,
query_text: refinedPanel.query_text,
connection_id: connectionId,
panel_position: {
position_x: position_x ?? 0,
Expand Down Expand Up @@ -183,9 +183,9 @@ Generate a JSON response with the following structure:
"name": "Short descriptive name for the chart (max 50 chars)",
"description": "Brief description of what the chart shows",
"query_text": "SELECT query that returns data for the chart. The query should return columns that can be used for labels and values. Use appropriate aggregations (COUNT, SUM, AVG, etc.) and GROUP BY clauses as needed. Always use the table name '${tableInfo.table_name}'.",
"widget_type": "chart" | "table" | "counter" | "text",
"panel_type": "chart" | "table" | "counter" | "text",
"chart_type": "bar" | "line" | "pie" | "doughnut" | "polarArea",
"widget_options": {
"panel_options": {
"label_column": "column name for labels/categories (x-axis)",
"value_column": "column name for values (y-axis) - use this for single series",
"series": [
Expand Down Expand Up @@ -218,19 +218,19 @@ IMPORTANT GUIDELINES:
- pie/doughnut: parts of a whole (percentages)
- polarArea: similar to pie but with equal angles
5. Use meaningful colors from this palette: #3366CC, #DC3912, #FF9900, #109618, #990099, #0099C6, #DD4477, #66AA00
6. Set widget_options.label_column to the column that should be used for labels
6. Set panel_options.label_column to the column that should be used for labels
7. For single value series, use value_column directly
8. For multiple series, use the series array

Respond ONLY with the JSON object, no additional text or explanation.`;
}

private parseAIResponse(aiResponse: string): AIGeneratedWidgetResponse {
private parseAIResponse(aiResponse: string): AIGeneratedPanelResponse {
try {
const cleanedResponse = cleanAIJsonResponse(aiResponse);
const parsed = JSON.parse(cleanedResponse) as AIGeneratedWidgetResponse;
const parsed = JSON.parse(cleanedResponse) as AIGeneratedPanelResponse;

if (!parsed.name || !parsed.query_text || !parsed.widget_type) {
if (!parsed.name || !parsed.query_text || !parsed.panel_type) {
throw new Error('Missing required fields in AI response');
}

Expand All @@ -239,23 +239,23 @@ Respond ONLY with the JSON object, no additional text or explanation.`;
console.error('Error parsing AI response:', error.message);
console.error('AI Response:', aiResponse);
throw new BadRequestException(
'Failed to generate widget configuration from AI. Please try again with a different description.',
'Failed to generate panel configuration from AI. Please try again with a different description.',
);
}
}

private async validateAndRefineQueryWithExplain(
dao: IDataAccessObject | IDataAccessObjectAgent,
generatedWidget: AIGeneratedWidgetResponse,
generatedPanel: AIGeneratedPanelResponse,
tableInfo: TableInfo,
connectionType: ConnectionTypesEnum,
chartDescription: string,
): Promise<AIGeneratedWidgetResponse> {
): Promise<AIGeneratedPanelResponse> {
if (!EXPLAIN_SUPPORTED_TYPES.has(connectionType)) {
return generatedWidget;
return generatedPanel;
}

let currentQuery = generatedWidget.query_text;
let currentQuery = generatedPanel.query_text;

for (let iteration = 0; iteration < MAX_FEEDBACK_ITERATIONS; iteration++) {
const explainResult = await this.runExplainQuery(dao, currentQuery, tableInfo.table_name);
Expand Down Expand Up @@ -290,7 +290,7 @@ Respond ONLY with the JSON object, no additional text or explanation.`;
}
}

return { ...generatedWidget, query_text: currentQuery };
return { ...generatedPanel, query_text: currentQuery };
}

private async runExplainQuery(
Expand Down Expand Up @@ -345,18 +345,31 @@ IMPORTANT:
}

private cleanQueryResponse(aiResponse: string): string {
return aiResponse
const cleaned = aiResponse
.trim()
.replace(/^```[a-zA-Z]*\n?/, '')
.replace(/```\s*$/, '')
.trim();

if (cleaned.startsWith('{')) {
try {
const parsed = JSON.parse(cleaned);
if (parsed.query_text) {
return parsed.query_text;
}
} catch {
// Not valid JSON, return as-is
}
}

return cleaned;
}

private normalizeWhitespace(query: string): string {
return query.replace(/\s+/g, ' ').trim();
}

private mapWidgetType(type: string): DashboardWidgetTypeEnum {
private mapPanelType(type: string): DashboardWidgetTypeEnum {
switch (type) {
case 'chart':
return DashboardWidgetTypeEnum.Chart;
Expand Down
Loading
Loading