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/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"dependencies": {
"@amplitude/node": "1.10.2",
"@aws-sdk/client-bedrock-runtime": "^3.954.0",
"@aws-sdk/lib-dynamodb": "^3.953.0",
"@electric-sql/pglite": "^0.3.14",
"@faker-js/faker": "^10.1.0",
Expand Down
1 change: 1 addition & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import { SignInAuditModule } from './entities/user-sign-in-audit/sign-in-audit.m
TableCategoriesModule,
UserSecretModule,
SignInAuditModule,
AIModule,
],
controllers: [AppController],
providers: [
Expand Down
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 @@ -154,6 +154,7 @@ export enum UseCaseType {
DELETE_API_KEY = 'DELETE_API_KEY',

REQUEST_INFO_FROM_TABLE_WITH_AI_V2 = 'REQUEST_INFO_FROM_TABLE_WITH_AI_V2',
REQUEST_AI_SETTINGS_AND_WIDGETS_CREATION = 'REQUEST_AI_SETTINGS_AND_WIDGETS_CREATION',

CREATE_TABLE_FILTERS = 'CREATE_TABLE_FILTERS',
FIND_TABLE_FILTERS = 'FIND_TABLE_FILTERS',
Expand Down
10 changes: 10 additions & 0 deletions backend/src/entities/ai/ai-data-entities/types/ai-module-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ForeignKeyDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/foreign-key.ds.js';
import { PrimaryKeyDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/primary-key.ds.js';
import { TableStructureDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/table-structure.ds.js';

export type TableInformation = {
table_name: string;
structure: Array<TableStructureDS>;
foreignKeys: Array<ForeignKeyDS>;
primaryColumns: Array<PrimaryKeyDS>;
};
5 changes: 5 additions & 0 deletions backend/src/entities/ai/ai-use-cases.interface.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { InTransactionEnum } from '../../enums/in-transaction.enum.js';
import { FindOneConnectionDs } from '../connection/application/data-structures/find-one-connection.ds.js';
import { RequestInfoFromTableDSV2 } from './application/data-structures/request-info-from-table.ds.js';

export interface IRequestInfoFromTableV2 {
execute(inputData: RequestInfoFromTableDSV2, inTransaction: InTransactionEnum): Promise<void>;
}

export interface IAISettingsAndWidgetsCreation {
execute(connectionData: FindOneConnectionDs, inTransaction: InTransactionEnum): Promise<void>;
}
20 changes: 18 additions & 2 deletions backend/src/entities/ai/ai.module.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
import { Global, MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthMiddleware } from '../../authorization/auth.middleware.js';
import { GlobalDatabaseContext } from '../../common/application/global-database-context.js';
import { BaseType, UseCaseType } from '../../common/data-injection.tokens.js';
import { LogOutEntity } from '../log-out/log-out.entity.js';
import { UserEntity } from '../user/user.entity.js';
import { AiService } from './ai.service.js';
import { AmazonBedrockAiProvider } from './amazon-bedrock/amazon-bedrock.ai.provider.js';
import { RequestInfoFromTableWithAIUseCaseV4 } from './use-cases/request-info-from-table-with-ai-v4.use.case.js';
import { UserAIRequestsControllerV2 } from './user-ai-requests-v2.controller.js';
import { RequestAISettingsAndWidgetsCreationUseCase } from './use-cases/request-ai-settings-and-widgets-creation.use.case.js';

@Global()
@Module({
imports: [TypeOrmModule.forFeature([UserEntity, LogOutEntity])],
providers: [
Expand All @@ -19,11 +23,23 @@ import { UserAIRequestsControllerV2 } from './user-ai-requests-v2.controller.js'
provide: UseCaseType.REQUEST_INFO_FROM_TABLE_WITH_AI_V2,
useClass: RequestInfoFromTableWithAIUseCaseV4,
},
{
provide: UseCaseType.REQUEST_AI_SETTINGS_AND_WIDGETS_CREATION,
useClass: RequestAISettingsAndWidgetsCreationUseCase,
},
AmazonBedrockAiProvider,
AiService,
],
exports: [AiService, AmazonBedrockAiProvider],
controllers: [UserAIRequestsControllerV2],
})
export class AIModule implements NestModule {
public configure(consumer: MiddlewareConsumer): any {
consumer.apply(AuthMiddleware).forRoutes({ path: '/ai/v2/request/:connectionId', method: RequestMethod.POST });
consumer
.apply(AuthMiddleware)
.forRoutes(
{ path: '/ai/v2/request/:connectionId', method: RequestMethod.POST },
{ path: '/ai/v2/setup/:connectionId', method: RequestMethod.GET },
);
}
}
218 changes: 218 additions & 0 deletions backend/src/entities/ai/ai.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { Injectable } from '@nestjs/common';
import { TableSettingsEntity } from '../table-settings/table-settings.entity.js';
import { TableWidgetEntity } from '../widget/table-widget.entity.js';
import { TableInformation } from './ai-data-entities/types/ai-module-types.js';
import { AmazonBedrockAiProvider } from './amazon-bedrock/amazon-bedrock.ai.provider.js';
import { QueryOrderingEnum } from '../../enums/query-ordering.enum.js';
import { WidgetTypeEnum } from '../../enums/widget-type.enum.js';
import { checkFieldAutoincrement } from '../../helpers/check-field-autoincrement.js';

interface AIGeneratedTableSettings {
table_name: string;
display_name: string;
list_fields: string[];
ordering_field: string | null;
ordering: 'ASC' | 'DESC';
search_fields: string[];
readonly_fields: string[];
columns_view: string[];
widgets: Array<{
field_name: string;
widget_type: string;
name: string;
description: string;
}>;
}

interface AIResponse {
tables: AIGeneratedTableSettings[];
}

@Injectable()
export class AiService {
constructor(protected readonly aiProvider: AmazonBedrockAiProvider) {}

public async generateNewTableSettingsWithAI(
tablesInformation: Array<TableInformation>,
): Promise<Array<TableSettingsEntity>> {
const prompt = this.buildPrompt(tablesInformation);
const aiResponse = await this.aiProvider.generateResponse(prompt);
const parsedResponse = this.parseAIResponse(aiResponse);
return this.buildTableSettingsEntities(parsedResponse, tablesInformation);
}

private buildPrompt(tablesInformation: Array<TableInformation>): string {
const widgetTypes = Object.values(WidgetTypeEnum).join(', ');

const tablesDescription = tablesInformation
.map((table) => {
const columns = table.structure
.map(
(col) =>
` - ${col.column_name}: ${col.data_type}${col.allow_null ? ' (nullable)' : ''}${checkFieldAutoincrement(col.column_default, col.extra) ? ' (auto_increment)' : ''}`,
)
.join('\n');
const primaryKeys = table.primaryColumns.map((pk) => pk.column_name).join(', ');
const foreignKeys = table.foreignKeys
.map((fk) => ` - ${fk.column_name} -> ${fk.referenced_table_name}.${fk.referenced_column_name}`)
.join('\n');

return `
Table: ${table.table_name}
Primary Keys: ${primaryKeys || 'none'}
Columns:
${columns}
Foreign Keys:
${foreignKeys || ' none'}`;
})
.join('\n\n');

return `You are a database administration assistant. Analyze the following database tables and generate optimal settings for displaying and managing them in a web admin panel.

For each table, provide:
1. display_name: A human-readable name for the table
2. list_fields: Columns to display in the table list view (most important columns first, max 5-7 columns)
3. ordering_field: The best column to sort by default (usually created_at, id, or a timestamp)
4. ordering: ASC or DESC
5. search_fields: Columns that should be searchable
6. readonly_fields: Columns that should not be editable (like auto_increment, timestamps)
7. columns_view: All columns in preferred display order
8. widgets: For each column, suggest the best widget type from: ${widgetTypes}

Available widget types and when to use them:
- Password: for password fields
- Boolean: for boolean/bit columns
- Date: for date columns
- Time: for time-only columns
- DateTime: for datetime/timestamp columns
- JSON: for JSON/JSONB columns
- Textarea: for long text fields (description, content, etc.)
- String: for short text fields (name, title, etc.)
- Readonly: for auto-generated fields
- Number: for numeric columns
- Select: for columns with limited options
- UUID: for UUID columns
- Enum: for enum columns
- Foreign_key: for foreign key columns
- File: for file path columns
- Image: for image URL columns
- URL: for URL columns
- Code: for code snippets
- Phone: for phone number columns
- Country: for country columns
- Color: for color columns (hex values)
- Range: for range values
- Timezone: for timezone columns

Database tables to analyze:
${tablesDescription}

Respond ONLY with valid JSON in this exact format (no markdown, no explanations):
{
"tables": [
{
"table_name": "table_name",
"display_name": "Human Readable Name",
"list_fields": ["col1", "col2"],
"ordering_field": "created_at",
"ordering": "DESC",
"search_fields": ["name", "email"],
"readonly_fields": ["id", "created_at"],
"columns_view": ["id", "name", "email", "created_at"],
"widgets": [
{
"field_name": "column_name",
"widget_type": "String",
"name": "Column Display Name",
"description": "Description of what this column contains"
}
]
}
]
}`;
}

private parseAIResponse(aiResponse: string): AIResponse {
let cleanedResponse = aiResponse.trim();
if (cleanedResponse.startsWith('```json')) {
cleanedResponse = cleanedResponse.slice(7);
} else if (cleanedResponse.startsWith('```')) {
cleanedResponse = cleanedResponse.slice(3);
}
if (cleanedResponse.endsWith('```')) {
cleanedResponse = cleanedResponse.slice(0, -3);
}
cleanedResponse = cleanedResponse.trim();

try {
return JSON.parse(cleanedResponse) as AIResponse;
} catch (error) {
throw new Error(`Failed to parse AI response: ${error.message}`);
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

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

The error parsing logic in this method does not account for potential issues when accessing error.message. If the error caught is not an Error object (e.g., a string or other type is thrown), accessing error.message will result in undefined. Consider using a type guard or checking the error type before accessing its message property.

Suggested change
throw new Error(`Failed to parse AI response: ${error.message}`);
const errorMessage =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: (() => {
try {
return JSON.stringify(error);
} catch {
return 'Unknown error';
}
})();
throw new Error(`Failed to parse AI response: ${errorMessage}`);

Copilot uses AI. Check for mistakes.
}
}

private buildTableSettingsEntities(
aiResponse: AIResponse,
tablesInformation: Array<TableInformation>,
): Array<TableSettingsEntity> {
return aiResponse.tables.map((tableSettings) => {
const tableInfo = tablesInformation.find((t) => t.table_name === tableSettings.table_name);
const validColumnNames = tableInfo?.structure.map((col) => col.column_name) || [];

const settings = new TableSettingsEntity();
settings.table_name = tableSettings.table_name;
settings.display_name = tableSettings.display_name;
settings.list_fields = this.filterValidColumns(tableSettings.list_fields, validColumnNames);
settings.ordering_field = tableSettings.ordering_field;
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

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

The ordering_field value from AI is not validated against validColumnNames before being assigned. While list_fields, search_fields, readonly_fields, and columns_view are all filtered through filterValidColumns, ordering_field is directly assigned without validation. If the AI suggests an invalid column name for ordering_field, this could cause runtime errors when the ordering is applied. Consider validating that ordering_field exists in validColumnNames, or setting it to null if invalid.

Suggested change
settings.ordering_field = tableSettings.ordering_field;
settings.ordering_field = validColumnNames.includes(tableSettings.ordering_field)
? tableSettings.ordering_field
: null;

Copilot uses AI. Check for mistakes.
settings.ordering = tableSettings.ordering === 'DESC' ? QueryOrderingEnum.DESC : QueryOrderingEnum.ASC;
settings.search_fields = this.filterValidColumns(tableSettings.search_fields, validColumnNames);
settings.readonly_fields = this.filterValidColumns(tableSettings.readonly_fields, validColumnNames);
settings.columns_view = this.filterValidColumns(tableSettings.columns_view, validColumnNames);
settings.table_widgets = tableSettings.widgets
.filter((w) => validColumnNames.includes(w.field_name))
.map((widgetData) => {
const widget = new TableWidgetEntity();
widget.field_name = widgetData.field_name;
widget.widget_type = this.mapWidgetType(widgetData.widget_type);
widget.name = widgetData.name;
widget.description = widgetData.description;
return widget;
});
Comment on lines +171 to +180
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

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

The mapWidgetType method returns WidgetTypeEnum | undefined, but when a widget type doesn't match any in the map, undefined is assigned to widget.widget_type. This could cause issues downstream if code expects widget_type to always have a valid enum value. Consider either filtering out widgets with unmapped types, using a default widget type (e.g., WidgetTypeEnum.String), or explicitly handling the undefined case in the validation logic.

Copilot uses AI. Check for mistakes.

return settings;
});
}

private filterValidColumns(columns: string[], validColumnNames: string[]): string[] {
return columns?.filter((col) => validColumnNames.includes(col)) || [];
}

private mapWidgetType(widgetType: string): WidgetTypeEnum | undefined {
const widgetTypeMap = new Map<string, WidgetTypeEnum>([
['Password', WidgetTypeEnum.Password],
['Boolean', WidgetTypeEnum.Boolean],
['Date', WidgetTypeEnum.Date],
['Time', WidgetTypeEnum.Time],
['DateTime', WidgetTypeEnum.DateTime],
['JSON', WidgetTypeEnum.JSON],
['Textarea', WidgetTypeEnum.Textarea],
['String', WidgetTypeEnum.String],
['Readonly', WidgetTypeEnum.Readonly],
['Number', WidgetTypeEnum.Number],
['Select', WidgetTypeEnum.Select],
['UUID', WidgetTypeEnum.UUID],
['Enum', WidgetTypeEnum.Enum],
['Foreign_key', WidgetTypeEnum.Foreign_key],
['File', WidgetTypeEnum.File],
['Image', WidgetTypeEnum.Image],
['URL', WidgetTypeEnum.URL],
['Code', WidgetTypeEnum.Code],
['Phone', WidgetTypeEnum.Phone],
['Country', WidgetTypeEnum.Country],
['Color', WidgetTypeEnum.Color],
['Range', WidgetTypeEnum.Range],
['Timezone', WidgetTypeEnum.Timezone],
]);
return widgetTypeMap.get(widgetType);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface IAIProvider {
generateResponse(prompt: string): Promise<string>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { BedrockRuntimeClient, ConverseCommand } from '@aws-sdk/client-bedrock-runtime';
import { IAIProvider } from './ai-provider.interface.js';

@Injectable()
export class AmazonBedrockAiProvider implements IAIProvider {
private readonly bedrockRuntimeClient: BedrockRuntimeClient;
private readonly modelId: string = 'global.anthropic.claude-sonnet-4-5-20250929-v1:0';
private readonly temperature: number = 0.7;
private readonly maxTokens: number = 1024;
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

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

The maxTokens value of 1024 may be insufficient for generating comprehensive table settings for databases with many tables or complex schemas. The prompt requests settings for multiple tables with display names, fields, ordering, search fields, readonly fields, columns view, and widgets for each column. For databases with 10+ tables, this could easily exceed 1024 tokens. Consider increasing this value or making it configurable based on the number of tables being processed.

Copilot uses AI. Check for mistakes.
private readonly region: string = 'us-west-2';
private readonly topP: number = 0.9;

constructor() {
this.bedrockRuntimeClient = new BedrockRuntimeClient({
region: this.region,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
Comment on lines +15 to +19
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

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

The AWS credentials are loaded directly from environment variables without validation. If AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY are undefined, the BedrockRuntimeClient will be instantiated with undefined credentials, which may lead to authentication failures at runtime rather than at startup. Consider adding validation to ensure these environment variables are set, and provide a clear error message if they are missing.

Suggested change
this.bedrockRuntimeClient = new BedrockRuntimeClient({
region: this.region,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
if (!accessKeyId || !secretAccessKey) {
throw new Error(
'AWS credentials are not configured. Please set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables.',
);
}
this.bedrockRuntimeClient = new BedrockRuntimeClient({
region: this.region,
credentials: {
accessKeyId,
secretAccessKey,

Copilot uses AI. Check for mistakes.
},
});
}
public async generateResponse(prompt: string): Promise<string> {
const conversation = [
{
role: 'user' as const,
content: [{ text: prompt }],
},
];

const command = new ConverseCommand({
modelId: this.modelId,
messages: conversation,
inferenceConfig: { maxTokens: this.maxTokens, temperature: this.temperature, topP: this.topP },
});
try {
const response = await this.bedrockRuntimeClient.send(command);
const responseText = response.output.message?.content[0].text;
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

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

The code accesses response.output.message?.content[0].text without checking if the content array has any elements. If the response has an empty content array, this will throw a runtime error. Add a check to ensure content array length is greater than 0 before accessing content[0].

Suggested change
const responseText = response.output.message?.content[0].text;
const content = response.output.message?.content;
const responseText = content && content.length > 0 ? content[0]?.text : undefined;

Copilot uses AI. Check for mistakes.
return responseText || 'No response generated.';
} catch (error) {
console.error('Error generating AI response:', error);
throw new Error('Failed to generate AI response.');
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

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

The error handling throws a generic Error with a hardcoded message that loses the original error details. This makes debugging AI provider issues difficult. Consider including the original error message or using a more descriptive error that includes context about what failed (e.g., model ID, region, or specific AWS error codes).

Suggested change
throw new Error('Failed to generate AI response.');
const originalMessage =
error instanceof Error ? error.message : typeof error === 'string' ? error : JSON.stringify(error);
throw new Error(
`Failed to generate AI response (modelId=${this.modelId}, region=${this.region}): ${originalMessage}`,
);

Copilot uses AI. Check for mistakes.
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { BadRequestException, Inject, Injectable, Scope } from '@nestjs/common';
import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js';
import { BaseType } from '../../../common/data-injection.tokens.js';
import AbstractUseCase from '../../../common/abstract-use.case.js';
import { IAISettingsAndWidgetsCreation } from '../ai-use-cases.interface.js';
import { SharedJobsService } from '../../shared-jobs/shared-jobs.service.js';
import { FindOneConnectionDs } from '../../connection/application/data-structures/find-one-connection.ds.js';
import { Messages } from '../../../exceptions/text/messages.js';

@Injectable({ scope: Scope.REQUEST })
export class RequestAISettingsAndWidgetsCreationUseCase
extends AbstractUseCase<FindOneConnectionDs, void>
implements IAISettingsAndWidgetsCreation
{
constructor(
@Inject(BaseType.GLOBAL_DB_CONTEXT)
protected _dbContext: IGlobalDatabaseContext,
private readonly sharedJobsService: SharedJobsService,
) {
super();
}

public async implementation(connectionData: FindOneConnectionDs): Promise<void> {
const { connectionId, masterPwd } = connectionData;

const connection = await this._dbContext.connectionRepository.findAndDecryptConnection(connectionId, masterPwd);
if (!connection) {
throw new BadRequestException(Messages.CONNECTION_NOT_FOUND);
}

await this.sharedJobsService.scanDatabaseAndCreateSettingsAndWidgetsWithAI(connection);
}
}
Loading
Loading