Skip to content

Commit 9393201

Browse files
authored
Merge pull request #1809 from rocket-admin/backend_ai_settings_response
feat: implement AI settings and widgets creation with streaming response
2 parents 736cfea + c560fa0 commit 9393201

9 files changed

Lines changed: 455 additions & 14 deletions
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { InTransactionEnum } from '../../enums/in-transaction.enum.js';
2-
import { FindOneConnectionDs } from '../connection/application/data-structures/find-one-connection.ds.js';
2+
import { RequestAISettingsCreationDs } from './application/data-structures/request-ai-settings-creation.ds.js';
33
import { RequestInfoFromTableDSV2 } from './application/data-structures/request-info-from-table.ds.js';
44

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

99
export interface IAISettingsAndWidgetsCreation {
10-
execute(connectionData: FindOneConnectionDs, inTransaction: InTransactionEnum): Promise<void>;
10+
execute(inputData: RequestAISettingsCreationDs, inTransaction: InTransactionEnum): Promise<void>;
1111
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { Response } from 'express';
2+
import { FindOneConnectionDs } from '../../../connection/application/data-structures/find-one-connection.ds.js';
3+
4+
export class RequestAISettingsCreationDs extends FindOneConnectionDs {
5+
response: Response;
6+
}
Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import { BadRequestException, Inject, Injectable, Scope } from '@nestjs/common';
2+
import Sentry from '@sentry/minimal';
3+
import { Response } from 'express';
24
import AbstractUseCase from '../../../common/abstract-use.case.js';
35
import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js';
46
import { BaseType } from '../../../common/data-injection.tokens.js';
57
import { Messages } from '../../../exceptions/text/messages.js';
6-
import { FindOneConnectionDs } from '../../connection/application/data-structures/find-one-connection.ds.js';
8+
import { getErrorMessage } from '../../../helpers/get-error-message.js';
79
import { SharedJobsService } from '../../shared-jobs/shared-jobs.service.js';
810
import { IAISettingsAndWidgetsCreation } from '../ai-use-cases.interface.js';
11+
import { RequestAISettingsCreationDs } from '../application/data-structures/request-ai-settings-creation.ds.js';
912

1013
@Injectable({ scope: Scope.REQUEST })
1114
export class RequestAISettingsAndWidgetsCreationUseCase
12-
extends AbstractUseCase<FindOneConnectionDs, void>
15+
extends AbstractUseCase<RequestAISettingsCreationDs, void>
1316
implements IAISettingsAndWidgetsCreation
1417
{
1518
constructor(
@@ -20,14 +23,43 @@ export class RequestAISettingsAndWidgetsCreationUseCase
2023
super();
2124
}
2225

23-
public async implementation(connectionData: FindOneConnectionDs): Promise<void> {
24-
const { connectionId, masterPwd } = connectionData;
26+
public async implementation(inputData: RequestAISettingsCreationDs): Promise<void> {
27+
const { connectionId, masterPwd, response } = inputData;
2528

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

31-
await this.sharedJobsService.scanDatabaseAndCreateSettingsAndWidgetsWithAI(connection);
34+
this.setupResponseHeaders(response);
35+
36+
try {
37+
await this.sharedJobsService.scanDatabaseAndCreateSettingsAndWidgetsWithAI(connection, (chunk) =>
38+
this.writeChunk(response, chunk),
39+
);
40+
this.writeChunk(response, { type: 'complete' });
41+
response.end();
42+
} catch (error) {
43+
Sentry.captureException(error);
44+
if (!response.headersSent) {
45+
response.status(500).send({ error: 'An error occurred while processing your request.' });
46+
return;
47+
}
48+
this.writeChunk(response, { type: 'error', message: getErrorMessage(error) });
49+
response.end();
50+
}
51+
}
52+
53+
private setupResponseHeaders(response: Response): void {
54+
response.setHeader('Content-Type', 'text/event-stream');
55+
response.setHeader('Cache-Control', 'no-cache');
56+
response.setHeader('Connection', 'keep-alive');
57+
}
58+
59+
private writeChunk(
60+
response: Response,
61+
chunk: { type: 'message'; text: string } | { type: 'complete' } | { type: 'error'; message: string },
62+
): void {
63+
response.write(JSON.stringify(chunk) + '\n');
3264
}
3365
}

backend/src/entities/ai/user-ai-requests-v2.controller.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { isTest } from '../../helpers/app/is-test.js';
2525
import { ValidationHelper } from '../../helpers/validators/validation-helper.js';
2626
import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js';
2727
import { IAISettingsAndWidgetsCreation, IRequestInfoFromTableV2 } from './ai-use-cases.interface.js';
28+
import { RequestAISettingsCreationDs } from './application/data-structures/request-ai-settings-creation.ds.js';
2829
import { RequestInfoFromTableDSV2 } from './application/data-structures/request-info-from-table.ds.js';
2930
import { RequestInfoFromTableBodyDTO } from './application/dto/request-info-from-table-body.dto.js';
3031

@@ -88,7 +89,7 @@ export class UserAIRequestsControllerV2 {
8889
})
8990
@ApiResponse({
9091
status: 200,
91-
description: 'AI settings and widgets creation job has been queued.',
92+
description: 'Streams progress of the AI settings and widgets creation job as newline-delimited JSON chunks.',
9293
})
9394
@UseGuards(ConnectionEditGuard)
9495
@Timeout(!isTest() ? TimeoutDefaults.AI : TimeoutDefaults.AI_TEST)
@@ -97,12 +98,14 @@ export class UserAIRequestsControllerV2 {
9798
@SlugUuid('connectionId') connectionId: string,
9899
@MasterPassword() masterPassword: string,
99100
@UserId() userId: string,
101+
@Res({ passthrough: true }) response: Response,
100102
): Promise<void> {
101-
const connectionData = {
103+
const inputData: RequestAISettingsCreationDs = {
102104
connectionId,
103105
masterPwd: masterPassword,
104106
cognitoUserName: userId,
107+
response,
105108
};
106-
return await this.requestAISettingsAndWidgetsCreationUseCase.execute(connectionData, InTransactionEnum.OFF);
109+
return await this.requestAISettingsAndWidgetsCreationUseCase.execute(inputData, InTransactionEnum.OFF);
107110
}
108111
}

backend/src/entities/connection/use-cases/create-connection.use.case.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { IGlobalDatabaseContext } from '../../../common/application/global-datab
66
import { BaseType } from '../../../common/data-injection.tokens.js';
77
import { AccessLevelEnum } from '../../../enums/access-level.enum.js';
88
import { Messages } from '../../../exceptions/text/messages.js';
9+
import { isTest } from '../../../helpers/app/is-test.js';
910
import { Encryptor } from '../../../helpers/encryption/encryptor.js';
1011
import { isConnectionTypeAgent } from '../../../helpers/is-connection-entity-agent.js';
1112
import { slackPostMessage } from '../../../helpers/slack/slack-post-message.js';
@@ -125,7 +126,12 @@ export class CreateConnectionUseCase
125126
const connectionRO = buildCreatedConnectionDs(savedConnection, token, masterPwd);
126127
return connectionRO;
127128
} finally {
128-
if (connectionCopy && isConnectionTestedSuccessfully && !isConnectionTypeAgent(connectionCopy.type)) {
129+
if (
130+
connectionCopy &&
131+
isConnectionTestedSuccessfully &&
132+
!isConnectionTypeAgent(connectionCopy.type) &&
133+
!isTest()
134+
) {
129135
// Fire-and-forget: run AI scan in background without blocking response
130136
this.sharedJobsService.scanDatabaseAndCreateSettingsAndWidgetsWithAI(connectionCopy).catch((error) => {
131137
console.error('Background AI scan failed:', error);

backend/src/entities/shared-jobs/shared-jobs.service.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import PQueue from 'p-queue';
99
import { IGlobalDatabaseContext } from '../../common/application/global-database-context.interface.js';
1010
import { BaseType } from '../../common/data-injection.tokens.js';
1111
import { WidgetTypeEnum } from '../../enums/widget-type.enum.js';
12-
import { isTest } from '../../helpers/app/is-test.js';
1312
import { getErrorMessage } from '../../helpers/get-error-message.js';
1413
import { ValidationHelper } from '../../helpers/validators/validation-helper.js';
1514
import { AiService } from '../ai/ai.service.js';
@@ -18,6 +17,11 @@ import { TableSettingsEntity } from '../table-settings/common-table-settings/tab
1817
import { buildEmptyTableSettings } from '../table-settings/common-table-settings/utils/build-empty-table-settings.js';
1918
import { buildNewTableSettingsEntity } from '../table-settings/common-table-settings/utils/build-new-table-settings-entity.js';
2019
import { TableWidgetEntity } from '../widget/table-widget.entity.js';
20+
import { emitSettingsMessages } from './utils/emit-settings-messages.util.js';
21+
22+
export type AIScanProgressChunk = { type: 'message'; text: string };
23+
24+
export type AIScanProgressCallback = (chunk: AIScanProgressChunk) => void;
2125

2226
@Injectable()
2327
export class SharedJobsService {
@@ -27,11 +31,22 @@ export class SharedJobsService {
2731
private readonly aiService: AiService,
2832
) {}
2933

30-
public async scanDatabaseAndCreateSettingsAndWidgetsWithAI(connection: ConnectionEntity): Promise<void> {
31-
if (!connection || isTest()) {
34+
public async scanDatabaseAndCreateSettingsAndWidgetsWithAI(
35+
connection: ConnectionEntity,
36+
onProgress?: AIScanProgressCallback,
37+
): Promise<void> {
38+
if (!connection) {
3239
return;
3340
}
41+
const emit: AIScanProgressCallback = (chunk) => {
42+
if (onProgress) {
43+
onProgress(chunk);
44+
}
45+
};
46+
const message = (text: string): void => emit({ type: 'message', text });
47+
3448
console.info(`Starting AI scan for connection with id "${connection.id}"`);
49+
message(`Starting AI scan for connection "${connection.title || connection.id}"`);
3550
try {
3651
const existingTableSettings = await this._dbContext.tableSettingsRepository.find({
3752
where: {
@@ -43,14 +58,18 @@ export class SharedJobsService {
4358

4459
const existingTableNames = new Set(existingTableSettings.map((setting) => setting.table_name));
4560
const dao = getDataAccessObject(connection);
61+
message('Fetching tables from database');
4662
const tables: Array<TableDS> = await dao.getTablesFromDB();
4763
const tablesToScan = tables.filter((table) => !existingTableNames.has(table.tableName));
4864

4965
if (tablesToScan.length === 0) {
5066
console.info(`No new tables to scan for connection with id "${connection.id}"`);
67+
message('No new tables to scan — all tables already have settings');
5168
return;
5269
}
5370

71+
message(`Found ${tablesToScan.length} new ${tablesToScan.length === 1 ? 'table' : 'tables'} to scan`);
72+
5473
const queue = new PQueue({ concurrency: 4 });
5574
const tablesInformationResults = await Promise.all(
5675
tablesToScan.map((table) =>
@@ -59,13 +78,15 @@ export class SharedJobsService {
5978
const structure = await dao.getTableStructure(table.tableName, null);
6079
const primaryColumns = await dao.getTablePrimaryColumns(table.tableName, null);
6180
const foreignKeys = await dao.getTableForeignKeys(table.tableName, null);
81+
message(`Inspected structure of table "${table.tableName}"`);
6282
return {
6383
table_name: table.tableName,
6484
structure,
6585
primaryColumns,
6686
foreignKeys,
6787
};
6888
} catch (error) {
89+
message(`Failed to inspect table "${table.tableName}": ${getErrorMessage(error)}`);
6990
console.error(`Error getting table information for "${table.tableName}": ${getErrorMessage(error)}`);
7091
return null;
7192
}
@@ -77,18 +98,26 @@ export class SharedJobsService {
7798

7899
if (tablesInformation.length === 0) {
79100
console.info(`No valid tables to process for connection with id "${connection.id}"`);
101+
message('No valid tables to process');
80102
return;
81103
}
82104

83105
console.info(`Processing ${tablesInformation.length} tables with AI for connection "${connection.id}"`);
106+
message(
107+
`Generating settings with AI for ${tablesInformation.length} ${tablesInformation.length === 1 ? 'table' : 'tables'}`,
108+
);
84109
const generatedTableSettings = await this.aiService.generateNewTableSettingsWithAI(tablesInformation);
85110

86111
if (generatedTableSettings.length === 0) {
87112
console.info(`No table settings generated by AI for connection with id "${connection.id}"`);
113+
message('AI did not produce any table settings');
88114
return;
89115
}
90116

91117
console.info(`AI generated settings for ${generatedTableSettings.length} tables`);
118+
message(
119+
`AI returned settings for ${generatedTableSettings.length} ${generatedTableSettings.length === 1 ? 'table' : 'tables'}`,
120+
);
92121

93122
const widgetsByTable = new Map<string, Array<TableWidgetEntity>>();
94123
for (const setting of generatedTableSettings) {
@@ -106,6 +135,7 @@ export class SharedJobsService {
106135
const validateSettingsDS = buildValidateTableSettingsDS(setting);
107136
const errors = await dao.validateSettings(validateSettingsDS, setting.table_name, undefined);
108137
if (errors.length > 0) {
138+
message(`Validation failed for table "${setting.table_name}", skipping`);
109139
console.error(`Validation errors for table "${setting.table_name}":`, errors);
110140
return null;
111141
}
@@ -119,6 +149,7 @@ export class SharedJobsService {
119149
const savedSettings = await this._dbContext.tableSettingsRepository.save(settingsToSave);
120150
const widgetsToSave: Array<TableWidgetEntity> = [];
121151
for (const savedSetting of savedSettings) {
152+
emitSettingsMessages(savedSetting, message);
122153
const widgets = widgetsByTable.get(savedSetting.table_name);
123154
if (widgets && widgets.length > 0) {
124155
for (const widget of widgets) {
@@ -131,17 +162,22 @@ export class SharedJobsService {
131162
widgetEntity.description = widget.description || null;
132163
widgetEntity.settings = savedSetting;
133164
widgetsToSave.push(widgetEntity);
165+
message(
166+
`Added ${widget.widget_type} widget for table "${savedSetting.table_name}" on column "${widget.field_name}"`,
167+
);
134168
}
135169
}
136170
}
137171

138172
if (widgetsToSave.length > 0) {
139173
await this._dbContext.tableWidgetsRepository.save(widgetsToSave);
140174
}
175+
message(`Finished setup for ${savedSettings.length} ${savedSettings.length === 1 ? 'table' : 'tables'}`);
141176
}
142177
} catch (error) {
143178
console.error('Error during AI scan and creation of settings/widgets: ', error);
144179
Sentry.captureException(error);
180+
throw error;
145181
}
146182
}
147183

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { TableSettingsEntity } from '../../table-settings/common-table-settings/table-settings.entity.js';
2+
3+
export function emitSettingsMessages(setting: TableSettingsEntity, emit: (text: string) => void): void {
4+
const tableName = setting.table_name;
5+
const params: Array<[string, string]> = [];
6+
if (setting.display_name) {
7+
params.push(['display_name', `"${setting.display_name}"`]);
8+
}
9+
if (setting.search_fields && setting.search_fields.length > 0) {
10+
params.push(['search_fields', setting.search_fields.join(', ')]);
11+
}
12+
if (setting.readonly_fields && setting.readonly_fields.length > 0) {
13+
params.push(['readonly_fields', setting.readonly_fields.join(', ')]);
14+
}
15+
if (setting.columns_view && setting.columns_view.length > 0) {
16+
params.push(['columns_view', setting.columns_view.join(', ')]);
17+
}
18+
if (setting.ordering) {
19+
params.push(['ordering', String(setting.ordering)]);
20+
}
21+
if (setting.ordering_field) {
22+
params.push(['ordering_field', `"${setting.ordering_field}"`]);
23+
}
24+
if (setting.identity_column) {
25+
params.push(['identity_column', `"${setting.identity_column}"`]);
26+
}
27+
28+
if (params.length === 0) {
29+
emit(`Set up settings for table "${tableName}" with default parameters`);
30+
return;
31+
}
32+
for (const [name, value] of params) {
33+
emit(`Set up settings for table "${tableName}", ${name} parameter set to ${value}`);
34+
}
35+
}

0 commit comments

Comments
 (0)