From 57024c223e7ef682fdb6c2cc4a89767c4186f4b0 Mon Sep 17 00:00:00 2001
From: Paolo Cristiani <42511852+pgrisu@users.noreply.github.com>
Date: Tue, 13 May 2025 14:44:27 +0200
Subject: [PATCH 01/31] feat: add tooltip stacked total option in time series
chart settings
---
...e-series-chart-basic-config.component.html | 5 +++
...ime-series-chart-basic-config.component.ts | 10 ++++-
.../chart/time-series-chart-tooltip.models.ts | 45 ++++++++++++++++++-
.../lib/chart/time-series-chart.models.ts | 1 +
...eries-chart-widget-settings.component.html | 5 +++
...-series-chart-widget-settings.component.ts | 9 +++-
.../assets/locale/locale.constant-en_US.json | 1 +
7 files changed, 73 insertions(+), 3 deletions(-)
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html
index 61c9f89c7c..8d1b5eaad7 100644
--- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html
+++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html
@@ -311,6 +311,11 @@
+
+
+ {{ 'tooltip.show-stack-total' | translate }}
+
+
+
+
+ {{ 'tooltip.show-stack-total' | translate }}
+
+
+
+ {{ 'legend.show-total' | translate }}
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.ts
index 9890895938..1a75f95d2f 100644
--- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.ts
@@ -176,6 +176,7 @@ export abstract class LatestChartBasicConfigComponent !item.total);
}
legendLabelStyle: ComponentStyle;
@@ -92,6 +93,7 @@ export class LatestChartComponent implements OnInit, OnDestroy, AfterViewInit {
private shapeResize$: ResizeObserver;
private legendHorizontal: boolean;
+ private legendShowTotal: boolean;
private latestChart: TbLatestChart;
@@ -119,6 +121,7 @@ export class LatestChartComponent implements OnInit, OnDestroy, AfterViewInit {
this.legendValueStyle = textStyle(this.settings.legendValueFont);
this.disabledLegendValueStyle = textStyle(this.settings.legendValueFont);
this.legendValueStyle.color = this.settings.legendValueColor;
+ this.legendShowTotal = this.settings.legendShowTotal;
}
}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts
index 931d3c52ed..9184bc59e7 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts
@@ -103,6 +103,7 @@ export interface LatestChartWidgetSettings extends LatestChartSettings {
legendLabelColor: string;
legendValueFont: Font;
legendValueColor: string;
+ legendShowTotal: boolean;
background: BackgroundSettings;
padding: string;
}
@@ -129,6 +130,7 @@ export const latestChartWidgetDefaultSettings: LatestChartWidgetSettings = {
lineHeight: '20px'
},
legendValueColor: 'rgba(0, 0, 0, 0.87)',
+ legendShowTotal: true,
background: {
type: BackgroundType.color,
color: '#fff',
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html
index 04476e69e2..5246747c20 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html
@@ -61,6 +61,9 @@
+
+ {{ 'legend.show-total' | translate }}
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.ts
index 1d7baebd33..b67a1d85f5 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.ts
@@ -134,6 +134,7 @@ export abstract class LatestChartWidgetSettingsComponent
Date: Fri, 26 Sep 2025 11:37:15 +0300
Subject: [PATCH 03/31] UI: Add support UTF-8 symbols in export file name
---
.../import-export/import-export.service.ts | 95 +++++++------------
1 file changed, 36 insertions(+), 59 deletions(-)
diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts
index 94960cb838..d7c6d5880a 100644
--- a/ui-ngx/src/app/shared/import-export/import-export.service.ts
+++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts
@@ -22,7 +22,6 @@ import { AppState } from '@core/core.state';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { BreakpointId, Dashboard, DashboardLayoutId } from '@shared/models/dashboard.models';
import { deepClone, guid, isDefined, isNotEmptyStr, isObject, isString, isUndefined } from '@core/utils';
-import { WINDOW } from '@core/services/window.service';
import { DOCUMENT } from '@angular/common';
import {
AliasesInfo,
@@ -100,8 +99,7 @@ type SupportEntityResources = 'includeResourcesInExportWidgetTypes' | 'includeRe
@Injectable()
export class ImportExportService {
- constructor(@Inject(WINDOW) private window: Window,
- @Inject(DOCUMENT) private document: Document,
+ constructor(@Inject(DOCUMENT) private document: Document,
private store: Store,
private translate: TranslateService,
private dashboardService: DashboardService,
@@ -177,9 +175,7 @@ export class ImportExportService {
public exportCalculatedField(calculatedFieldId: string): void {
this.calculatedFieldsService.getCalculatedFieldById(calculatedFieldId).subscribe({
next: (calculatedField) => {
- let name = calculatedField.name;
- name = name.toLowerCase().replace(/\W/g, '_');
- this.exportToPc(this.prepareCalculatedFieldExport(calculatedField), name);
+ this.exportToPc(this.prepareCalculatedFieldExport(calculatedField), calculatedField.name, true);
},
error: (e) => {
this.handleExportError(e, 'calculated-fields.export-failed-error');
@@ -200,9 +196,7 @@ export class ImportExportService {
this.updateUserSettingsIncludeResourcesIfNeeded(includeResources, result.include, 'includeResourcesInExportDashboard');
this.dashboardService.exportDashboard(dashboardId, result.include).subscribe({
next: (dashboard) => {
- let name = dashboard.title;
- name = name.toLowerCase().replace(/\W/g, '_');
- this.exportToPc(this.prepareDashboardExport(dashboard), name);
+ this.exportToPc(this.prepareDashboardExport(dashboard), dashboard.title, true);
},
error: (e) => {
this.handleExportError(e, 'dashboard.export-failed-error');
@@ -261,9 +255,8 @@ export class ImportExportService {
widgetTitle: string, breakpoint: BreakpointId) {
const widgetItem = this.itembuffer.prepareWidgetItem(dashboard, sourceState, sourceLayout, widget, breakpoint);
const widgetDefaultName = this.widgetService.getWidgetInfoFromCache(widget.typeFullFqn).widgetName;
- let fileName = widgetDefaultName + (isNotEmptyStr(widgetTitle) ? `_${widgetTitle}` : '');
- fileName = fileName.toLowerCase().replace(/\W/g, '_');
- this.exportToPc(this.prepareExport(widgetItem), fileName);
+ const fileName = widgetDefaultName + (isNotEmptyStr(widgetTitle) ? `_${widgetTitle}` : '');
+ this.exportToPc(this.prepareExport(widgetItem), fileName, true);
}
public importWidget(dashboard: Dashboard, targetState: string,
@@ -360,9 +353,7 @@ export class ImportExportService {
this.updateUserSettingsIncludeResourcesIfNeeded(includeResources, result.include, 'includeResourcesInExportWidgetTypes');
this.widgetService.exportWidgetType(widgetTypeId, result.include).subscribe({
next: (widgetTypeDetails) => {
- let name = widgetTypeDetails.name;
- name = name.toLowerCase().replace(/\W/g, '_');
- this.exportToPc(this.prepareExport(widgetTypeDetails), name);
+ this.exportToPc(this.prepareExport(widgetTypeDetails), widgetTypeDetails.name, true);
},
error: (e) => {
this.handleExportError(e, 'widget-type.export-failed-error');
@@ -440,7 +431,7 @@ export class ImportExportService {
public exportEntity(entityData: VersionedEntity): void {
const id = (entityData as EntityInfoData).id ?? (entityData as RuleChainMetaData).ruleChainId;
let fileName = (entityData as EntityInfoData).name;
- let preparedData;
+ let preparedData: any;
switch (id.entityType) {
case EntityType.DEVICE_PROFILE:
case EntityType.ASSET_PROFILE:
@@ -511,9 +502,7 @@ export class ImportExportService {
for (const widgetTypeDetails of widgetTypesDetails) {
widgetsBundleItem.widgetTypes.push(this.prepareExport(widgetTypeDetails));
}
- let name = widgetsBundle.title;
- name = name.toLowerCase().replace(/\W/g, '_');
- this.exportToPc(widgetsBundleItem, name);
+ this.exportToPc(widgetsBundleItem, widgetsBundle.title, true);
},
error: (e) => {
this.handleExportError(e, 'widgets-bundle.export-failed-error');
@@ -528,9 +517,7 @@ export class ImportExportService {
widgetsBundle: this.prepareExport(widgetsBundle),
widgetTypeFqns
};
- let name = widgetsBundle.title;
- name = name.toLowerCase().replace(/\W/g, '_');
- this.exportToPc(widgetsBundleItem, name);
+ this.exportToPc(widgetsBundleItem, widgetsBundle.title, true);
},
error: (e) => {
this.handleExportError(e, 'widgets-bundle.export-failed-error');
@@ -662,11 +649,9 @@ export class ImportExportService {
private onRuleChainExported() {
return {
next: (ruleChainExport: RuleChainImport) => {
- let name = ruleChainExport.ruleChain.name;
- name = name.toLowerCase().replace(/\W/g, '_');
- this.exportToPc(ruleChainExport, name);
+ this.exportToPc(ruleChainExport, ruleChainExport.ruleChain.name, true);
},
- error: (e) => {
+ error: (e: any) => {
this.handleExportError(e, 'rulechain.export-failed-error');
}
};
@@ -747,9 +732,7 @@ export class ImportExportService {
public exportDeviceProfile(deviceProfileId: string) {
this.deviceProfileService.exportDeviceProfile(deviceProfileId).subscribe({
next: (deviceProfile) => {
- let name = deviceProfile.name;
- name = name.toLowerCase().replace(/\W/g, '_');
- this.exportToPc(this.prepareProfileExport(deviceProfile), name);
+ this.exportToPc(this.prepareProfileExport(deviceProfile), deviceProfile.name, true);
},
error: (e) => {
this.handleExportError(e, 'device-profile.export-failed-error');
@@ -776,9 +759,7 @@ export class ImportExportService {
public exportAssetProfile(assetProfileId: string) {
this.assetProfileService.exportAssetProfile(assetProfileId).subscribe({
next: (assetProfile) => {
- let name = assetProfile.name;
- name = name.toLowerCase().replace(/\W/g, '_');
- this.exportToPc(this.prepareProfileExport(assetProfile), name);
+ this.exportToPc(this.prepareProfileExport(assetProfile), assetProfile.name, true);
},
error: (e) => {
this.handleExportError(e, 'asset-profile.export-failed-error');
@@ -805,9 +786,7 @@ export class ImportExportService {
public exportTenantProfile(tenantProfileId: string) {
this.tenantProfileService.getTenantProfile(tenantProfileId).subscribe({
next: (tenantProfile) => {
- let name = tenantProfile.name;
- name = name.toLowerCase().replace(/\W/g, '_');
- this.exportToPc(this.prepareProfileExport(tenantProfile), name);
+ this.exportToPc(this.prepareProfileExport(tenantProfile), tenantProfile.name, true);
},
error: (e) => {
this.handleExportError(e, 'tenant-profile.export-failed-error');
@@ -882,7 +861,7 @@ export class ImportExportService {
jsZip.generateAsync({type: 'blob'}).then(content => {
this.downloadFile(content, filename, ZIP_TYPE);
exportJsSubjectSubject.next(null);
- }).catch(e => {
+ }).catch((e: any) => {
exportJsSubjectSubject.error(e);
});
} catch (e) {
@@ -1180,42 +1159,40 @@ export class ImportExportService {
));
}
- private exportToPc(data: any, filename: string) {
+ private exportToPc(data: any, filename: string, normalizeFileName = false) {
if (!data) {
console.error('No data');
return;
}
- this.exportJson(data, filename);
+ this.exportJson(data, filename, normalizeFileName);
}
- public exportJson(data: any, filename: string) {
+ public exportJson(data: any, filename: string, normalizeFileName = false) {
if (isObject(data)) {
data = JSON.stringify(data, null, 2);
}
- this.downloadFile(data, filename, JSON_TYPE);
+ this.downloadFile(data, filename, JSON_TYPE, normalizeFileName);
}
- private downloadFile(data: any, filename: string, fileType: FileType) {
- if (!filename) {
- filename = 'download';
+ private prepareFilename(filename: string, extension: string, normalizeFileName: boolean): string {
+ if (normalizeFileName) {
+ filename = filename.toLowerCase().replace(/\s/g, '_');
}
- filename += '.' + fileType.extension;
+ filename = filename.replace(/[\\/<>:"|?*\s]/g, '_');
+ return `${filename}.${extension}`;
+ }
+
+ private downloadFile(data: any, filename = 'download', fileType: FileType, normalizeFileName = false) {
+ filename = this.prepareFilename(filename, fileType.extension, normalizeFileName);
const blob = new Blob([data], {type: fileType.mimeType});
- // @ts-ignore
- if (this.window.navigator && this.window.navigator.msSaveOrOpenBlob) {
- // @ts-ignore
- this.window.navigator.msSaveOrOpenBlob(blob, filename);
- } else {
- const e = this.document.createEvent('MouseEvents');
- const a = this.document.createElement('a');
- a.download = filename;
- a.href = URL.createObjectURL(blob);
- a.dataset.downloadurl = [fileType.mimeType, a.download, a.href].join(':');
- // @ts-ignore
- e.initEvent('click', true, false, this.window,
- 0, 0, 0, 0, 0, false, false, false, false, 0, null);
- a.dispatchEvent(e);
- }
+ const url = URL.createObjectURL(blob);
+
+ const a = this.document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.dataset.downloadurl = [fileType.mimeType, a.download, a.href].join(':');
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 0);
}
private prepareDashboardExport(dashboard: Dashboard): Dashboard {
From cdcb2c4fd68b395ea899277c73e70afa347a9e09 Mon Sep 17 00:00:00 2001
From: Vladyslav_Prykhodko
Date: Fri, 26 Sep 2025 11:59:10 +0300
Subject: [PATCH 04/31] UI: updated public api in import-export service
---
.../import-export/import-export.service.ts | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts
index d7c6d5880a..34e584a527 100644
--- a/ui-ngx/src/app/shared/import-export/import-export.service.ts
+++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts
@@ -821,7 +821,7 @@ export class ImportExportService {
return cellData;
}
- public exportCsv(data: {[key: string]: any}[], filename: string) {
+ public exportCsv(data: {[key: string]: any}[], filename: string, normalizeFileName = false) {
let colsHead: string;
let colsData: string;
if (data && data.length) {
@@ -836,18 +836,18 @@ export class ImportExportService {
colsData = '';
}
const csvData = `${colsHead}\n${colsData}`;
- this.downloadFile(csvData, filename, CSV_TYPE);
+ this.downloadFile(csvData, filename, CSV_TYPE, normalizeFileName);
}
- public exportText(data: string | Array, filename: string) {
+ public exportText(data: string | Array, filename: string, normalizeFileName = false) {
let content = data;
if (Array.isArray(data)) {
content = data.join('\n');
}
- this.downloadFile(content, filename, TEXT_TYPE);
+ this.downloadFile(content, filename, TEXT_TYPE, normalizeFileName);
}
- public exportJSZip(data: object, filename: string): Observable {
+ public exportJSZip(data: object, filename: string, normalizeFileName = false): Observable {
const exportJsSubjectSubject = new Subject();
import('jszip').then((JSZip) => {
try {
@@ -859,7 +859,7 @@ export class ImportExportService {
}
}
jsZip.generateAsync({type: 'blob'}).then(content => {
- this.downloadFile(content, filename, ZIP_TYPE);
+ this.downloadFile(content, filename, ZIP_TYPE, normalizeFileName);
exportJsSubjectSubject.next(null);
}).catch((e: any) => {
exportJsSubjectSubject.error(e);
@@ -1174,7 +1174,7 @@ export class ImportExportService {
this.downloadFile(data, filename, JSON_TYPE, normalizeFileName);
}
- private prepareFilename(filename: string, extension: string, normalizeFileName: boolean): string {
+ private prepareFilename(filename: string, extension: string, normalizeFileName = false): string {
if (normalizeFileName) {
filename = filename.toLowerCase().replace(/\s/g, '_');
}
@@ -1182,7 +1182,7 @@ export class ImportExportService {
return `${filename}.${extension}`;
}
- private downloadFile(data: any, filename = 'download', fileType: FileType, normalizeFileName = false) {
+ private downloadFile(data: any, filename = 'download', fileType: FileType, normalizeFileName: boolean) {
filename = this.prepareFilename(filename, fileType.extension, normalizeFileName);
const blob = new Blob([data], {type: fileType.mimeType});
const url = URL.createObjectURL(blob);
From ec30bb0578c8186b3d83a848d72add37a22b6845 Mon Sep 17 00:00:00 2001
From: VIacheslavKlimov
Date: Fri, 26 Sep 2025 13:16:31 +0300
Subject: [PATCH 05/31] AI Request Node: added ability to attach files (#13910)
---
.../server/actors/ActorSystemContext.java | 5 +
.../actors/ruleChain/DefaultTbContext.java | 21 +-
.../controller/TbResourceController.java | 17 ++
...faultTbCalculatedFieldConsumerService.java | 4 +-
.../queue/DefaultTbClusterService.java | 9 +-
.../queue/DefaultTbCoreConsumerService.java | 4 +-
.../queue/DefaultTbEdgeConsumerService.java | 2 +-
.../DefaultTbRuleEngineConsumerService.java | 4 +-
.../processing/AbstractConsumerService.java | 5 +
...AbstractPartitionBasedConsumerService.java | 4 +-
.../resource/DefaultTbResourceService.java | 1 -
.../src/main/resources/thingsboard.yml | 3 +
.../controller/TbResourceControllerTest.java | 23 +-
.../DefaultResourceDataCacheTest.java | 83 +++++++
.../sql/BaseTbResourceServiceTest.java | 121 +++++++++-
.../server/dao/resource/ResourceService.java | 5 +
.../dao/resource/TbResourceDataCache.java | 28 +++
.../common/data/GeneralFileDescriptor.java | 29 +++
.../server/common/data/ResourceType.java | 3 +-
.../server/common/data/TbResource.java | 6 +
.../common/data/TbResourceDataInfo.java | 31 +++
.../common/data/TbResourceDeleteResult.java | 3 +-
.../thingsboard/common/util/DonAsynchron.java | 15 ++
.../server/dao/ResourceContainerDao.java | 5 +-
.../server/dao/resource/BaseImageService.java | 5 +-
.../dao/resource/BaseResourceService.java | 72 ++++--
.../resource/DefaultTbResourceDataCache.java | 72 ++++++
.../server/dao/resource/TbResourceDao.java | 2 +
.../dao/resource/TbResourceInfoDao.java | 2 +
.../server/dao/rule/RuleChainDao.java | 4 +-
.../dashboard/DashboardInfoRepository.java | 16 +-
.../sql/dashboard/JpaDashboardInfoDao.java | 10 +-
.../dao/sql/resource/JpaTbResourceDao.java | 6 +
.../sql/resource/JpaTbResourceInfoDao.java | 8 +
.../resource/TbResourceInfoRepository.java | 6 +
.../sql/resource/TbResourceRepository.java | 3 +
.../server/dao/sql/rule/JpaRuleChainDao.java | 12 +
.../dao/sql/rule/RuleChainRepository.java | 15 ++
.../dao/sql/widget/JpaWidgetTypeDao.java | 10 +-
.../sql/widget/WidgetTypeInfoRepository.java | 15 +-
.../dao/sql/rule/JpaRuleNodeDaoTest.java | 1 +
.../rule/engine/api/TbContext.java | 7 +
.../thingsboard/rule/engine/ai/TbAiNode.java | 121 +++++++++-
.../rule/engine/ai/TbAiNodeConfiguration.java | 6 +-
.../rule/engine/ai/TbAiNodeTest.java | 223 ++++++++++++++++++
45 files changed, 965 insertions(+), 82 deletions(-)
create mode 100644 application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java
create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java
create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java
create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java
create mode 100644 dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java
diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
index ea46ce86eb..b23015a1fe 100644
--- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
+++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
@@ -97,6 +97,7 @@ import org.thingsboard.server.dao.ota.OtaPackageService;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.relation.RelationService;
+import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.resource.ResourceService;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.rule.RuleNodeStateService;
@@ -511,6 +512,10 @@ public class ActorSystemContext {
@Getter
private ResourceService resourceService;
+ @Autowired
+ @Getter
+ private TbResourceDataCache resourceDataCache;
+
@Lazy
@Autowired(required = false)
@Getter
diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
index 6374e4016d..88b04c7613 100644
--- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
+++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
@@ -51,6 +51,7 @@ import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.HasRuleEngineProfile;
+import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.alarm.Alarm;
@@ -60,6 +61,7 @@ import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.id.TenantId;
@@ -110,6 +112,7 @@ import org.thingsboard.server.dao.ota.OtaPackageService;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.relation.RelationService;
+import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.resource.ResourceService;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.tenant.TenantService;
@@ -770,6 +773,11 @@ public class DefaultTbContext implements TbContext {
return mainCtx.getResourceService();
}
+ @Override
+ public TbResourceDataCache getTbResourceDataCache() {
+ return mainCtx.getResourceDataCache();
+ }
+
@Override
public OtaPackageService getOtaPackageService() {
return mainCtx.getOtaPackageService();
@@ -1054,7 +1062,18 @@ public class DefaultTbContext implements TbContext {
@Override
public void checkTenantEntity(EntityId entityId) throws TbNodeException {
- if (!this.getTenantId().equals(TenantIdLoader.findTenantId(this, entityId))) {
+ TenantId actualTenantId = TenantIdLoader.findTenantId(this, entityId);
+ assertSameTenantId(actualTenantId, entityId);
+ }
+
+ @Override
+ public & HasTenantId, I extends EntityId> void checkTenantEntity(E entity) throws TbNodeException {
+ TenantId actualTenantId = entity.getTenantId();
+ assertSameTenantId(actualTenantId, entity.getId());
+ }
+
+ private void assertSameTenantId(TenantId tenantId, EntityId entityId) throws TbNodeException {
+ if (!getTenantId().equals(tenantId)) {
throw new TbNodeException("Entity with id: '" + entityId + "' specified in the configuration doesn't belong to the current tenant.", true);
}
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java
index b23603f6a2..54d2494679 100644
--- a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java
@@ -55,14 +55,17 @@ import org.thingsboard.server.common.data.util.ThrowingSupplier;
import org.thingsboard.server.config.annotations.ApiOperation;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.resource.TbResourceService;
+import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.permission.Operation;
import org.thingsboard.server.service.security.permission.Resource;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
+import java.util.UUID;
import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER;
import static org.thingsboard.server.controller.ControllerConstants.LWM2M_OBJECT_DESCRIPTION;
@@ -263,6 +266,20 @@ public class TbResourceController extends BaseController {
}
}
+ @ApiOperation(value = "Get Resource Infos by ids (getSystemOrTenantResourcesByIds)")
+ @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
+ @GetMapping(value = "/resource", params = {"resourceIds"})
+ public List getSystemOrTenantResourcesByIds(
+ @Parameter(description = "A list of resource ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")))
+ @RequestParam("resourceIds") Set resourceUuids) throws ThingsboardException {
+ SecurityUser user = getCurrentUser();
+ List resourceIds = new ArrayList<>();
+ for (UUID resourceId : resourceUuids) {
+ resourceIds.add(new TbResourceId(resourceId));
+ }
+ return resourceService.findSystemOrTenantResourcesByIds(user.getTenantId(), resourceIds);
+ }
+
@ApiOperation(value = "Get All Resource Infos (getAllResources)",
notes = "Returns a page of Resource Info objects owned by tenant. " +
PAGE_DATA_PARAMETERS + RESOURCE_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH)
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
index 8d4ab25578..acb36449e8 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
@@ -35,6 +35,7 @@ import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.common.msg.queue.ServiceType;
import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
+import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
@@ -83,6 +84,7 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa
ActorSystemContext actorContext,
TbDeviceProfileCache deviceProfileCache,
TbAssetProfileCache assetProfileCache,
+ TbResourceDataCache tbResourceDataCache,
TbTenantProfileCache tenantProfileCache,
TbApiUsageStateService apiUsageStateService,
PartitionService partitionService,
@@ -90,7 +92,7 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa
JwtSettingsService jwtSettingsService,
CalculatedFieldCache calculatedFieldCache,
CalculatedFieldStateService stateService) {
- super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, calculatedFieldCache, apiUsageStateService, partitionService,
+ super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService,
eventPublisher, jwtSettingsService);
this.queueFactory = tbQueueFactory;
this.stateService = stateService;
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java
index 265f14c4e2..86d00c77ca 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java
@@ -48,6 +48,7 @@ import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.RuleChainId;
+import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.page.PageData;
@@ -435,8 +436,9 @@ public class DefaultTbClusterService implements TbClusterService {
@Override
public void onResourceChange(TbResourceInfo resource, TbQueueCallback callback) {
+ TenantId tenantId = resource.getTenantId();
+ TbResourceId resourceId = resource.getId();
if (resource.getResourceType() == ResourceType.LWM2M_MODEL) {
- TenantId tenantId = resource.getTenantId();
log.trace("[{}][{}][{}] Processing change resource", tenantId, resource.getResourceType(), resource.getResourceKey());
ResourceUpdateMsg resourceUpdateMsg = ResourceUpdateMsg.newBuilder()
.setTenantIdMSB(tenantId.getId().getMostSignificantBits())
@@ -447,6 +449,7 @@ public class DefaultTbClusterService implements TbClusterService {
ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceUpdateMsg(resourceUpdateMsg).build();
broadcast(transportMsg, DataConstants.LWM2M_TRANSPORT_NAME, callback);
}
+ broadcastEntityStateChangeEvent(tenantId, resourceId, ComponentLifecycleEvent.UPDATED);
}
@Override
@@ -462,6 +465,7 @@ public class DefaultTbClusterService implements TbClusterService {
ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceDeleteMsg(resourceDeleteMsg).build();
broadcast(transportMsg, DataConstants.LWM2M_TRANSPORT_NAME, callback);
}
+ broadcastEntityStateChangeEvent(resource.getTenantId(), resource.getId(), ComponentLifecycleEvent.DELETED);
}
private void broadcastEntityChangeToTransport(TenantId tenantId, EntityId entityid, T entity, TbQueueCallback callback) {
@@ -592,7 +596,8 @@ public class DefaultTbClusterService implements TbClusterService {
EntityType.TENANT_PROFILE,
EntityType.DEVICE_PROFILE,
EntityType.ASSET_PROFILE,
- EntityType.JOB)
+ EntityType.JOB,
+ EntityType.TB_RESOURCE)
|| (entityType == EntityType.ASSET && msg.getEvent() == ComponentLifecycleEvent.UPDATED)
|| (entityType == EntityType.DEVICE && msg.getEvent() == ComponentLifecycleEvent.UPDATED)
) {
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
index f0b1a4d7d2..9ab5a062eb 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
@@ -55,6 +55,7 @@ import org.thingsboard.server.common.stats.StatsFactory;
import org.thingsboard.server.common.util.KvProtoUtil;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.dao.resource.ImageCacheKey;
+import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.gen.transport.TransportProtos.DeviceStateServiceMsgProto;
@@ -176,10 +177,11 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService>>() {
+ var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() {
});
Assert.assertNotNull(widgetTypeInfos);
Assert.assertFalse(widgetTypeInfos.isEmpty());
Assert.assertEquals(1, widgetTypeInfos.size());
- var dashboardInfo = widgetTypeInfos.get(EntityType.WIDGET_TYPE.name()).get(0);
- Assert.assertNotNull(dashboardInfo);
-
- WidgetTypeInfo foundedWidgetType = doGet("/api/widgetTypeInfo/" + savedWidgetType.getId().getId().toString(), WidgetTypeInfo.class);
- Assert.assertNotNull(foundedWidgetType);
- Assert.assertEquals(foundedWidgetType, dashboardInfo);
+ var widgetTypeInfo = widgetTypeInfos.get(EntityType.WIDGET_TYPE.name()).get(0);
+ Assert.assertNotNull(widgetTypeInfo);
+ Assert.assertEquals(new EntityInfo(savedWidgetType.getId(), savedWidgetType.getName()), widgetTypeInfo);
}
@Test
@@ -372,7 +370,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
Assert.assertTrue(isSuccess);
var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references");
- var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() {
+ var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() {
});
Assert.assertNull(widgetTypeInfos);
}
@@ -417,7 +415,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references");
Assert.assertNotNull(referenceValues);
- var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() {
+ var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() {
});
Assert.assertNotNull(dashboardInfos);
Assert.assertFalse(dashboardInfos.isEmpty());
@@ -425,10 +423,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
var dashboardInfo = dashboardInfos.get(EntityType.DASHBOARD.name()).get(0);
Assert.assertNotNull(dashboardInfo);
-
- DashboardInfo foundDashboard = doGet("/api/dashboard/info/" + savedDashboard.getId().getId().toString(), DashboardInfo.class);
- Assert.assertNotNull(foundDashboard);
- Assert.assertEquals(foundDashboard, dashboardInfo);
+ Assert.assertEquals(new EntityInfo(savedDashboard.getId(), savedDashboard.getName()), dashboardInfo);
}
@Test
@@ -469,7 +464,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
Assert.assertTrue(isSuccess);
var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references");
- var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() {
+ var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() {
});
Assert.assertNull(dashboardInfos);
}
diff --git a/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java
new file mode 100644
index 0000000000..f12a8d3c5d
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java
@@ -0,0 +1,83 @@
+/**
+ * Copyright © 2016-2025 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.service.resource;
+
+import org.junit.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.common.data.GeneralFileDescriptor;
+import org.thingsboard.server.common.data.ResourceType;
+import org.thingsboard.server.common.data.TbResource;
+import org.thingsboard.server.common.data.TbResourceDataInfo;
+import org.thingsboard.server.common.data.TbResourceInfo;
+import org.thingsboard.server.controller.AbstractControllerTest;
+import org.thingsboard.server.dao.resource.ResourceService;
+import org.thingsboard.server.dao.resource.TbResourceDataCache;
+import org.thingsboard.server.dao.service.DaoSqlTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+@DaoSqlTest
+public class DefaultResourceDataCacheTest extends AbstractControllerTest {
+
+ @MockitoSpyBean
+ private ResourceService resourceService;
+ @Autowired
+ private TbResourceService tbResourceService;
+ @MockitoSpyBean
+ private TbResourceDataCache resourceDataCache;
+
+ @Test
+ public void testGetCachedResourceData() throws Exception {
+ loginTenantAdmin();
+
+ TbResource resource = new TbResource();
+ resource.setTenantId(tenantId);
+ resource.setTitle("File for AI request");
+ resource.setResourceType(ResourceType.GENERAL);
+ resource.setFileName("myTestJson.json");
+ GeneralFileDescriptor descriptor = new GeneralFileDescriptor("application/json");
+ resource.setDescriptorValue(descriptor);
+ byte[] data = "This is a test prompt for AI request.".getBytes();
+ resource.setData(data);
+ TbResourceInfo savedResource = tbResourceService.save(resource);
+ verify(resourceDataCache, timeout(2000).times(1)).evictResourceData(tenantId, savedResource.getId());
+
+ TbResourceDataInfo cachedData = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get();
+ assertThat(cachedData.getData()).isEqualTo(data);
+ assertThat(JacksonUtil.treeToValue(cachedData.getDescriptor(), GeneralFileDescriptor.class)).isEqualTo(descriptor);
+ verify(resourceService).getResourceDataInfo(tenantId, savedResource.getId());
+
+ // retrieve resource data second time
+ clearInvocations(resourceService);
+ TbResourceDataInfo cachedData2 = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get();
+ assertThat(cachedData2.getData()).isEqualTo(data);
+ verifyNoMoreInteractions(resourceService);
+
+ // delete resource, check cache
+ TbResource resourceById = resourceService.findResourceById(tenantId, savedResource.getId());
+ tbResourceService.delete(resourceById, true, null);
+ verify(resourceDataCache, timeout(2000).times(2)).evictResourceData(tenantId, savedResource.getId());
+ TbResourceDataInfo cachedDataAfterDeletion = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get();
+ assertThat(cachedDataAfterDeletion).isEqualTo(null);
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java b/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java
index fe416eacd5..da20b9489c 100644
--- a/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java
@@ -17,6 +17,7 @@ package org.thingsboard.server.service.resource.sql;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.TextNode;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
@@ -24,8 +25,10 @@ import org.junit.Test;
import org.junit.jupiter.api.Assertions;
import org.springframework.beans.factory.annotation.Autowired;
import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.rule.engine.ai.TbAiNode;
+import org.thingsboard.rule.engine.ai.TbAiNodeConfiguration;
+import org.thingsboard.rule.engine.ai.TbResponseFormat;
import org.thingsboard.server.common.data.Dashboard;
-import org.thingsboard.server.common.data.DashboardInfo;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.EntityType;
@@ -37,26 +40,40 @@ import org.thingsboard.server.common.data.TbResourceInfoFilter;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.User;
+import org.thingsboard.server.common.data.ai.AiModel;
+import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
+import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
+import org.thingsboard.server.common.data.debug.DebugSettings;
import org.thingsboard.server.common.data.exception.ThingsboardException;
+import org.thingsboard.server.common.data.id.RuleChainId;
+import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
+import org.thingsboard.server.common.data.rule.RuleChain;
+import org.thingsboard.server.common.data.rule.RuleChainMetaData;
+import org.thingsboard.server.common.data.rule.RuleChainType;
+import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
-import org.thingsboard.server.common.data.widget.WidgetTypeInfo;
import org.thingsboard.server.controller.AbstractControllerTest;
+import org.thingsboard.server.dao.ai.AiModelService;
import org.thingsboard.server.dao.dashboard.DashboardService;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.resource.ResourceService;
+import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.service.resource.TbResourceService;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
+import java.util.Set;
+import java.util.function.Function;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -135,6 +152,10 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest {
private WidgetTypeService widgetTypeService;
@Autowired
private DashboardService dashboardService;
+ @Autowired
+ private RuleChainService ruleChainService;
+ @Autowired
+ private AiModelService aiModelService;
private Tenant savedTenant;
private User tenantAdmin;
@@ -453,11 +474,9 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest {
Assert.assertFalse(result.getReferences().isEmpty());
Assert.assertEquals(1, result.getReferences().size());
- WidgetTypeInfo widgetTypeInfo = (WidgetTypeInfo) result.getReferences().get(EntityType.WIDGET_TYPE.name()).get(0);
- WidgetTypeInfo foundWidgetTypeInfo = new WidgetTypeInfo(foundWidgetType);
+ EntityInfo widgetTypeInfo = (EntityInfo) result.getReferences().get(EntityType.WIDGET_TYPE.name()).get(0);
Assert.assertNotNull(widgetTypeInfo);
- Assert.assertNotNull(foundWidgetTypeInfo);
- Assert.assertEquals(widgetTypeInfo, foundWidgetTypeInfo);
+ Assert.assertEquals(widgetTypeInfo, new EntityInfo(foundWidgetType.getId(), foundWidgetType.getName()));
TbResourceInfo foundResourceInfo = resourceService.findResourceInfoById(savedTenant.getId(), savedResource.getId());
Assert.assertNotNull(foundResource);
@@ -546,11 +565,9 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest {
Assert.assertNotNull(result.getReferences());
Assert.assertEquals(1, result.getReferences().size());
- DashboardInfo dashboardInfo = (DashboardInfo) result.getReferences().get(EntityType.DASHBOARD.name()).get(0);
- DashboardInfo foundDashboardInfo = dashboardService.findDashboardInfoById(savedTenant.getId(), savedDashboard.getId());
+ EntityInfo dashboardInfo = (EntityInfo) result.getReferences().get(EntityType.DASHBOARD.name()).get(0);
Assert.assertNotNull(dashboardInfo);
- Assert.assertNotNull(foundDashboardInfo);
- Assert.assertEquals(foundDashboardInfo, dashboardInfo);
+ Assert.assertEquals(new EntityInfo(savedDashboard.getId(), savedDashboard.getName()), dashboardInfo);
foundResource = resourceService.findResourceById(savedTenant.getId(), savedResource.getId());
Assert.assertNotNull(foundResource);
@@ -598,6 +615,90 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest {
Assert.assertNull(foundResource);
}
+ @Test
+ public void testShouldNotDeleteResourceIfUsedInAiNode() throws Exception {
+ TbResource resource = new TbResource();
+ resource.setResourceType(ResourceType.GENERAL);
+ resource.setTitle("My resource");
+ resource.setFileName("test.json");
+ resource.setTenantId(savedTenant.getId());
+ resource.setData("".getBytes());
+ TbResourceInfo savedResource = tbResourceService.save(resource);
+ RuleChainMetaData ruleChain = createRuleChainReferringResource(savedResource.getId());
+
+ TbResourceDeleteResult result = tbResourceService.delete(savedResource, false, null);
+ assertThat(result).isNotNull();
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getReferences()).isNotEmpty().hasSize(1);
+ EntityInfo entityInfo = (EntityInfo) result.getReferences().get(EntityType.RULE_CHAIN.name()).get(0);
+ assertThat(entityInfo).isEqualTo(new EntityInfo(ruleChain.getRuleChainId(), "Test"));
+
+ TbResource foundResource = resourceService.findResourceById(savedTenant.getId(), savedResource.getId());
+ assertThat(foundResource).isNotNull();
+
+ // force delete
+ TbResourceDeleteResult deleteResult = tbResourceService.delete(savedResource, true, null);
+ assertThat(deleteResult).isNotNull();
+ assertThat(deleteResult.isSuccess()).isTrue();
+
+ TbResource resourceAfterDeletion = resourceService.findResourceById(savedTenant.getId(), savedResource.getId());
+ assertThat(resourceAfterDeletion).isNull();
+ }
+
+ private RuleChainMetaData createRuleChainReferringResource(TbResourceId resourceId) {
+ AiModel model = constructValidOpenAiModel("Test model");
+ AiModel saved = aiModelService.save(model);
+
+ RuleChain ruleChain = new RuleChain();
+ ruleChain.setTenantId(tenantId);
+ ruleChain.setName("Test");
+ ruleChain.setType(RuleChainType.CORE);
+ ruleChain.setDebugMode(true);
+ ruleChain.setConfiguration(JacksonUtil.newObjectNode().set("a", new TextNode("b")));
+ ruleChain = ruleChainService.saveRuleChain(ruleChain);
+ RuleChainId ruleChainId = ruleChain.getId();
+
+ RuleChainMetaData metaData = new RuleChainMetaData();
+ metaData.setRuleChainId(ruleChainId);
+
+ RuleNode aiNode = new RuleNode();
+ aiNode.setName("Ai request");
+ aiNode.setType(org.thingsboard.rule.engine.ai.TbAiNode.class.getName());
+ aiNode.setConfigurationVersion(TbAiNode.class.getAnnotation(org.thingsboard.rule.engine.api.RuleNode.class).version());
+ aiNode.setDebugSettings(DebugSettings.all());
+ TbAiNodeConfiguration configuration = new TbAiNodeConfiguration();
+ configuration.setResourceIds(Set.of(resourceId.getId()));
+ configuration.setModelId(saved.getId());
+ configuration.setResponseFormat(new TbResponseFormat.TbJsonResponseFormat());
+ configuration.setTimeoutSeconds(1);
+ configuration.setUserPrompt("What is temp");
+ aiNode.setConfiguration(JacksonUtil.valueToTree(configuration));
+
+ metaData.setNodes(Arrays.asList(aiNode));
+ metaData.setFirstNodeIndex(0);
+ ruleChainService.saveRuleChainMetaData(tenantId, metaData, Function.identity());
+ return ruleChainService.loadRuleChainMetaData(tenantId, ruleChainId);
+ }
+
+ private AiModel constructValidOpenAiModel(String name) {
+ var modelConfig = OpenAiChatModelConfig.builder()
+ .providerConfig(new OpenAiProviderConfig("test-api-key"))
+ .modelId("gpt-4o")
+ .temperature(0.5)
+ .topP(0.3)
+ .frequencyPenalty(0.1)
+ .presencePenalty(0.2)
+ .maxOutputTokens(1000)
+ .timeoutSeconds(60)
+ .maxRetries(2)
+ .build();
+
+ return AiModel.builder()
+ .tenantId(tenantId)
+ .name(name)
+ .configuration(modelConfig)
+ .build();
+ }
@Test
public void testFindTenantResourcesByTenantId() throws Exception {
loginSysAdmin();
diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java
index ee187db46b..65211ec17a 100644
--- a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java
+++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java
@@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.ResourceExportData;
import org.thingsboard.server.common.data.ResourceSubType;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
+import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.common.data.TbResourceDeleteResult;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.TbResourceInfoFilter;
@@ -46,6 +47,8 @@ public interface ResourceService extends EntityDaoService {
byte[] getResourceData(TenantId tenantId, TbResourceId resourceId);
+ TbResourceDataInfo getResourceDataInfo(TenantId tenantId, TbResourceId resourceId);
+
ResourceExportData exportResource(TbResourceInfo resourceInfo);
List exportResources(TenantId tenantId, Collection resources);
@@ -90,4 +93,6 @@ public interface ResourceService extends EntityDaoService {
TbResource createOrUpdateSystemResource(ResourceType resourceType, ResourceSubType resourceSubType, String resourceKey, byte[] data);
+ List findSystemOrTenantResourcesByIds(TenantId tenantId, List resourceIds);
+
}
diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java
new file mode 100644
index 0000000000..23485684af
--- /dev/null
+++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright © 2016-2025 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.dao.resource;
+
+import com.google.common.util.concurrent.FluentFuture;
+import org.thingsboard.server.common.data.TbResourceDataInfo;
+import org.thingsboard.server.common.data.id.TbResourceId;
+import org.thingsboard.server.common.data.id.TenantId;
+
+public interface TbResourceDataCache {
+
+ FluentFuture getResourceDataInfoAsync(TenantId tenantId, TbResourceId resourceId);
+
+ void evictResourceData(TenantId tenantId, TbResourceId resourceId);
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java
new file mode 100644
index 0000000000..94edd4fa01
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java
@@ -0,0 +1,29 @@
+/**
+ * Copyright © 2016-2025 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.common.data;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+@Data
+@EqualsAndHashCode
+@AllArgsConstructor
+@NoArgsConstructor
+public class GeneralFileDescriptor {
+ private String mediaType;
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java
index 77b17198e9..f7579b6878 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java
@@ -25,7 +25,8 @@ public enum ResourceType {
PKCS_12("application/x-pkcs12", false, false),
JS_MODULE("application/javascript", true, true),
IMAGE(null, true, true),
- DASHBOARD("application/json", true, true);
+ DASHBOARD("application/json", true, true),
+ GENERAL(null, false, true);
@Getter
private final String mediaType;
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java
index ba37067106..457d30e263 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java
@@ -16,6 +16,7 @@
package org.thingsboard.server.common.data;
import com.fasterxml.jackson.annotation.JsonGetter;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonSetter;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -86,6 +87,11 @@ public class TbResource extends TbResourceInfo {
.orElse(null);
}
+ @JsonIgnore
+ public TbResourceDataInfo toResourceDataInfo() {
+ return new TbResourceDataInfo(data, getDescriptor());
+ }
+
@Override
public String toString() {
return super.toString();
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java
new file mode 100644
index 0000000000..039478470d
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java
@@ -0,0 +1,31 @@
+/**
+ * Copyright © 2016-2025 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.common.data;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class TbResourceDataInfo {
+
+ private byte[] data;
+ private JsonNode descriptor;
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java
index edc5a2f539..76945a97ed 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java
@@ -17,7 +17,6 @@ package org.thingsboard.server.common.data;
import lombok.Builder;
import lombok.Data;
-import org.thingsboard.server.common.data.id.HasId;
import java.util.List;
import java.util.Map;
@@ -27,6 +26,6 @@ import java.util.Map;
public class TbResourceDeleteResult {
private boolean success;
- private Map>> references;
+ private Map> references;
}
diff --git a/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java b/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java
index 0f1a56cb17..79b8181548 100644
--- a/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java
+++ b/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java
@@ -15,12 +15,15 @@
*/
package org.thingsboard.common.util;
+import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.SettableFuture;
import java.util.concurrent.Callable;
+import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.function.Consumer;
@@ -71,4 +74,16 @@ public class DonAsynchron {
return future;
}
+ public static FluentFuture toFluentFuture(CompletableFuture completable) {
+ SettableFuture future = SettableFuture.create();
+ completable.whenComplete((result, exception) -> {
+ if (exception != null) {
+ future.setException(exception);
+ } else {
+ future.set(result);
+ }
+ });
+ return FluentFuture.from(future);
+ }
+
}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java b/dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java
index 6a952fd501..93cc64b2db 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java
@@ -15,6 +15,7 @@
*/
package org.thingsboard.server.dao;
+import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.id.TenantId;
@@ -22,8 +23,8 @@ import java.util.List;
public interface ResourceContainerDao> {
- List findByTenantIdAndResourceLink(TenantId tenantId, String link, int limit);
+ List findByTenantIdAndResource(TenantId tenantId, String reference, int limit);
- List findByResourceLink(String link, int limit);
+ List findByResource(String reference, int limit);
}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java
index 3be7f5b91c..c16e37d30f 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java
@@ -50,6 +50,7 @@ import org.thingsboard.server.dao.ImageContainerDao;
import org.thingsboard.server.dao.asset.AssetProfileDao;
import org.thingsboard.server.dao.dashboard.DashboardInfoDao;
import org.thingsboard.server.dao.device.DeviceProfileDao;
+import org.thingsboard.server.dao.rule.RuleChainDao;
import org.thingsboard.server.dao.service.Validator;
import org.thingsboard.server.dao.service.validator.ResourceDataValidator;
import org.thingsboard.server.dao.util.ImageUtils;
@@ -109,8 +110,8 @@ public class BaseImageService extends BaseResourceService implements ImageServic
public BaseImageService(TbResourceDao resourceDao, TbResourceInfoDao resourceInfoDao, ResourceDataValidator resourceValidator,
AssetProfileDao assetProfileDao, DeviceProfileDao deviceProfileDao, WidgetsBundleDao widgetsBundleDao,
- WidgetTypeDao widgetTypeDao, DashboardInfoDao dashboardInfoDao) {
- super(resourceDao, resourceInfoDao, resourceValidator, widgetTypeDao, dashboardInfoDao);
+ WidgetTypeDao widgetTypeDao, DashboardInfoDao dashboardInfoDao, RuleChainDao ruleChainDao) {
+ super(resourceDao, resourceInfoDao, resourceValidator, widgetTypeDao, dashboardInfoDao, ruleChainDao);
this.assetProfileDao = assetProfileDao;
this.deviceProfileDao = deviceProfileDao;
this.widgetsBundleDao = widgetsBundleDao;
diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java
index bf941256f5..7f0dd4a6bf 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java
@@ -35,11 +35,13 @@ import org.thingsboard.server.cache.resourceInfo.ResourceInfoCacheKey;
import org.thingsboard.server.cache.resourceInfo.ResourceInfoEvictEvent;
import org.thingsboard.server.common.data.Dashboard;
import org.thingsboard.server.common.data.DataConstants;
+import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ResourceExportData;
import org.thingsboard.server.common.data.ResourceSubType;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
+import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.common.data.TbResourceDeleteResult;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.TbResourceInfoFilter;
@@ -56,6 +58,7 @@ import org.thingsboard.server.dao.entity.AbstractCachedEntityService;
import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent;
import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent;
import org.thingsboard.server.dao.exception.DataValidationException;
+import org.thingsboard.server.dao.rule.RuleChainDao;
import org.thingsboard.server.dao.service.PaginatedRemover;
import org.thingsboard.server.dao.service.Validator;
import org.thingsboard.server.dao.service.validator.ResourceDataValidator;
@@ -92,13 +95,16 @@ public class BaseResourceService extends AbstractCachedEntityService> resourceContainerDaoMap = new HashMap<>();
+ protected final RuleChainDao ruleChainDao;
+ private final Map> resourceLinkContainerDaoMap = new HashMap<>();
+ private final Map> generalResourceContainerDaoMap = new HashMap<>();
protected static final int MAX_ENTITIES_TO_FIND = 10;
@PostConstruct
public void init() {
- resourceContainerDaoMap.put(EntityType.WIDGET_TYPE, widgetTypeDao);
- resourceContainerDaoMap.put(EntityType.DASHBOARD, dashboardInfoDao);
+ resourceLinkContainerDaoMap.put(EntityType.WIDGET_TYPE, widgetTypeDao);
+ resourceLinkContainerDaoMap.put(EntityType.DASHBOARD, dashboardInfoDao);
+ generalResourceContainerDaoMap.put(EntityType.RULE_CHAIN, ruleChainDao);
}
@Autowired @Lazy
@@ -206,6 +212,12 @@ public class BaseResourceService extends AbstractCachedEntityService>> affectedEntities = new HashMap<>();
-
- resourceContainerDaoMap.forEach((entityType, resourceContainerDao) -> {
- var entities = tenantId.isSysTenantId() ? resourceContainerDao.findByResourceLink(link, MAX_ENTITIES_TO_FIND) :
- resourceContainerDao.findByTenantIdAndResourceLink(tenantId, link, MAX_ENTITIES_TO_FIND);
- if (!entities.isEmpty()) {
- affectedEntities.put(entityType.name(), entities);
- }
- });
-
- if (!affectedEntities.isEmpty()) {
- success = false;
- result.references(affectedEntities);
- }
+ Map> references = findResourceReferences(tenantId, resource);
+ if (!references.isEmpty()) {
+ success = false;
+ result.references(references);
}
}
if (success) {
resourceDao.removeById(tenantId, resourceId.getId());
+ publishEvictEvent(new ResourceInfoEvictEvent(tenantId, resourceId));
eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entity(resource).entityId(resourceId).build());
}
return result.success(success).build();
}
+ private Map> findResourceReferences(TenantId tenantId, TbResourceInfo resource) {
+ Map> references = new HashMap<>();
+
+ if (resource.getResourceType() == ResourceType.JS_MODULE) {
+ var ref = resource.getLink();
+ findReferences(tenantId, references, ref, resourceLinkContainerDaoMap);
+ }
+
+ if (resource.getResourceType() == ResourceType.GENERAL) {
+ var ref = resource.getId().getId().toString();
+ findReferences(tenantId, references, ref, generalResourceContainerDaoMap);
+ }
+
+ return references;
+ }
+
+ private void findReferences(TenantId tenantId, Map> references, String ref, Map> resourceLinkContainerDaoMap) {
+ resourceLinkContainerDaoMap.forEach((entityType, dao) -> {
+ List entities = tenantId.isSysTenantId()
+ ? dao.findByResource(ref, MAX_ENTITIES_TO_FIND)
+ : dao.findByTenantIdAndResource(tenantId, ref, MAX_ENTITIES_TO_FIND);
+ if (!entities.isEmpty()) {
+ references.put(entityType.name(), entities);
+ }
+ });
+ }
+
@Override
public void deleteEntity(TenantId tenantId, EntityId id, boolean force) {
deleteResource(tenantId, (TbResourceId) id, force);
@@ -663,6 +691,12 @@ public class BaseResourceService extends AbstractCachedEntityService findSystemOrTenantResourcesByIds(TenantId tenantId, List resourceIds) {
+ log.trace("Executing findSystemOrTenantResourcesByIds, tenantId [{}], resourceIds [{}]", tenantId, resourceIds);
+ return resourceInfoDao.findSystemOrTenantResourcesByIds(tenantId, resourceIds);
+ }
+
@Override
public String calculateEtag(byte[] data) {
return Hashing.sha256().hashBytes(data).toString();
diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java b/dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java
new file mode 100644
index 0000000000..452f86b1a6
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java
@@ -0,0 +1,72 @@
+/**
+ * Copyright © 2016-2025 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.dao.resource;
+
+import com.github.benmanes.caffeine.cache.AsyncLoadingCache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.google.common.util.concurrent.FluentFuture;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.thingsboard.common.util.DonAsynchron;
+import org.thingsboard.server.common.data.TbResourceDataInfo;
+import org.thingsboard.server.common.data.id.TbResourceId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.dao.sql.JpaExecutorService;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class DefaultTbResourceDataCache implements TbResourceDataCache {
+
+ private final ResourceService resourceService;
+ private final JpaExecutorService executorService;
+
+ @Value("${cache.tbResourceData.maxSize:100000}")
+ private int cacheMaxSize;
+ @Value("${cache.tbResourceData.timeToLiveInMinutes:44640}")
+ private int cacheValueTtl;
+ private AsyncLoadingCache cache;
+
+ @PostConstruct
+ private void init() {
+ cache = Caffeine.newBuilder()
+ .maximumSize(cacheMaxSize)
+ .expireAfterAccess(cacheValueTtl, TimeUnit.MINUTES)
+ .executor(executorService)
+ .buildAsync((key, executor) -> CompletableFuture.supplyAsync(() -> resourceService.getResourceDataInfo(key.tenantId(), key.resourceId()), executor));
+ }
+
+ @Override
+ public FluentFuture getResourceDataInfoAsync(TenantId tenantId, TbResourceId resourceId) {
+ log.trace("Retrieving resource data info by id [{}], tenant id [{}] from cache", resourceId, tenantId);
+ return DonAsynchron.toFluentFuture(cache.get(new ResourceDataKey(tenantId, resourceId)));
+ }
+
+ @Override
+ public void evictResourceData(TenantId tenantId, TbResourceId resourceId) {
+ cache.asMap().remove(new ResourceDataKey(tenantId, resourceId));
+ log.trace("Evicted resource data info with id [{}], tenant id [{}]", resourceId, tenantId);
+ }
+
+ record ResourceDataKey (TenantId tenantId, TbResourceId resourceId) {}
+
+}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java
index 23b59b5658..1b9f250521 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java
@@ -18,6 +18,7 @@ package org.thingsboard.server.dao.resource;
import org.thingsboard.server.common.data.ResourceSubType;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
+import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
@@ -51,4 +52,5 @@ public interface TbResourceDao extends Dao, TenantEntityWithDataDao,
long getResourceSize(TenantId tenantId, TbResourceId resourceId);
+ TbResourceDataInfo getResourceDataInfo(TenantId tenantId, TbResourceId resourceId);
}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java
index 8e97738501..f4fe02843d 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java
@@ -18,6 +18,7 @@ package org.thingsboard.server.dao.resource;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.TbResourceInfoFilter;
+import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
@@ -46,4 +47,5 @@ public interface TbResourceInfoDao extends Dao {
TbResourceInfo findPublicResourceByKey(ResourceType resourceType, String publicResourceKey);
+ List findSystemOrTenantResourcesByIds(TenantId tenantId, List resourceIds);
}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java b/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java
index 5b09eec42a..ac716bb4fc 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java
@@ -21,8 +21,10 @@ import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainType;
+import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.dao.Dao;
import org.thingsboard.server.dao.ExportableEntityDao;
+import org.thingsboard.server.dao.ResourceContainerDao;
import org.thingsboard.server.dao.TenantEntityDao;
import java.util.Collection;
@@ -31,7 +33,7 @@ import java.util.UUID;
/**
* Created by igor on 3/12/18.
*/
-public interface RuleChainDao extends Dao, TenantEntityDao, ExportableEntityDao {
+public interface RuleChainDao extends Dao, TenantEntityDao, ExportableEntityDao, ResourceContainerDao {
/**
* Find rule chains by tenantId and page link.
diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java
index 7624ddc738..32ac596562 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java
@@ -20,6 +20,7 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
+import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.dao.model.sql.DashboardInfoEntity;
import java.util.List;
@@ -87,12 +88,15 @@ public interface DashboardInfoRepository extends JpaRepository findByImageLink(@Param("imageLink") String imageLink, @Param("limit") int limit);
- @Query(value = "SELECT * FROM dashboard d WHERE d.tenant_id = :tenantId and d.configuration ILIKE CONCAT('%', :link, '%') limit :limit",
- nativeQuery = true)
- List findDashboardInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, @Param("link") String link, @Param("limit") int limit);
+ @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(d.id, 'DASHBOARD', d.title) " +
+ "FROM DashboardEntity d WHERE d.tenantId = :tenantId AND ilike(cast(d.configuration as string), CONCAT('%', :link, '%')) = true")
+ List findDashboardInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId,
+ @Param("link") String link,
+ Pageable pageable);
- @Query(value = "SELECT * FROM dashboard d WHERE d.configuration ILIKE CONCAT('%', :link, '%') limit :limit",
- nativeQuery = true)
- List findDashboardInfosByResourceLink(@Param("link") String link, @Param("limit") int limit);
+ @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(d.id, 'DASHBOARD', d.title) " +
+ "FROM DashboardEntity d WHERE ilike(cast(d.configuration as string), CONCAT('%', :link, '%')) = true")
+ List findDashboardInfosByResourceLink(@Param("link") String link,
+ Pageable pageable);
}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java
index bc07139725..0e04c94a46 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java
@@ -17,9 +17,11 @@ package org.thingsboard.server.dao.sql.dashboard;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.PageRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.DashboardInfo;
+import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
@@ -135,13 +137,13 @@ public class JpaDashboardInfoDao extends JpaAbstractDao findByTenantIdAndResourceLink(TenantId tenantId, String url, int limit) {
- return DaoUtil.convertDataList(dashboardInfoRepository.findDashboardInfosByTenantIdAndResourceLink(tenantId.getId(), url, limit));
+ public List findByTenantIdAndResource(TenantId tenantId, String reference, int limit) {
+ return dashboardInfoRepository.findDashboardInfosByTenantIdAndResourceLink(tenantId.getId(), reference, PageRequest.of(0, limit));
}
@Override
- public List findByResourceLink(String link, int limit) {
- return DaoUtil.convertDataList(dashboardInfoRepository.findDashboardInfosByResourceLink(link, limit));
+ public List findByResource(String reference, int limit) {
+ return dashboardInfoRepository.findDashboardInfosByResourceLink(reference, PageRequest.of(0, limit));
}
}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java
index 6cce9d76c2..48e41c8553 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java
@@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ResourceSubType;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
+import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
@@ -115,6 +116,11 @@ public class JpaTbResourceDao extends JpaAbstractDao findSystemOrTenantResourcesByIds(TenantId tenantId, List resourceIds) {
+ return DaoUtil.convertDataList(resourceInfoRepository.findSystemOrTenantResourcesByIdIn(tenantId.getId(), TenantId.NULL_UUID, toUUIDs(resourceIds)));
+ }
}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java
index 6eea20a287..97b1e56527 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java
@@ -79,4 +79,10 @@ public interface TbResourceInfoRepository extends JpaRepository findSystemOrTenantResourcesByIdIn(@Param("tenantId") UUID tenantId,
+ @Param("systemTenantId") UUID systemTenantId,
+ @Param("resourceIds") List resourceIds);
+
}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java
index 1c642d2069..4aa699174f 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java
@@ -20,6 +20,7 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
+import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.dao.ExportableEntityRepository;
import org.thingsboard.server.dao.model.sql.TbResourceEntity;
@@ -101,4 +102,6 @@ public interface TbResourceRepository extends JpaRepository findIdsByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable);
+ @Query("SELECT new org.thingsboard.server.common.data.TbResourceDataInfo(r.data, r.descriptor) FROM TbResourceEntity r WHERE r.id = :id")
+ TbResourceDataInfo getDataInfoById(UUID id);
}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java
index 77044d41dc..4a6427a7e5 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java
@@ -18,8 +18,10 @@ package org.thingsboard.server.dao.sql.rule;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Limit;
+import org.springframework.data.domain.PageRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;
+import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.edqs.fields.RuleChainFields;
import org.thingsboard.server.common.data.id.RuleChainId;
@@ -141,6 +143,16 @@ public class JpaRuleChainDao extends JpaAbstractDao
return findRuleChainsByTenantId(tenantId.getId(), pageLink);
}
+ @Override
+ public List findByTenantIdAndResource(TenantId tenantId, String reference, int limit) {
+ return ruleChainRepository.findRuleChainsByTenantIdAndResource(tenantId.getId(), reference, PageRequest.of(0, limit));
+ }
+
+ @Override
+ public List findByResource(String reference, int limit) {
+ return ruleChainRepository.findRuleChainsByResource(reference, PageRequest.of(0, limit));
+ }
+
@Override
public List findNextBatch(UUID id, int batchSize) {
return ruleChainRepository.findNextBatch(id, Limit.of(batchSize));
diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java
index cfa06caf14..4bf648cbbd 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java
@@ -17,10 +17,12 @@ package org.thingsboard.server.dao.sql.rule;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
+import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.edqs.fields.RuleChainFields;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.dao.ExportableEntityRepository;
@@ -72,6 +74,19 @@ public interface RuleChainRepository extends JpaRepository findRuleChainsByTenantIdAndResource(@Param("tenantId") UUID tenantId,
+ @Param("resourceId") String resourceId,
+ PageRequest of);
+
+ @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(rc.id, 'RULE_CHAIN', rc.name) " +
+ "FROM RuleChainEntity rc WHERE EXISTS " +
+ "(SELECT 1 FROM RuleNodeEntity rn WHERE rn.ruleChainId = rc.id AND cast(rn.configuration as string) LIKE CONCAT('%', :resourceId, '%'))")
+ List findRuleChainsByResource(@Param("resourceId") String resourceId,
+ Pageable pageable);
+
@Query("SELECT new org.thingsboard.server.common.data.edqs.fields.RuleChainFields(r.id, r.createdTime, r.tenantId," +
"r.name, r.version, r.additionalInfo) FROM RuleChainEntity r WHERE r.id > :id ORDER BY r.id")
List findNextBatch(@Param("id") UUID id, Limit limit);
diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java
index c728f5d006..18fb544dcb 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java
@@ -17,8 +17,10 @@ package org.thingsboard.server.dao.sql.widget;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Limit;
+import org.springframework.data.domain.PageRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;
+import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.edqs.fields.WidgetTypeFields;
import org.thingsboard.server.common.data.id.TenantId;
@@ -269,13 +271,13 @@ public class JpaWidgetTypeDao extends JpaAbstractDao findByTenantIdAndResourceLink(TenantId tenantId, String link, int limit) {
- return DaoUtil.convertDataList(widgetTypeInfoRepository.findWidgetTypeInfosByTenantIdAndResourceLink(tenantId.getId(), link, limit));
+ public List findByTenantIdAndResource(TenantId tenantId, String reference, int limit) {
+ return widgetTypeInfoRepository.findWidgetTypeInfosByTenantIdAndResourceLink(tenantId.getId(), reference, PageRequest.of(0, limit));
}
@Override
- public List findByResourceLink(String link, int limit) {
- return DaoUtil.convertDataList(widgetTypeInfoRepository.findWidgetTypeInfosByResourceLink(link, limit));
+ public List findByResource(String reference, int limit) {
+ return widgetTypeInfoRepository.findWidgetTypeInfosByResourceLink(reference, PageRequest.of(0, limit));
}
@Override
diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java
index dc79280bcf..b97b42a6b9 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java
@@ -20,6 +20,7 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
+import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.dao.model.sql.WidgetTypeInfoEntity;
import java.util.List;
@@ -214,10 +215,14 @@ public interface WidgetTypeInfoRepository extends JpaRepository findByImageUrl(@Param("imageLink") String imageLink, @Param("limit") int limit);
- @Query(value = "SELECT * FROM widget_type_info_view w WHERE w.tenant_id = :tenantId AND w.descriptor ILIKE CONCAT('%', :link, '%') LIMIT :limit ", nativeQuery = true)
- List findWidgetTypeInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, @Param("link") String link, @Param("limit") int limit);
-
- @Query(value = "SELECT * FROM widget_type_info_view w WHERE w.descriptor ILIKE CONCAT('%', :link, '%') LIMIT :limit ", nativeQuery = true)
- List findWidgetTypeInfosByResourceLink(@Param("link") String link, @Param("limit") int limit);
+ @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(w.id, 'WIDGET_TYPE', w.name) " +
+ "FROM WidgetTypeEntity w WHERE w.tenantId = :tenantId AND ilike(cast(w.descriptor as string), CONCAT('%', :link, '%')) = true")
+ List findWidgetTypeInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId,
+ @Param("link") String link,
+ Pageable pageable);
+ @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(w.id, 'WIDGET_TYPE', w.name) " +
+ "FROM WidgetTypeEntity w WHERE ilike(cast(w.descriptor as string), CONCAT('%', :link, '%')) = true")
+ List findWidgetTypeInfosByResourceLink(@Param("link") String link,
+ Pageable pageable);
}
diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java
index f5aa8a3af5..59c0db9eee 100644
--- a/dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java
+++ b/dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java
@@ -24,6 +24,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId;
+import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java
index d2687a1b10..920f00ed27 100644
--- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java
+++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java
@@ -24,6 +24,7 @@ import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
+import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.asset.Asset;
@@ -32,6 +33,7 @@ import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.id.TenantId;
@@ -78,6 +80,7 @@ import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.resource.ResourceService;
+import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
@@ -252,6 +255,8 @@ public interface TbContext {
void checkTenantEntity(EntityId entityId) throws TbNodeException;
+ & HasTenantId, I extends EntityId> void checkTenantEntity(E entity) throws TbNodeException;
+
boolean isLocalEntity(EntityId entityId);
RuleNodeId getSelfId();
@@ -308,6 +313,8 @@ public interface TbContext {
ResourceService getResourceService();
+ TbResourceDataCache getTbResourceDataCache();
+
OtaPackageService getOtaPackageService();
RuleEngineDeviceProfileCache getDeviceProfileCache();
diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java
index 3497795771..bd02089204 100644
--- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java
+++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java
@@ -18,12 +18,20 @@ package org.thingsboard.rule.engine.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
import dev.langchain4j.data.message.ChatMessage;
+import dev.langchain4j.data.message.Content;
+import dev.langchain4j.data.message.ImageContent;
+import dev.langchain4j.data.message.PdfFileContent;
import dev.langchain4j.data.message.SystemMessage;
+import dev.langchain4j.data.message.TextContent;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.request.ResponseFormat;
import dev.langchain4j.model.chat.response.ChatResponse;
+import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.RuleNode;
@@ -33,24 +41,38 @@ import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.rule.engine.api.util.TbNodeUtils;
import org.thingsboard.rule.engine.external.TbAbstractExternalNode;
+import org.thingsboard.server.common.data.GeneralFileDescriptor;
+import org.thingsboard.server.common.data.ResourceType;
+import org.thingsboard.server.common.data.TbResourceDataInfo;
+import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.ai.model.AiModelType;
import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig;
import org.thingsboard.server.common.data.id.AiModelId;
+import org.thingsboard.server.common.data.id.TbResourceId;
+import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.dao.exception.DataValidationException;
+import org.thingsboard.server.dao.resource.TbResourceDataCache;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
+import java.util.Base64;
+import java.util.HashSet;
import java.util.List;
import java.util.NoSuchElementException;
+import java.util.Objects;
import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbResponseFormatType;
import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields;
+@Slf4j
@RuleNode(
type = ComponentType.EXTERNAL,
name = "AI request",
@@ -77,6 +99,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
private String systemPrompt;
private String userPrompt;
+ private Set resourceIds;
private ResponseFormat responseFormat;
private int timeoutSeconds;
private AiModelId modelId;
@@ -111,6 +134,14 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
// LangChain4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT
responseFormat = config.getResponseFormat().toLangChainResponseFormat();
}
+ if (config.getResourceIds() != null && !config.getResourceIds().isEmpty()) {
+ resourceIds = new HashSet<>(config.getResourceIds().size());
+ for (UUID resourceId : config.getResourceIds()) {
+ TbResourceId tbResourceId = new TbResourceId(resourceId);
+ validateResource(ctx, tbResourceId);
+ resourceIds.add(tbResourceId);
+ }
+ }
systemPrompt = config.getSystemPrompt();
userPrompt = config.getUserPrompt();
@@ -126,12 +157,42 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
@Override
public void onMsg(TbContext ctx, TbMsg msg) {
var ackedMsg = ackIfNeeded(ctx, msg);
+ final String processedUserPrompt = TbNodeUtils.processPattern(this.userPrompt, ackedMsg);
+
+ final ListenableFuture userMessageFuture =
+ resourceIds == null
+ ? Futures.immediateFuture(UserMessage.from(processedUserPrompt))
+ : Futures.transform(
+ loadResources(ctx),
+ resources -> UserMessage.from(buildContents(processedUserPrompt, resources)),
+ ctx.getDbCallbackExecutor()
+ );
+
+ Futures.addCallback(
+ userMessageFuture,
+ new FutureCallback<>() {
+ @Override
+ public void onSuccess(UserMessage userMessage) {
+ buildAndSendRequest(ctx, ackedMsg, userMessage);
+ }
+ @Override
+ public void onFailure(Throwable t) {
+ tellFailure(ctx, ackedMsg, t);
+ }
+ },
+ MoreExecutors.directExecutor()
+ );
+ }
+
+ private void buildAndSendRequest(TbContext ctx, TbMsg ackedMsg, UserMessage userMessage) {
List chatMessages = new ArrayList<>(2);
- if (systemPrompt != null) {
+
+ if (systemPrompt != null && !systemPrompt.isBlank()) {
chatMessages.add(SystemMessage.from(TbNodeUtils.processPattern(systemPrompt, ackedMsg)));
}
- chatMessages.add(UserMessage.from(TbNodeUtils.processPattern(userPrompt, ackedMsg)));
+
+ chatMessages.add(userMessage);
var chatRequest = ChatRequest.builder()
.messages(chatMessages)
@@ -192,11 +253,67 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
return JacksonUtil.newObjectNode().put("response", response).toString();
}
+ private void validateResource(TbContext ctx, TbResourceId tbResourceId) throws TbNodeException {
+ TbResourceInfo resource = ctx.getResourceService().findResourceInfoById(ctx.getTenantId(), tbResourceId);
+ if (resource == null) {
+ throw new TbNodeException("[" + ctx.getTenantId() + "] Resource with ID: [" + tbResourceId + "] was not found", true);
+ }
+ if (!ResourceType.GENERAL.equals(resource.getResourceType())) {
+ throw new TbNodeException("[" + ctx.getTenantId() + "] Resource with ID: [" + tbResourceId + "] has unsupported resource type: " + resource.getResourceType(), true);
+ }
+ ctx.checkTenantEntity(resource);
+ }
+
+ private ListenableFuture> loadResources(TbContext ctx) {
+ final TenantId tenantId = ctx.getTenantId();
+ final TbResourceDataCache cache = ctx.getTbResourceDataCache();
+ List extends ListenableFuture> futures = resourceIds.stream()
+ .map(id -> cache.getResourceDataInfoAsync(tenantId, id))
+ .toList();
+ return Futures.allAsList(futures);
+ }
+
+ private List buildContents(String userPrompt, List resources) {
+ List contents = new ArrayList<>(1 + resources.size());
+ contents.add(new TextContent(userPrompt)); // user prompt first
+
+ resources.stream()
+ .filter(Objects::nonNull)
+ .map(this::toContent)
+ .forEach(contents::add);
+
+ return contents;
+ }
+
+ private Content toContent(TbResourceDataInfo resource) {
+ if (resource.getDescriptor() == null) {
+ throw new RuntimeException("Missing descriptor for resource");
+ }
+ GeneralFileDescriptor descriptor = JacksonUtil.treeToValue(resource.getDescriptor(), GeneralFileDescriptor.class);
+ String mediaType = descriptor.getMediaType();
+ if (mediaType == null) {
+ throw new RuntimeException("Missing mediaType in resource descriptor " + resource.getDescriptor());
+ }
+ byte[] data = resource.getData();
+ if (mediaType.startsWith("text/")) {
+ return new TextContent(new String(data, StandardCharsets.UTF_8));
+ }
+ if (mediaType.equals("application/pdf")) {
+ return new PdfFileContent(Base64.getEncoder().encodeToString(data), mediaType);
+ }
+ if (mediaType.startsWith("image/")) {
+ return new ImageContent(Base64.getEncoder().encodeToString(data), mediaType);
+ }
+ log.debug("Trying to create text content for {}", resource.getDescriptor());
+ return new TextContent(new String(data, StandardCharsets.UTF_8));
+ }
+
@Override
public void destroy() {
super.destroy();
systemPrompt = null;
userPrompt = null;
+ resourceIds = null;
responseFormat = null;
modelId = null;
}
diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java
index 48392aa76e..f51983ecb1 100644
--- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java
+++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java
@@ -20,12 +20,14 @@ import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
-import jakarta.validation.constraints.Pattern;
import lombok.Data;
import org.thingsboard.rule.engine.api.NodeConfiguration;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.validation.Length;
+import java.util.Set;
+import java.util.UUID;
+
import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat;
@Data
@@ -41,6 +43,8 @@ public class TbAiNodeConfiguration implements NodeConfiguration resourceIds;
+
@NotNull
@Valid
private TbResponseFormat responseFormat;
diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java
index f21aa559d4..c5b7f2c44b 100644
--- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java
+++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java
@@ -17,8 +17,11 @@ package org.thingsboard.rule.engine.ai;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.FluentFuture;
+import com.google.common.util.concurrent.Futures;
import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.data.message.ImageContent;
import dev.langchain4j.data.message.SystemMessage;
+import dev.langchain4j.data.message.TextContent;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.request.ResponseFormat;
import dev.langchain4j.model.chat.request.ResponseFormatType;
@@ -32,6 +35,7 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.common.util.JacksonUtil;
@@ -43,6 +47,10 @@ import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException;
+import org.thingsboard.server.common.data.GeneralFileDescriptor;
+import org.thingsboard.server.common.data.ResourceType;
+import org.thingsboard.server.common.data.TbResource;
+import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.ai.model.AiModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
@@ -52,6 +60,7 @@ import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.RuleNodeId;
+import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.msg.TbNodeConnectionType;
import org.thingsboard.server.common.data.rule.RuleNode;
@@ -59,9 +68,14 @@ import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.dao.ai.AiModelService;
import org.thingsboard.server.dao.exception.DataValidationException;
+import org.thingsboard.server.dao.resource.ResourceService;
+import org.thingsboard.server.dao.resource.TbResourceDataCache;
+import java.util.Base64;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;
@@ -76,16 +90,23 @@ import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
+import static org.thingsboard.server.common.data.ResourceType.GENERAL;
@ExtendWith(MockitoExtension.class)
class TbAiNodeTest {
+ private static final byte[] PNG_IMAGE = Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAC9FBMVEUAAAABAQEBAgICAgICAwMCBAQDAwMDBQUDBgYEBAQEBwcECAgFCQkFCgoGBgYGCwsGDAwHBwcHDQ0HDg4ICAgIDw8IEBAJCQkJEREKEhIKExMLFBQLFRUMFhYMFxcNDQ0NGBgNGRkODg4OGhoOGxsPDw8PHBwPHR0QEBAQHh4QHx8RERERICARISESEhISIiITExMTIyMTJCQUJSUUJiYVKCgWFhYWKSkXFxcXGhwYGBgYLC0ZGRkaMDEaMTIbGxsbMjMcMzQdNTYfOTogICAgOzwiP0AiQEEjIyMjQkMkQ0QnJycnSEkoS0wpKSkrUFErUVIsLCwvV1gvWFkwWlszMzMzYGE1NTU2NjY3Zmc4aWo5OTk5ams5a2w6Ojo6bG07bm88cXI9cnM9c3Q/dndAQEBAeHlBeXpCQkJCe3xCfH1DQ0NEREREf4FFRUVFgIJGg4VHhYdISEhIhohJh4lLi41LjI5MTExMjpBNj5FNkJJOkpRQUFBQlZdRUVFSUlJTU1NTmpxUVFRUnZ9VVVVVnqBWVlZYpadZWVlZp6laqKpbW1tbqatbqqxcXFxcrK5dra9drrBeXl5er7FfsbNfsrRgs7VhYWFiYmJiuLpjubtku71lvL5lvb9mvsBnwcNowsRpxMZpxcdra2tryctsysxubm5uzc9vb29vz9Fw0dNx0tVy1Ndy1dhz1tlz19p0dHR02Nt02dx12t1229523N93d3d33eB33uF5eXl54eR6enp64+Z65Od75eh75ul8fHx85+p86Ot96ex96u2AgICA7vGA7/KB8fSC8/aD9PeD9fiEhISE9/qF+PuF+fyGhoaG+v2G+/6Hh4eH/P+IiIiMjIyNjY2Ojo6QkJCRkZGSkpKTk5Obm5ucnJyfn5+lpaWnp6eoqKipqamqqqqwsLCzs7O1tbW4uLi5ubm6urq7u7u8vLy/v7/BwcHCwsLFxcXGxsbPz8/Y2Nji4uLj4+Pv7+/4+Pj5+fn+/v7/75T///+GLm1tAAAAAWJLR0T7omo23AAABJtJREFUeNrt3Wd8E3UYB/CH0oqm1dJaS5N0IKu0qQSVinXG4gKlKFi3uMC9FVwoVQnQqCBgBVxFnKCoFFFExFGhliWt/zoYLuIMKEpB7b3xuf9dQu+MvAjXcsTf7/PJk/ul1/S+TS53r3KkNFfk0V6evDHbFGruQ3EQTzNVUFxkHOXFB6QbIQiCIAiC/GeSs/QkR6vkCPeUaNUeSUjkkdR1npCp6a7VV7U6P1dbKfNFrS89rJNas/T6rlZtkUS/i2evhw99Q92y9/r7nVzzw7VfeDX3y2qv893plTVb1uW+uw6xiyNpspAQ8bjLy8l5REiImOlUq3Pniunyxw8Ib+vqF7aB5AgdItLVmit0iOgc9W0owhDt1RSAABL3EGeDDqmXhwRXgw6pj3qESFhtgHC1DYSGrJCQjweFq4SEqzkD67zGah8Inay+p1yl4XqKWt2lF69UDxQrzzevXZprrDn2gfTIUs85Iv/oHpny8HKHdugeVZhpXNudu6u6J1P8lmpIX1ys10X6myVfPeLl919UZFi74JXjWtfCecfa5sj+odx908XSg9Taqdaw+3I1QuYLA6RG2AbiEDpE9JJnvcYP1BRhgiw3QuoAASTuIQnP6JCF8hQlcbYBwrWIKgPDIg9UGSGP2QdCnZ+QkDneKQs4swqe1CDJ09RaXfBUETWKm3a+gFMMEMc0+0AoJVX9nM1+VDsCznLurz64b5VWq7nWLLi81QfygYZfNlU7nAUP0nOwrLnGiiAIgiAIgiAIgiDI/zstLS3tMEtKSiycgAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBYAkEQBEEQBEEQBEEQBGmrdLwuyLmhg703km8Z63k7N2Tw0jnqFt/f0bROn69WBYOfbuxiyR+8MXC9vB8QCBTQkEAgMOG2gVyvDmTzdAWuifFp077m8f503vwZr/PSd28Hg+uaTjVDlOFEIxVrINVijfwi4glCHE1XioXPz6kX9xHNFIUkvyM/xqeduIPHup95bGni8edYotOUqJCrrII0iMv4LnNFg4Sczd/9/Zw4abchD0Ygv0pIBVFZG0Nq587lu/PE02EIXSQuaSfI92l88bfNFkHqLxUnEM1+bXQEMloMY8hgn893esyQIzbzWHtveXn51GW89AtfTeyATWZIWm919s6wBtLYdfXdVCyuuEdCHhoxwr/mAzdDtMQKoaP4duQmRVG+kUtyu83X3OuylX09f+9r0c6eOvkjx82fdPdLiHrdjsrD1Z39LP5W06ExQ475g8eqSR6PZ+oXvLSVNWk/nmmGKNcSXaBYBXEPFkMXV1GlhFyYlSof3t19ZOxfPJp+4/HTeh47JhGdqLQxJDtpyRJxBgUi+0g7QkYSlVsHoVtFrcNiyO0SsoXHDxIykej4v/8F+XxDKLRxmXWQfo2jyGJIh894PDs9FArNeIGXvlwbCn37Upl5rXObOMPtf1K4z5u8ne/sx0tl6hbfgtNkBEGQPZs4uUBwTxoTH5DxtM0TD46+20lpHrfXX7e52/jtyj9kFKbIT2L3FQAAAABJRU5ErkJggg==");
+
@Mock
TbContext ctxMock;
@Mock
AiModelService aiModelServiceMock;
@Mock
RuleEngineAiChatModelService aiChatModelServiceMock;
+ @Mock
+ TbResourceDataCache tbResourceDataCacheMock;
+ @Mock
+ ResourceService resourceServiceMock;
TbAiNode aiNode;
TbAiNodeConfiguration config;
@@ -141,6 +162,8 @@ class TbAiNodeTest {
lenient().when(ctxMock.getAiModelService()).thenReturn(aiModelServiceMock);
lenient().when(ctxMock.getAiChatModelService()).thenReturn(aiChatModelServiceMock);
lenient().when(ctxMock.getDbCallbackExecutor()).thenReturn(new TestDbCallbackExecutor());
+ lenient().when(ctxMock.getTbResourceDataCache()).thenReturn(tbResourceDataCacheMock);
+ lenient().when(ctxMock.getResourceService()).thenReturn(resourceServiceMock);
}
@Test
@@ -158,6 +181,7 @@ class TbAiNodeTest {
assertThat(config.getResponseFormat()).isEqualTo(new TbJsonResponseFormat());
assertThat(config.getTimeoutSeconds()).isEqualTo(60);
assertThat(config.isForceAck()).isTrue();
+ assertThat(config.getResourceIds()).isNull();
}
/* -- Node initialization tests -- */
@@ -373,6 +397,36 @@ class TbAiNodeTest {
.matches(e -> ((TbNodeException) e).isUnrecoverable());
}
+ @Test
+ void givenNotExistingResources_whenInit_thenThrowsException() {
+ // GIVEN
+ config = constructValidConfig();
+ UUID resourceId = UUID.randomUUID();
+ config.setResourceIds(Set.of(resourceId));
+
+ // WHEN-THEN
+ assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
+ .isInstanceOf(TbNodeException.class)
+ .hasMessageContaining("[" + tenantId + "] Resource with ID: [" + resourceId + "] was not found");
+ }
+
+ @Test
+ void givenResourceOfWrongType_whenInit_thenThrowsException() {
+ // GIVEN
+ config = constructValidConfig();
+ UUID resourceId = UUID.randomUUID();
+ config.setResourceIds(Set.of(resourceId));
+
+ // WHEN-THEN
+ TbResource tbResource = new TbResource();
+ tbResource.setResourceType(ResourceType.DASHBOARD);
+ given(resourceServiceMock.findResourceInfoById(any(), any())).willReturn(tbResource);
+
+ assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
+ .isInstanceOf(TbNodeException.class)
+ .hasMessageContaining("[" + tenantId + "] Resource with ID: [" + resourceId + "] has unsupported resource type: " + ResourceType.DASHBOARD);
+ }
+
/* -- Message processing tests -- */
@Test
@@ -560,6 +614,166 @@ class TbAiNodeTest {
);
}
+ @Test
+ void givenSystemPromptAndUserPromptAndResourcesConfigured_whenOnMsg_thenRequestContainsSystemAndUserAndResourceContent() throws TbNodeException {
+ String systemPrompt = "Respond with valid JSON";
+ String userPrompt = "Tell me a joke";
+ String textData = "Text resource content for AI request.";
+ String xmlData = "";
+
+ // GIVEN
+ config = constructValidConfig();
+ config.setSystemPrompt(systemPrompt);
+ config.setUserPrompt(userPrompt);
+ UUID resourceId = UUID.randomUUID();
+ UUID resourceId2 = UUID.randomUUID();
+ UUID resourceId3 = UUID.randomUUID();
+
+ config.setResourceIds(Set.of(resourceId, resourceId2, resourceId3));
+
+ // WHEN-THEN
+ TbResource textResource = buildGeneralResource(textData.getBytes(), "text/plain");
+ TbResource xmlResource = buildGeneralResource(xmlData.getBytes(), "application/xml");
+ TbResource imageResource = buildGeneralResource(PNG_IMAGE, "image/png");
+
+ given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(textResource);
+ given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId2)))).willReturn(xmlResource);
+ given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId3)))).willReturn(imageResource);
+
+ given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(textResource.toResourceDataInfo())));
+ given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId2)))).willReturn(FluentFuture.from(Futures.immediateFuture(xmlResource.toResourceDataInfo())));
+ given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId3)))).willReturn(FluentFuture.from(Futures.immediateFuture(imageResource.toResourceDataInfo())));
+
+ aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
+
+ var msg = TbMsg.newMsg()
+ .originator(deviceId)
+ .data(TbMsg.EMPTY_JSON_OBJECT)
+ .metaData(TbMsgMetaData.EMPTY)
+ .build();
+
+ var chatResponse = ChatResponse.builder()
+ .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}"))
+ .build();
+
+ given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse)));
+
+ // WHEN
+ aiNode.onMsg(ctxMock, msg);
+
+ // THEN
+ then(aiChatModelServiceMock).should().sendChatRequestAsync(any(),
+ argThat(actualChatRequest -> {
+ assertThat(actualChatRequest.messages()).hasSize(2);
+ assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(systemPrompt));
+ assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents())
+ .containsAll(List.of(new TextContent(userPrompt), new TextContent(textData),
+ new TextContent(xmlData), new ImageContent(Base64.getEncoder().encodeToString(PNG_IMAGE), "image/png")));
+ return true;
+ })
+ );
+ }
+
+ @Test
+ void givenNullResource_whenOnMsg_thenRequestContainsSystemAndUserPrompt() throws TbNodeException {
+ // GIVEN
+ config = constructValidConfig();
+ UUID resourceId = UUID.randomUUID();
+ config.setResourceIds(Set.of(resourceId));
+
+ // WHEN-THEN
+ TbResource tbResource = buildGeneralResource("Text resource content for AI request.".getBytes(), "text/plain");
+
+ given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(tbResource);
+ given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(null)));
+
+ aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
+
+ var msg = TbMsg.newMsg()
+ .originator(deviceId)
+ .data(TbMsg.EMPTY_JSON_OBJECT)
+ .metaData(TbMsgMetaData.EMPTY)
+ .build();
+
+ // WHEN
+ aiNode.onMsg(ctxMock, msg);
+
+ // THEN
+ then(aiChatModelServiceMock).should().sendChatRequestAsync(any(),
+ argThat(actualChatRequest -> {
+ assertThat(actualChatRequest.messages()).hasSize(2);
+ assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(config.getSystemPrompt()));
+ assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents())
+ .containsAll(List.of(new TextContent(config.getUserPrompt())));
+ return true;
+ })
+ );
+ }
+
+ @Test
+ void givenResourceWithNoDescriptor_whenOnMsg_thenEnqueueForTellFailure() throws TbNodeException {
+ // GIVEN
+ config = constructValidConfig();
+ UUID resourceId = UUID.randomUUID();
+ config.setResourceIds(Set.of(resourceId));
+
+ // WHEN-THEN
+ TbResource tbResource = buildGeneralResource("Text resource content for AI request.".getBytes(), "text/plain");
+ TbResourceDataInfo resourceDataInfo = new TbResourceDataInfo(tbResource.getData(), null);
+
+ given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(tbResource);
+ given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(resourceDataInfo)));
+
+ aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
+
+ var msg = TbMsg.newMsg()
+ .originator(deviceId)
+ .data(TbMsg.EMPTY_JSON_OBJECT)
+ .metaData(TbMsgMetaData.EMPTY)
+ .build();
+
+ // WHEN
+ aiNode.onMsg(ctxMock, msg);
+
+ // THEN
+ var exceptionCaptor = ArgumentCaptor.forClass(Throwable.class);
+ then(ctxMock).should().enqueueForTellFailure(any(), exceptionCaptor.capture());
+ Throwable actualException = exceptionCaptor.getValue();
+ assertThat(actualException.getMessage()).isEqualTo("Missing descriptor for resource");
+ }
+
+ @Test
+ void givenResourceWithNoMediaType_whenOnMsg_thenEnqueueForTellFailure() throws TbNodeException {
+ // GIVEN
+ config = constructValidConfig();
+ UUID resourceId = UUID.randomUUID();
+ config.setResourceIds(Set.of(resourceId));
+
+ // WHEN-THEN
+ TbResource tbResource = buildGeneralResource("Text resource content for AI request.".getBytes(), "text/plain");
+ TbResourceDataInfo resourceDataInfo = new TbResourceDataInfo(tbResource.getData(), JacksonUtil.newObjectNode());
+
+ given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(tbResource);
+ given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(resourceDataInfo)));
+
+ aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
+
+ var msg = TbMsg.newMsg()
+ .originator(deviceId)
+ .data(TbMsg.EMPTY_JSON_OBJECT)
+ .metaData(TbMsgMetaData.EMPTY)
+ .build();
+
+ // WHEN
+ aiNode.onMsg(ctxMock, msg);
+
+ // THEN
+ var exceptionCaptor = ArgumentCaptor.forClass(Throwable.class);
+ then(ctxMock).should().enqueueForTellFailure(any(), exceptionCaptor.capture());
+ Throwable actualException = exceptionCaptor.getValue();
+ assertThat(actualException.getMessage()).isEqualTo("Missing mediaType in resource descriptor {}");
+ }
+
@Test
void givenTemplatedPrompts_whenOnMsg_thenRequestContainsSubstitutedMessages() throws TbNodeException {
// GIVEN
@@ -950,4 +1164,13 @@ class TbAiNodeTest {
then(ctxMock).should(never()).tellFailure(any(), any());
}
+ private TbResource buildGeneralResource(byte[] data, String mediaType) {
+ TbResource tbResource = new TbResource();
+ tbResource.setResourceType(GENERAL);
+ GeneralFileDescriptor descriptor = new GeneralFileDescriptor(mediaType);
+ tbResource.setDescriptorValue(descriptor);
+ tbResource.setData(data);
+ return tbResource;
+ }
+
}
From e477dd17b641488546d0303c06b495fa45067c7c Mon Sep 17 00:00:00 2001
From: Volodymyr Babak
Date: Fri, 26 Sep 2025 13:34:58 +0300
Subject: [PATCH 06/31] Improved Edge session cleanup
---
.../service/edge/rpc/EdgeGrpcService.java | 30 +++++++++++--------
1 file changed, 18 insertions(+), 12 deletions(-)
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java
index 7340696788..ec3f839cb3 100644
--- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java
+++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java
@@ -93,14 +93,13 @@ import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAS
@TbCoreComponent
public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase implements EdgeRpcService {
- private static final int DESTROY_SESSION_MAX_ATTEMPTS = 10;
-
private final ConcurrentMap sessions = new ConcurrentHashMap<>();
private final ConcurrentMap sessionNewEventsLocks = new ConcurrentHashMap<>();
private final Map sessionNewEvents = new HashMap<>();
private final ConcurrentMap> sessionEdgeEventChecks = new ConcurrentHashMap<>();
private final ConcurrentMap> localSyncEdgeRequests = new ConcurrentHashMap<>();
private final ConcurrentMap edgeEventsMigrationProcessed = new ConcurrentHashMap<>();
+ private final List zombieSessions = new ArrayList<>();
@Value("${edges.rpc.port}")
private int rpcPort;
@@ -193,7 +192,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
this.edgeEventProcessingExecutorService = ThingsBoardExecutors.newScheduledThreadPool(schedulerPoolSize, "edge-event-check-scheduler");
this.sendDownlinkExecutorService = ThingsBoardExecutors.newScheduledThreadPool(sendSchedulerPoolSize, "edge-send-scheduler");
this.executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edge-service");
- this.executorService.scheduleAtFixedRate(this::destroyKafkaSessionIfDisconnectedAndConsumerActive, 60, 60, TimeUnit.SECONDS);
+ this.executorService.scheduleAtFixedRate(this::cleanupZombieSessions, 60, 60, TimeUnit.SECONDS);
log.info("Edge RPC service initialized!");
}
@@ -518,14 +517,10 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
private void destroySession(EdgeGrpcSession session) {
try (session) {
- for (int i = 0; i < DESTROY_SESSION_MAX_ATTEMPTS; i++) {
- if (session.destroy()) {
- break;
- } else {
- try {
- Thread.sleep(100);
- } catch (InterruptedException ignored) {}
- }
+ if (!session.destroy()) {
+ log.warn("[{}][{}] Session destroy failed for edge [{}] with session id [{}]. Adding to zombie queue for later cleanup.",
+ session.getTenantId(), session.getEdge().getId(), session.getEdge().getName(), session.getSessionId());
+ zombieSessions.add(session);
}
}
}
@@ -634,7 +629,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
}
}
- private void destroyKafkaSessionIfDisconnectedAndConsumerActive() {
+ private void cleanupZombieSessions() {
try {
List toRemove = new ArrayList<>();
for (EdgeGrpcSession session : sessions.values()) {
@@ -655,6 +650,17 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
}
}
}
+ zombieSessions.removeIf(zombie -> {
+ if (zombie.destroy()) {
+ log.info("[{}][{}] Successfully cleaned up zombie session [{}] for edge [{}].",
+ zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName());
+ return true;
+ } else {
+ log.warn("[{}][{}] Failed to remove zombie session [{}] for edge [{}].",
+ zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName());
+ return false;
+ }
+ });
} catch (Exception e) {
log.warn("Failed to cleanup kafka sessions", e);
}
From 41f0a9702e57f8e1ba7ecc641c6f6b5fafe7b088 Mon Sep 17 00:00:00 2001
From: Dmytro Skarzhynets
Date: Mon, 15 Sep 2025 17:32:57 +0300
Subject: [PATCH 07/31] AI models: add support for Ollama
---
application/pom.xml | 4 ++
.../Langchain4jChatModelConfigurerImpl.java | 16 ++++++
.../common/data/ai/dto/TbChatResponse.java | 6 +-
.../common/data/ai/model/AiModelConfig.java | 8 ++-
.../data/ai/model/chat/AiChatModelConfig.java | 2 +-
.../chat/Langchain4jChatModelConfigurer.java | 2 +
.../ai/model/chat/OllamaChatModelConfig.java | 57 +++++++++++++++++++
.../common/data/ai/provider/AiProvider.java | 3 +-
.../data/ai/provider/AiProviderConfig.java | 2 +-
.../ai/provider/OllamaProviderConfig.java | 22 +++++++
.../rule/engine/ai/TbResponseFormat.java | 8 +--
.../ai-model/ai-model-dialog.component.html | 9 +++
.../ai-model/ai-model-dialog.component.ts | 1 +
.../src/app/shared/models/ai-model.models.ts | 21 +++++--
.../assets/locale/locale.constant-en_US.json | 5 +-
15 files changed, 146 insertions(+), 20 deletions(-)
create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java
create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java
diff --git a/application/pom.xml b/application/pom.xml
index 33bc0972d4..0413f7732c 100644
--- a/application/pom.xml
+++ b/application/pom.xml
@@ -419,6 +419,10 @@
+
+ dev.langchain4j
+ langchain4j-ollama
+
diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
index 69dd98f47f..7008e866f4 100644
--- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
+++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
@@ -32,6 +32,7 @@ import dev.langchain4j.model.chat.request.ChatRequestParameters;
import dev.langchain4j.model.github.GitHubModelsChatModel;
import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel;
import dev.langchain4j.model.mistralai.MistralAiChatModel;
+import dev.langchain4j.model.ollama.OllamaChatModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel;
import org.springframework.stereotype.Component;
@@ -43,6 +44,7 @@ import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelC
import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer;
import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig;
import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig;
@@ -262,6 +264,20 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
.build();
}
+ @Override
+ public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) {
+ return OllamaChatModel.builder()
+ .baseUrl(chatModelConfig.providerConfig().baseUrl())
+ .modelName(chatModelConfig.modelId())
+ .temperature(chatModelConfig.temperature())
+ .topP(chatModelConfig.topP())
+ .topK(chatModelConfig.topK())
+ .numPredict(chatModelConfig.maxOutputTokens())
+ .timeout(toDuration(chatModelConfig.timeoutSeconds()))
+ .maxRetries(chatModelConfig.maxRetries())
+ .build();
+ }
+
private static Duration toDuration(Integer timeoutSeconds) {
return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null;
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java
index 2cc17e4553..73e6557fb5 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java
@@ -22,7 +22,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
property = "status",
- include = JsonTypeInfo.As.PROPERTY,
+ include = JsonTypeInfo.As.EXISTING_PROPERTY,
visible = true
)
@JsonSubTypes({
@@ -51,9 +51,7 @@ public sealed interface TbChatResponse permits TbChatResponse.Success, TbChatRes
}
record Failure(
- @Schema(
- description = "A string containing details about the failure"
- )
+ @Schema(description = "A string containing details about the failure")
String errorDetails
) implements TbChatResponse {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java
index bfaa29a6e3..f18429e7cf 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java
@@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelCon
import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.AiProviderConfig;
@@ -34,6 +35,7 @@ import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig
import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
@JsonTypeInfo(
@@ -50,7 +52,8 @@ import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
@JsonSubTypes.Type(value = MistralAiChatModelConfig.class, name = "MISTRAL_AI"),
@JsonSubTypes.Type(value = AnthropicChatModelConfig.class, name = "ANTHROPIC"),
@JsonSubTypes.Type(value = AmazonBedrockChatModelConfig.class, name = "AMAZON_BEDROCK"),
- @JsonSubTypes.Type(value = GitHubModelsChatModelConfig.class, name = "GITHUB_MODELS")
+ @JsonSubTypes.Type(value = GitHubModelsChatModelConfig.class, name = "GITHUB_MODELS"),
+ @JsonSubTypes.Type(value = OllamaChatModelConfig.class, name = "OLLAMA")
})
public interface AiModelConfig {
@@ -69,7 +72,8 @@ public interface AiModelConfig {
@JsonSubTypes.Type(value = MistralAiProviderConfig.class, name = "MISTRAL_AI"),
@JsonSubTypes.Type(value = AnthropicProviderConfig.class, name = "ANTHROPIC"),
@JsonSubTypes.Type(value = AmazonBedrockProviderConfig.class, name = "AMAZON_BEDROCK"),
- @JsonSubTypes.Type(value = GitHubModelsProviderConfig.class, name = "GITHUB_MODELS")
+ @JsonSubTypes.Type(value = GitHubModelsProviderConfig.class, name = "GITHUB_MODELS"),
+ @JsonSubTypes.Type(value = OllamaProviderConfig.class, name = "OLLAMA")
})
AiProviderConfig providerConfig();
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java
index 2bc28cfce0..49126c1861 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java
@@ -24,7 +24,7 @@ public sealed interface AiChatModelConfig> extend
permits
OpenAiChatModelConfig, AzureOpenAiChatModelConfig, GoogleAiGeminiChatModelConfig,
GoogleVertexAiGeminiChatModelConfig, MistralAiChatModelConfig, AnthropicChatModelConfig,
- AmazonBedrockChatModelConfig, GitHubModelsChatModelConfig {
+ AmazonBedrockChatModelConfig, GitHubModelsChatModelConfig, OllamaChatModelConfig {
ChatModel configure(Langchain4jChatModelConfigurer configurer);
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java
index c9c1bc3173..828256dcdc 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java
@@ -35,4 +35,6 @@ public interface Langchain4jChatModelConfigurer {
ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig);
+ ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig);
+
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java
new file mode 100644
index 0000000000..360b514d6d
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java
@@ -0,0 +1,57 @@
+/**
+ * Copyright © 2016-2025 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.common.data.ai.model.chat;
+
+import dev.langchain4j.model.chat.ChatModel;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.PositiveOrZero;
+import lombok.Builder;
+import lombok.With;
+import org.thingsboard.server.common.data.ai.provider.AiProvider;
+import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
+
+@Builder
+public record OllamaChatModelConfig(
+ @NotNull @Valid OllamaProviderConfig providerConfig,
+ @NotBlank String modelId,
+ @PositiveOrZero Double temperature,
+ @Positive @Max(1) Double topP,
+ @PositiveOrZero Integer topK,
+ @Positive Integer maxOutputTokens,
+ @With @Positive Integer timeoutSeconds,
+ @With @PositiveOrZero Integer maxRetries
+) implements AiChatModelConfig {
+
+ @Override
+ public AiProvider provider() {
+ return AiProvider.OLLAMA;
+ }
+
+ @Override
+ public ChatModel configure(Langchain4jChatModelConfigurer configurer) {
+ return configurer.configureChatModel(this);
+ }
+
+ @Override
+ public boolean supportsJsonMode() {
+ return true;
+ }
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java
index d0a5bd0510..a9a6af4de8 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java
@@ -24,6 +24,7 @@ public enum AiProvider {
MISTRAL_AI,
ANTHROPIC,
AMAZON_BEDROCK,
- GITHUB_MODELS
+ GITHUB_MODELS,
+ OLLAMA
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java
index bd32c88efb..5423b24410 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java
@@ -19,4 +19,4 @@ public sealed interface AiProviderConfig
permits
OpenAiProviderConfig, AzureOpenAiProviderConfig, GoogleAiGeminiProviderConfig,
GoogleVertexAiGeminiProviderConfig, MistralAiProviderConfig, AnthropicProviderConfig,
- AmazonBedrockProviderConfig, GitHubModelsProviderConfig {}
+ AmazonBedrockProviderConfig, GitHubModelsProviderConfig, OllamaProviderConfig {}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java
new file mode 100644
index 0000000000..fc0a2d6fd8
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java
@@ -0,0 +1,22 @@
+/**
+ * Copyright © 2016-2025 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.common.data.ai.provider;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record OllamaProviderConfig(
+ @NotBlank String baseUrl
+) implements AiProviderConfig {}
diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java
index 5c891a9c74..5107e613a4 100644
--- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java
+++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java
@@ -60,9 +60,7 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes
@Override
public ResponseFormat toLangChainResponseFormat() {
- return ResponseFormat.builder()
- .type(ResponseFormatType.TEXT)
- .build();
+ return ResponseFormat.TEXT;
}
}
@@ -76,9 +74,7 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes
@Override
public ResponseFormat toLangChainResponseFormat() {
- return ResponseFormat.builder()
- .type(ResponseFormatType.JSON)
- .build();
+ return ResponseFormat.JSON;
}
}
diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html
index 5f9c189399..1a3c2bf181 100644
--- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html
+++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html
@@ -150,6 +150,15 @@
}
+ @if (providerFieldsList.includes('baseUrl')) {
+
+ ai-models.baseurl
+
+
+ {{ 'ai-models.baseurl-required' | translate }}
+
+
+ }
diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts
index c459d66f12..e6490cd84d 100644
--- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts
+++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts
@@ -100,6 +100,7 @@ export class AIModelDialogComponent extends DialogComponent, 'label'>, HasTenantId
region?: string;
accessKeyId?: string;
secretAccessKey?: string;
+ baseUrl?: string;
};
modelId: string;
temperature?: number;
@@ -57,7 +58,8 @@ export enum AiProvider {
MISTRAL_AI = 'MISTRAL_AI',
ANTHROPIC = 'ANTHROPIC',
AMAZON_BEDROCK = 'AMAZON_BEDROCK',
- GITHUB_MODELS = 'GITHUB_MODELS'
+ GITHUB_MODELS = 'GITHUB_MODELS',
+ OLLAMA = 'OLLAMA'
}
export const AiProviderTranslations = new Map(
@@ -69,7 +71,8 @@ export const AiProviderTranslations = new Map(
[AiProvider.MISTRAL_AI , 'ai-models.ai-providers.mistral-ai'],
[AiProvider.ANTHROPIC , 'ai-models.ai-providers.anthropic'],
[AiProvider.AMAZON_BEDROCK , 'ai-models.ai-providers.amazon-bedrock'],
- [AiProvider.GITHUB_MODELS , 'ai-models.ai-providers.github-models']
+ [AiProvider.GITHUB_MODELS , 'ai-models.ai-providers.github-models'],
+ [AiProvider.OLLAMA , 'ai-models.ai-providers.ollama']
]
);
@@ -84,7 +87,8 @@ export const ProviderFieldsAllList = [
'serviceVersion',
'region',
'accessKeyId',
- 'secretAccessKey'
+ 'secretAccessKey',
+ 'baseUrl'
];
export const ModelFieldsAllList = ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'];
@@ -191,6 +195,14 @@ export const AiModelMap = new Map
Date: Tue, 16 Sep 2025 14:41:38 +0300
Subject: [PATCH 08/31] AI models: add context length support for Ollama
---
.../Langchain4jChatModelConfigurerImpl.java | 1 +
.../chat/AmazonBedrockChatModelConfig.java | 2 +-
.../model/chat/AnthropicChatModelConfig.java | 2 +-
.../chat/AzureOpenAiChatModelConfig.java | 2 +-
.../chat/GitHubModelsChatModelConfig.java | 2 +-
.../chat/GoogleAiGeminiChatModelConfig.java | 2 +-
.../GoogleVertexAiGeminiChatModelConfig.java | 2 +-
.../model/chat/MistralAiChatModelConfig.java | 2 +-
.../ai/model/chat/OllamaChatModelConfig.java | 3 ++-
.../ai/model/chat/OpenAiChatModelConfig.java | 2 +-
.../ai-model/ai-model-dialog.component.html | 23 +++++++++++--------
.../ai-model/ai-model-dialog.component.ts | 3 ++-
.../src/app/shared/models/ai-model.models.ts | 5 ++--
.../assets/locale/locale.constant-en_US.json | 3 ++-
14 files changed, 31 insertions(+), 23 deletions(-)
diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
index 7008e866f4..84b09b9188 100644
--- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
+++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
@@ -272,6 +272,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
.temperature(chatModelConfig.temperature())
.topP(chatModelConfig.topP())
.topK(chatModelConfig.topK())
+ .numCtx(chatModelConfig.contextLength())
.numPredict(chatModelConfig.maxOutputTokens())
.timeout(toDuration(chatModelConfig.timeoutSeconds()))
.maxRetries(chatModelConfig.maxRetries())
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java
index 2bb4de5aa8..d2ab72086a 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java
@@ -33,7 +33,7 @@ public record AmazonBedrockChatModelConfig(
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
- @Positive Integer maxOutputTokens,
+ Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java
index 69b5578fb3..6d505f75a6 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java
@@ -34,7 +34,7 @@ public record AnthropicChatModelConfig(
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
@PositiveOrZero Integer topK,
- @Positive Integer maxOutputTokens,
+ Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java
index 47e7e96c37..f70f2af539 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java
@@ -35,7 +35,7 @@ public record AzureOpenAiChatModelConfig(
@Positive @Max(1) Double topP,
Double frequencyPenalty,
Double presencePenalty,
- @Positive Integer maxOutputTokens,
+ Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java
index b509254f77..0aafd72197 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java
@@ -35,7 +35,7 @@ public record GitHubModelsChatModelConfig(
@Positive @Max(1) Double topP,
Double frequencyPenalty,
Double presencePenalty,
- @Positive Integer maxOutputTokens,
+ Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java
index fe11a11460..b5c3d4263d 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java
@@ -36,7 +36,7 @@ public record GoogleAiGeminiChatModelConfig(
@PositiveOrZero Integer topK,
Double frequencyPenalty,
Double presencePenalty,
- @Positive Integer maxOutputTokens,
+ Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java
index 609e14f86e..944963ee27 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java
@@ -36,7 +36,7 @@ public record GoogleVertexAiGeminiChatModelConfig(
@PositiveOrZero Integer topK,
Double frequencyPenalty,
Double presencePenalty,
- @Positive Integer maxOutputTokens,
+ Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java
index f603e99c53..8f67d93398 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java
@@ -35,7 +35,7 @@ public record MistralAiChatModelConfig(
@Positive @Max(1) Double topP,
Double frequencyPenalty,
Double presencePenalty,
- @Positive Integer maxOutputTokens,
+ Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java
index 360b514d6d..ea48670b63 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java
@@ -34,7 +34,8 @@ public record OllamaChatModelConfig(
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
@PositiveOrZero Integer topK,
- @Positive Integer maxOutputTokens,
+ Integer contextLength,
+ Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java
index 00b5115d7d..23db9accc2 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java
@@ -35,7 +35,7 @@ public record OpenAiChatModelConfig(
@Positive @Max(1) Double topP,
Double frequencyPenalty,
Double presencePenalty,
- @Positive Integer maxOutputTokens,
+ Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig {
diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html
index 1a3c2bf181..c730850474 100644
--- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html
+++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html
@@ -151,7 +151,7 @@
}
@if (providerFieldsList.includes('baseUrl')) {
-
+
ai-models.baseurl
@@ -264,15 +264,18 @@
-
- warning
-
+ type="number" step="1" placeholder="{{ 'ai-models.set' | translate }}">
+
+
+ }
+ @if (modelFieldsList.includes('contextLength')) {
+
}
diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts
index e6490cd84d..3294c6ac76 100644
--- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts
+++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts
@@ -108,7 +108,8 @@ export class AIModelDialogComponent extends DialogComponent, 'label'>, HasTenantId
frequencyPenalty?: number;
presencePenalty?: number;
maxOutputTokens?: number;
+ contextLength?: number;
}
}
@@ -91,7 +92,7 @@ export const ProviderFieldsAllList = [
'baseUrl'
];
-export const ModelFieldsAllList = ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'];
+export const ModelFieldsAllList = ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens', 'contextLength'];
export const AiModelMap = new Map([
[
@@ -200,7 +201,7 @@ export const AiModelMap = new Map
Date: Mon, 22 Sep 2025 12:18:06 +0300
Subject: [PATCH 09/31] AI models: add auth support for Ollama
---
.../Langchain4jChatModelConfigurerImpl.java | 28 +++++++++++++---
.../ai/provider/OllamaProviderConfig.java | 32 +++++++++++++++++--
2 files changed, 53 insertions(+), 7 deletions(-)
diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
index 84b09b9188..2cb6c2097f 100644
--- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
+++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
@@ -35,6 +35,7 @@ import dev.langchain4j.model.mistralai.MistralAiChatModel;
import dev.langchain4j.model.ollama.OllamaChatModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel;
+import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
@@ -49,6 +50,7 @@ import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig;
import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
@@ -56,7 +58,11 @@ import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient;
import java.io.ByteArrayInputStream;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
+import java.util.Base64;
+
+import static java.util.Collections.singletonMap;
@Component
class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigurer {
@@ -136,7 +142,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
// set request timeout from model config
if (chatModelConfig.timeoutSeconds() != null) {
- retrySettings.setTotalTimeout(org.threeten.bp.Duration.ofSeconds(chatModelConfig.timeoutSeconds()));
+ retrySettings.setTotalTimeoutDuration(Duration.ofSeconds(chatModelConfig.timeoutSeconds()));
}
// set updated retry settings
@@ -266,7 +272,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) {
- return OllamaChatModel.builder()
+ var builder = OllamaChatModel.builder()
.baseUrl(chatModelConfig.providerConfig().baseUrl())
.modelName(chatModelConfig.modelId())
.temperature(chatModelConfig.temperature())
@@ -275,8 +281,22 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
.numCtx(chatModelConfig.contextLength())
.numPredict(chatModelConfig.maxOutputTokens())
.timeout(toDuration(chatModelConfig.timeoutSeconds()))
- .maxRetries(chatModelConfig.maxRetries())
- .build();
+ .maxRetries(chatModelConfig.maxRetries());
+
+ var auth = chatModelConfig.providerConfig().auth();
+ if (auth instanceof OllamaProviderConfig.OllamaAuth.Basic basicAuth) {
+ String credentials = basicAuth.username() + ":" + basicAuth.password();
+ String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
+ builder.customHeaders(singletonMap(HttpHeaders.AUTHORIZATION, "Basic " + encodedCredentials));
+ } else if (auth instanceof OllamaProviderConfig.OllamaAuth.Token tokenAuth) {
+ builder.customHeaders(singletonMap(HttpHeaders.AUTHORIZATION, "Bearer " + tokenAuth.token()));
+ } else if (auth instanceof OllamaProviderConfig.OllamaAuth.None) {
+ // do nothing
+ } else {
+ throw new UnsupportedOperationException("Unknown authentication type: " + auth.getClass().getSimpleName());
+ }
+
+ return builder.build();
}
private static Duration toDuration(Integer timeoutSeconds) {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java
index fc0a2d6fd8..39bb57834c 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java
@@ -15,8 +15,34 @@
*/
package org.thingsboard.server.common.data.ai.provider;
-import jakarta.validation.constraints.NotBlank;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
public record OllamaProviderConfig(
- @NotBlank String baseUrl
-) implements AiProviderConfig {}
+ @NotNull String baseUrl,
+ @NotNull @Valid OllamaAuth auth
+) implements AiProviderConfig {
+
+ @JsonTypeInfo(
+ use = JsonTypeInfo.Id.NAME,
+ include = JsonTypeInfo.As.PROPERTY,
+ property = "type"
+ )
+ @JsonSubTypes({
+ @JsonSubTypes.Type(value = OllamaAuth.None.class, name = "NONE"),
+ @JsonSubTypes.Type(value = OllamaAuth.Basic.class, name = "BASIC"),
+ @JsonSubTypes.Type(value = OllamaAuth.Token.class, name = "TOKEN")
+ })
+ public sealed interface OllamaAuth {
+
+ record None() implements OllamaAuth {}
+
+ record Basic(@NotNull String username, @NotNull String password) implements OllamaAuth {}
+
+ record Token(@NotNull String token) implements OllamaAuth {}
+
+ }
+
+}
From 329534df61f80eee17e6536cec449dd08028a8ea Mon Sep 17 00:00:00 2001
From: ArtemDzhereleiko
Date: Thu, 25 Sep 2025 15:44:50 +0300
Subject: [PATCH 10/31] UI: Add authentication for Ollama model
---
.../ai-model/ai-model-dialog.component.html | 87 +++++++++++++++----
.../ai-model/ai-model-dialog.component.ts | 67 +++++++++++---
.../src/app/shared/models/ai-model.models.ts | 11 +++
.../assets/locale/locale.constant-en_US.json | 16 +++-
4 files changed, 153 insertions(+), 28 deletions(-)
diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html
index c730850474..abfe8500b4 100644
--- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html
+++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html
@@ -55,31 +55,34 @@
-
diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts
index 552c4f1f71..9d1de180e9 100644
--- a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts
+++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts
@@ -14,7 +14,18 @@
/// limitations under the License.
///
-import { Component, ElementRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core';
+import {
+ Component,
+ ElementRef,
+ EventEmitter,
+ forwardRef,
+ Input,
+ OnChanges,
+ OnInit,
+ Output,
+ SimpleChanges,
+ ViewChild
+} from '@angular/core';
import {
ControlValueAccessor,
NG_VALIDATORS,
@@ -93,6 +104,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan
}
@Input()
+ @coerceBoolean()
disabled: boolean;
@Input()
@@ -109,6 +121,13 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan
@coerceBoolean()
inlineField: boolean;
+ @Input()
+ @coerceBoolean()
+ allowCreateNew: boolean;
+
+ @Output()
+ createNew = new EventEmitter();
+
@ViewChild('entityInput') entityInput: ElementRef;
@ViewChild('entityAutocomplete') matAutocomplete: MatAutocomplete;
@ViewChild('chipList', {static: true}) chipList: MatChipGrid;
@@ -136,6 +155,11 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan
this.entityListFormGroup.get('entities').updateValueAndValidity();
}
+ createNewEntity($event: Event, searchText?: string) {
+ $event.stopPropagation();
+ this.createNew.emit(searchText);
+ }
+
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
@@ -201,6 +225,9 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan
this.modelValue = null;
}
this.dirty = true;
+ if (this.entityInput) {
+ this.entityInput.nativeElement.value = '';
+ }
}
validate(): ValidationErrors | null {
diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts
index 0419e40a7c..3495bf9eac 100644
--- a/ui-ngx/src/app/shared/models/resource.models.ts
+++ b/ui-ngx/src/app/shared/models/resource.models.ts
@@ -24,7 +24,8 @@ export enum ResourceType {
LWM2M_MODEL = 'LWM2M_MODEL',
PKCS_12 = 'PKCS_12',
JKS = 'JKS',
- JS_MODULE = 'JS_MODULE'
+ JS_MODULE = 'JS_MODULE',
+ TEXT = 'TEXT',
}
export enum ResourceSubType {
@@ -57,7 +58,8 @@ export const ResourceTypeTranslationMap = new Map(
[ResourceType.LWM2M_MODEL, 'resource.type.lwm2m-model'],
[ResourceType.PKCS_12, 'resource.type.pkcs-12'],
[ResourceType.JKS, 'resource.type.jks'],
- [ResourceType.JS_MODULE, 'resource.type.js-module']
+ [ResourceType.JS_MODULE, 'resource.type.js-module'],
+ [ResourceType.TEXT, 'resource.type.text'],
]
);
@@ -76,8 +78,8 @@ export interface TbResourceInfo extends Omit, 'name' |
title?: string;
resourceType: ResourceType;
resourceSubType?: ResourceSubType;
- fileName: string;
- public: boolean;
+ fileName?: string;
+ public?: boolean;
publicResourceKey?: string;
readonly link?: string;
readonly publicLink?: string;
@@ -87,7 +89,7 @@ export interface TbResourceInfo extends Omit, 'name' |
export type ResourceInfo = TbResourceInfo;
export interface Resource extends ResourceInfo {
- data: string;
+ data?: string;
name?: string;
}
diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json
index 80328181f6..d4bfd4ea06 100644
--- a/ui-ngx/src/assets/locale/locale.constant-en_US.json
+++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json
@@ -4488,7 +4488,8 @@
"jks": "JKS",
"js-module": "JS module",
"lwm2m-model": "LWM2M model",
- "pkcs-12": "PKCS #12"
+ "pkcs-12": "PKCS #12",
+ "text": "Text"
},
"resource-sub-type": "Sub-type",
"sub-type": {
@@ -5467,7 +5468,8 @@
"timeout-required": "Timeout is required",
"timeout-validation": "Must be from 1 second to 10 minutes.",
"force-acknowledgement": "Force acknowledgement",
- "force-acknowledgement-hint": "If enabled, the incoming message is acknowledged immediately. The model's response is then enqueued as a separate, new message."
+ "force-acknowledgement-hint": "If enabled, the incoming message is acknowledged immediately. The model's response is then enqueued as a separate, new message.",
+ "ai-resources": "AI resources"
}
},
"timezone": {
From cd6063fd097e8c2db83b87e392011ad34866065c Mon Sep 17 00:00:00 2001
From: ArtemDzhereleiko
Date: Wed, 10 Sep 2025 09:41:47 +0300
Subject: [PATCH 12/31] UI: General resources
---
ui-ngx/src/app/core/http/entity.service.ts | 6 +-
ui-ngx/src/app/core/http/resource.service.ts | 14 +++--
.../resources/resources-dialog.component.html | 4 +-
.../resources/resources-dialog.component.ts | 3 +
.../resources-library.component.html | 61 ++++++++++---------
.../resources/resources-library.component.ts | 13 +++-
.../external/ai-config.component.html | 2 +-
.../rule-node/external/ai-config.component.ts | 2 +-
.../resources-table-header.component.ts | 2 +-
.../shared/components/file-input.component.ts | 14 ++++-
.../src/app/shared/models/resource.models.ts | 4 +-
.../assets/locale/locale.constant-en_US.json | 2 +-
12 files changed, 77 insertions(+), 50 deletions(-)
diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts
index 53a55bfe7f..c652ba39ba 100644
--- a/ui-ngx/src/app/core/http/entity.service.ts
+++ b/ui-ngx/src/app/core/http/entity.service.ts
@@ -299,9 +299,7 @@ export class EntityService {
entityIds);
break;
case EntityType.TB_RESOURCE:
- observable = this.getEntitiesByIdsObservable(
- (id) => this.resourceService.getResource(id, config),
- entityIds);
+ observable = this.resourceService.getResourcesByIds(entityIds, config);
break;
}
return observable;
@@ -478,7 +476,7 @@ export class EntityService {
break;
case EntityType.TB_RESOURCE:
pageLink.sortOrder.property = 'title';
- entitiesObservable = this.resourceService.getTenantResources(pageLink, subType as ResourceType, config);
+ entitiesObservable = this.resourceService.getResources(pageLink, subType as ResourceType, null, config);
break;
case EntityType.QUEUE_STATS:
pageLink.sortOrder.property = 'createdTime';
diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts
index 52c92d96e4..90335758dd 100644
--- a/ui-ngx/src/app/core/http/resource.service.ts
+++ b/ui-ngx/src/app/core/http/resource.service.ts
@@ -24,6 +24,7 @@ import { Resource, ResourceInfo, ResourceSubType, ResourceType, TBResourceScope
import { catchError, mergeMap } from 'rxjs/operators';
import { isNotEmptyStr } from '@core/utils';
import { ResourcesService } from '@core/services/resources.service';
+import { NotificationTarget } from "@shared/models/notification.models";
@Injectable({
providedIn: 'root'
@@ -47,12 +48,8 @@ export class ResourceService {
return this.http.get>(url, defaultHttpOptionsFromConfig(config));
}
- public getTenantResources(pageLink: PageLink, resourceType?: ResourceType, config?: RequestConfig): Observable> {
- let url = `/api/resource${pageLink.toQuery()}`;
- if (isNotEmptyStr(resourceType)) {
- url += `&resourceType=${resourceType}`;
- }
- return this.http.get>(url, defaultHttpOptionsFromConfig(config));
+ public getTenantResources(pageLink: PageLink, config?: RequestConfig): Observable> {
+ return this.http.get>(`/api/resource/tenant${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config))
}
public getResource(resourceId: string, config?: RequestConfig): Observable {
@@ -98,4 +95,9 @@ export class ResourceService {
return this.http.delete(`/api/resource/${resourceId}?force=${force}`, defaultHttpOptionsFromConfig(config));
}
+ public getResourcesByIds(ids: string[], config?: RequestConfig): Observable> {
+ return this.http.get>(`/api/resource?resourceIds=${ids.join(',')}`,
+ defaultHttpOptionsFromConfig(config));
+ }
+
}
diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html
index c6813ff0f2..0062cfa9ac 100644
--- a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html
+++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html
@@ -32,8 +32,8 @@
diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts
index a06c72827a..6216f087b1 100644
--- a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts
+++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts
@@ -106,6 +106,9 @@ export class ResourcesDialogComponent extends DialogComponent response[0])
).subscribe(result => this.dialogRef.close(result));
} else {
+ if (resource.resourceType !== ResourceType.GENERAL) {
+ delete resource.descriptor;
+ }
this.resourceService.saveResource(resource).subscribe(result => this.dialogRef.close(result));
}
}
diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html
index c602906be1..4737b75ef2 100644
--- a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html
+++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html
@@ -48,14 +48,16 @@
diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts
index 0b50e45a7a..e3ad0e15f3 100644
--- a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts
+++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts
@@ -44,7 +44,7 @@ export class ResourcesLibraryComponent extends EntityComponent impleme
standalone = false;
@Input()
- resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.TEXT];
+ resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.GENERAL];
@Input()
defaultResourceType = ResourceType.LWM2M_MODEL;
@@ -90,10 +90,19 @@ export class ResourcesLibraryComponent extends EntityComponent impleme
title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]],
resourceType: [entity?.resourceType ? entity.resourceType : ResourceType.LWM2M_MODEL, Validators.required],
fileName: [entity ? entity.fileName : null, Validators.required],
- data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []]
+ data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []],
+ descriptor: this.fb.group({
+ mediaType: ['']
+ })
});
}
+ mediaTypeChange(mediaType: string): void {
+ if (this.entityForm.get('resourceType').value === ResourceType.GENERAL) {
+ this.entityForm.get('descriptor').get('mediaType').patchValue(mediaType);
+ }
+ }
+
updateForm(entity: Resource): void {
this.entityForm.patchValue(entity);
}
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html
index d259d57ef3..5381fc770b 100644
--- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html
+++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html
@@ -76,7 +76,7 @@
placeholderText="{{ 'rule-node-config.ai.ai-resources' | translate }}"
[inlineField]="true"
[entityType]="EntityType.TB_RESOURCE"
- [subType]="ResourceType.TEXT"
+ [subType]="ResourceType.GENERAL"
(createNew)="createAiResources($event, 'resourceIds')"
formControlName="resourceIds">
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts
index a4d973e998..07bcb86fdf 100644
--- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts
+++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts
@@ -129,7 +129,7 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
- resources: {title: name, resourceType: ResourceType.TEXT},
+ resources: {title: name, resourceType: ResourceType.GENERAL},
isAdd: true
}
}).afterClosed()
diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts
index d4b5d18493..136c143e3c 100644
--- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts
+++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts
@@ -28,7 +28,7 @@ import { PageLink } from '@shared/models/page/page-link';
})
export class ResourcesTableHeaderComponent extends EntityTableHeaderComponent {
- readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.TEXT];
+ readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.GENERAL];
readonly resourceTypesTranslationMap = ResourceTypeTranslationMap;
constructor(protected store: Store) {
diff --git a/ui-ngx/src/app/shared/components/file-input.component.ts b/ui-ngx/src/app/shared/components/file-input.component.ts
index bbe68bb9c6..6960db73dd 100644
--- a/ui-ngx/src/app/shared/components/file-input.component.ts
+++ b/ui-ngx/src/app/shared/components/file-input.component.ts
@@ -129,10 +129,15 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
@Output()
fileNameChanged = new EventEmitter();
+ @Output()
+ mediaTypeChanged = new EventEmitter();
+
fileName: string | string[];
fileContent: any;
files: File[];
+ mediaType: string;
+
@ViewChild('flow', {static: true})
flow: FlowDirective;
@@ -180,6 +185,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
this.fileContent = files[0].fileContent;
this.fileName = files[0].fileName;
this.files = files[0].files;
+ this.mediaType = files[0].mediaType;
this.updateModel();
} else if (files.length > 1) {
this.fileContent = files.map(content => content.fileContent);
@@ -203,6 +209,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
let fileName = null;
let fileContent = null;
let files = null;
+ let mediaType = null;
if (reader.readyState === reader.DONE) {
if (!this.workFromFileObj) {
fileContent = reader.result;
@@ -211,16 +218,18 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
fileContent = this.contentConvertFunction(fileContent);
}
fileName = fileContent ? file.name : null;
+ mediaType = file?.file?.type || null;
}
} else if (file.name || file.file){
files = file.file;
fileName = file.name;
+ mediaType = file.file.type || null;
}
}
- resolve({fileContent, fileName, files});
+ resolve({fileContent, fileName, files, mediaType});
};
reader.onerror = () => {
- resolve({fileContent: null, fileName: null, files: null});
+ resolve({fileContent: null, fileName: null, files: null, mediaType: null});
};
if (this.readAsBinary) {
reader.readAsBinaryString(file.file);
@@ -283,6 +292,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
this.propagateChange(this.files);
} else {
this.propagateChange(this.fileContent);
+ this.mediaTypeChanged.emit(this.mediaType);
this.fileNameChanged.emit(this.fileName);
}
}
diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts
index 3495bf9eac..12590e7608 100644
--- a/ui-ngx/src/app/shared/models/resource.models.ts
+++ b/ui-ngx/src/app/shared/models/resource.models.ts
@@ -25,7 +25,7 @@ export enum ResourceType {
PKCS_12 = 'PKCS_12',
JKS = 'JKS',
JS_MODULE = 'JS_MODULE',
- TEXT = 'TEXT',
+ GENERAL = 'GENERAL',
}
export enum ResourceSubType {
@@ -59,7 +59,7 @@ export const ResourceTypeTranslationMap = new Map(
[ResourceType.PKCS_12, 'resource.type.pkcs-12'],
[ResourceType.JKS, 'resource.type.jks'],
[ResourceType.JS_MODULE, 'resource.type.js-module'],
- [ResourceType.TEXT, 'resource.type.text'],
+ [ResourceType.GENERAL, 'resource.type.general'],
]
);
diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json
index d4bfd4ea06..d79bb81ae5 100644
--- a/ui-ngx/src/assets/locale/locale.constant-en_US.json
+++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json
@@ -4489,7 +4489,7 @@
"js-module": "JS module",
"lwm2m-model": "LWM2M model",
"pkcs-12": "PKCS #12",
- "text": "Text"
+ "general": "General"
},
"resource-sub-type": "Sub-type",
"sub-type": {
From caf90636382db5418dd59703c52ceed0a0aaf0e5 Mon Sep 17 00:00:00 2001
From: ArtemDzhereleiko
Date: Fri, 12 Sep 2025 16:23:53 +0300
Subject: [PATCH 13/31] UI: Add resources in use dialog with force to delete
---
.../resources-library-table-config.resolve.ts | 180 +++++++++++++++++-
.../assets/locale/locale.constant-en_US.json | 7 +-
2 files changed, 177 insertions(+), 10 deletions(-)
diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts
index dc85ca4914..d92355fbac 100644
--- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts
+++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts
@@ -22,7 +22,13 @@ import {
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
import { Router } from '@angular/router';
-import { Resource, ResourceInfo, ResourceType, ResourceTypeTranslationMap } from '@shared/models/resource.models';
+import {
+ Resource,
+ ResourceInfo, ResourceInfoWithReferences,
+ ResourceType,
+ ResourceTypeTranslationMap,
+ toResourceDeleteResult
+} from '@shared/models/resource.models';
import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models';
import { NULL_UUID } from '@shared/models/id/has-uuid';
import { DatePipe } from '@angular/common';
@@ -35,9 +41,19 @@ import { Authority } from '@shared/models/authority.enum';
import { ResourcesLibraryComponent } from '@home/components/resources/resources-library.component';
import { PageLink } from '@shared/models/page/page-link';
import { EntityAction } from '@home/models/entity/entity-component.models';
-import { map } from 'rxjs/operators';
+import { catchError, map } from 'rxjs/operators';
import { ResourcesTableHeaderComponent } from '@home/pages/admin/resource/resources-table-header.component';
import { ResourceLibraryTabsComponent } from '@home/pages/admin/resource/resource-library-tabs.component';
+import { forkJoin, of } from "rxjs";
+import {
+ ResourcesInUseDialogComponent,
+ ResourcesInUseDialogData
+} from "@shared/components/resource/resources-in-use-dialog.component";
+import { parseHttpErrorMessage } from "@core/utils";
+import { ActionNotificationShow } from "@core/notification/notification.actions";
+import { ResourcesDatasource } from "@home/pages/admin/resource/resources-datasource";
+import { MatDialog } from "@angular/material/dialog";
+import { DialogService } from "@core/services/dialog.service";
@Injectable()
export class ResourcesLibraryTableConfigResolver {
@@ -49,6 +65,8 @@ export class ResourcesLibraryTableConfigResolver {
private resourceService: ResourceService,
private translate: TranslateService,
private router: Router,
+ private dialog: MatDialog,
+ private dialogService: DialogService,
private datePipe: DatePipe) {
this.config.entityType = EntityType.TB_RESOURCE;
@@ -76,19 +94,27 @@ export class ResourcesLibraryTableConfigResolver {
icon: 'file_download',
isEnabled: () => true,
onAction: ($event, entity) => this.downloadResource($event, entity)
- }
+ },
+ {
+ name: this.translate.instant('resource.delete'),
+ icon: 'delete',
+ isEnabled: (resource) => this.config.deleteEnabled(resource),
+ onAction: ($event, entity) => this.deleteResource($event, entity)
+ },
);
- this.config.deleteEntityTitle = resource => this.translate.instant('resource.delete-resource-title',
- { resourceTitle: resource.title });
- this.config.deleteEntityContent = () => this.translate.instant('resource.delete-resource-text');
- this.config.deleteEntitiesTitle = count => this.translate.instant('resource.delete-resources-title', {count});
- this.config.deleteEntitiesContent = () => this.translate.instant('resource.delete-resources-text');
+ this.config.groupActionDescriptors = [{
+ name: this.translate.instant('action.delete'),
+ icon: 'delete',
+ isEnabled: true,
+ onAction: ($event, entities) => this.deleteResources($event, entities)
+ }];
+
+ this.config.entitiesDeleteEnabled = false;
this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink, this.config.componentsData.resourceType);
this.config.loadEntity = id => this.resourceService.getResourceInfoById(id.id);
this.config.saveEntity = resource => this.saveResource(resource);
- this.config.deleteEntity = id => this.resourceService.deleteResource(id.id);
this.config.onEntityAction = action => this.onResourceAction(action);
}
@@ -147,6 +173,8 @@ export class ResourcesLibraryTableConfigResolver {
case 'downloadResource':
this.downloadResource(action.event, action.entity);
return true;
+ case 'deleteLibrary':
+ this.deleteResource(action.event, action.entity);
}
return false;
}
@@ -165,4 +193,138 @@ export class ResourcesLibraryTableConfigResolver {
return authority === Authority.SYS_ADMIN;
}
}
+
+ private deleteResource($event: Event, resource: ResourceInfo) {
+ if ($event) {
+ $event.stopPropagation();
+ }
+ this.dialogService.confirm(
+ this.translate.instant('resource.delete-resource-title', { resourceTitle: resource.title }),
+ this.translate.instant('resource.delete-resource-text'),
+ this.translate.instant('action.no'),
+ this.translate.instant('action.yes'),
+ true
+ ).subscribe((result) => {
+ if (result) {
+ this.resourceService.deleteResource(resource.id.id, false, {ignoreErrors: true}).pipe(
+ map(() => toResourceDeleteResult(resource)),
+ catchError((err) => of(toResourceDeleteResult(resource, err)))
+ ).subscribe(
+ (deleteResult) => {
+ if (deleteResult.success) {
+ if (this.config.getEntityDetailsPage()) {
+ this.config.getEntityDetailsPage().goBack();
+ } else {
+ this.config.updateData(true);
+ }
+ } else if (deleteResult.resourceIsReferencedError) {
+ const resources: ResourceInfoWithReferences[] = [{...resource, ...{references: deleteResult.references}}];
+ const data = {
+ multiple: false,
+ resources,
+ configuration: {
+ title: 'resource.resource-is-in-use',
+ message: this.translate.instant('resource.resource-is-in-use-text', {title: resources[0].title}),
+ deleteText: 'resource.delete-resource-in-use-text',
+ selectedText: 'resource.selected-resources',
+ columns: ['select', 'title', 'references']
+ }
+ };
+ this.dialog.open(ResourcesInUseDialogComponent, {
+ disableClose: true,
+ panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
+ data
+ }).afterClosed().subscribe((resources) => {
+ if (resources) {
+ this.resourceService.deleteResource(resource.id.id, true).subscribe(() => {
+ if (this.config.getEntityDetailsPage()) {
+ this.config.getEntityDetailsPage().goBack();
+ } else {
+ this.config.updateData(true);
+ }
+ });
+ }
+ });
+ } else {
+ const errorMessageWithTimeout = parseHttpErrorMessage(deleteResult.error, this.translate);
+ setTimeout(() => {
+ this.store.dispatch(new ActionNotificationShow({message: errorMessageWithTimeout.message, type: 'error'}));
+ }, errorMessageWithTimeout.timeout);
+ }
+ }
+ );
+ }
+ });
+ }
+
+ private deleteResources($event: Event, resources: ResourceInfo[]) {
+ if ($event) {
+ $event.stopPropagation();
+ }
+ if (resources && resources.length) {
+ const title = this.translate.instant('resource.delete-resources-title', {count: resources.length});
+ const content = this.translate.instant('resource.delete-resources-text');
+ this.dialogService.confirm(title, content,
+ this.translate.instant('action.no'),
+ this.translate.instant('action.yes')).subscribe((result) => {
+ if (result) {
+ const tasks = resources.map((resource) =>
+ this.resourceService.deleteResource(resource.id.id, false, {ignoreErrors: true}).pipe(
+ map(() => toResourceDeleteResult(resource)),
+ catchError((err) => of(toResourceDeleteResult(resource, err)))
+ )
+ );
+ forkJoin(tasks).subscribe(
+ (deleteResults) => {
+ const anySuccess = deleteResults.some(res => res.success);
+ const referenceErrors = deleteResults.filter(res => res.resourceIsReferencedError);
+ const otherError = deleteResults.find(res => !res.success);
+ if (anySuccess) {
+ this.config.updateData();
+ }
+ if (referenceErrors?.length) {
+ const resourcesWithReferences: ResourceInfoWithReferences[] =
+ referenceErrors.map(ref => ({...ref.resource, ...{references: ref.references}}));
+ const data = {
+ multiple: true,
+ resources: resourcesWithReferences,
+ configuration: {
+ title: 'resource.resources-are-in-use',
+ message: this.translate.instant('resource.resources-are-in-use-text'),
+ deleteText: 'resource.delete-resource-in-use-text',
+ selectedText: 'resource.selected-resources',
+ datasource: new ResourcesDatasource(this.resourceService, resourcesWithReferences, () => true),
+ columns: ['select', 'title', 'references']
+ }
+ };
+ this.dialog.open(ResourcesInUseDialogComponent, {
+ disableClose: true,
+ panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
+ data
+ }).afterClosed().subscribe((forceDeleteResources) => {
+ if (forceDeleteResources && forceDeleteResources.length) {
+ const forceDeleteTasks = forceDeleteResources.map((resource) =>
+ this.resourceService.deleteResource(resource.id.id, true)
+ );
+ forkJoin(forceDeleteTasks).subscribe(
+ () => {
+ this.config.updateData();
+ }
+ );
+ }
+ });
+ } else if (otherError) {
+ const errorMessageWithTimeout = parseHttpErrorMessage(otherError.error, this.translate);
+ setTimeout(() => {
+ this.store.dispatch(new ActionNotificationShow({message: errorMessageWithTimeout.message, type: 'error'}));
+ }, errorMessageWithTimeout.timeout);
+ }
+ }
+ );
+ }
+ });
+ }
+ }
}
diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json
index d79bb81ae5..4639552658 100644
--- a/ui-ngx/src/assets/locale/locale.constant-en_US.json
+++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json
@@ -4497,7 +4497,12 @@
"scada-symbol": "Scada symbol",
"extension": "Extension",
"module": "Module"
- }
+ },
+ "resource-is-in-use": "Resource is used by other entities",
+ "resources-are-in-use": "Resources are used by other entities",
+ "resource-is-in-use-text": "The Resource '{{title}}' was not deleted because it is used by the following entities:",
+ "resources-are-in-use-text": "Not all Resources have been deleted because they are used by other entities.You can view referenced entities by clicking the References button in the corresponding resource row.If you still want to delete these resources, select them in the table below and click the Delete selected button.",
+ "delete-resource-in-use-text": "If you still want to delete the resource, click the Delete anyway button."
},
"javascript": {
"add": "Add JavaScript resource",
From af600ff450925466baa86461f33e98065d82af9b Mon Sep 17 00:00:00 2001
From: ArtemDzhereleiko
Date: Fri, 12 Sep 2025 16:46:38 +0300
Subject: [PATCH 14/31] UI: oprimize import
---
ui-ngx/src/app/core/http/resource.service.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts
index 90335758dd..168c63b3b1 100644
--- a/ui-ngx/src/app/core/http/resource.service.ts
+++ b/ui-ngx/src/app/core/http/resource.service.ts
@@ -24,7 +24,6 @@ import { Resource, ResourceInfo, ResourceSubType, ResourceType, TBResourceScope
import { catchError, mergeMap } from 'rxjs/operators';
import { isNotEmptyStr } from '@core/utils';
import { ResourcesService } from '@core/services/resources.service';
-import { NotificationTarget } from "@shared/models/notification.models";
@Injectable({
providedIn: 'root'
From e688a8af0f40c51400277320b2061d841c580b14 Mon Sep 17 00:00:00 2001
From: Dmytro Skarzhynets
Date: Mon, 29 Sep 2025 13:35:22 +0300
Subject: [PATCH 15/31] AI models: add base URL to OpenAI
---
.../Langchain4jChatModelConfigurerImpl.java | 1 +
.../controller/AiModelControllerTest.java | 12 ++++++--
.../ai/DefaultTbAiModelServiceTest.java | 2 +-
.../sql/BaseTbResourceServiceTest.java | 6 +++-
.../ai/provider/OpenAiProviderConfig.java | 30 +++++++++++++++++--
.../rule/engine/ai/TbAiNodeTest.java | 5 +++-
6 files changed, 47 insertions(+), 9 deletions(-)
diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
index 2cb6c2097f..c631f1a3d0 100644
--- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
+++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
@@ -70,6 +70,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) {
return OpenAiChatModel.builder()
+ .baseUrl(chatModelConfig.providerConfig().baseUrl())
.apiKey(chatModelConfig.providerConfig().apiKey())
.modelName(chatModelConfig.modelId())
.temperature(chatModelConfig.temperature())
diff --git a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
index ae2972b0cc..53648af441 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
@@ -104,7 +104,10 @@ public class AiModelControllerTest extends AbstractControllerTest {
var model = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class);
var newModelConfig = OpenAiChatModelConfig.builder()
- .providerConfig(new OpenAiProviderConfig("test-api-key-updated"))
+ .providerConfig(OpenAiProviderConfig.builder()
+ .baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL)
+ .apiKey("test-api-key-updated")
+ .build())
.modelId("o4-mini")
.temperature(0.2)
.topP(0.4)
@@ -270,7 +273,7 @@ public class AiModelControllerTest extends AbstractControllerTest {
.tenantId(tenantId)
.name("Test model 1")
.configuration(OpenAiChatModelConfig.builder()
- .providerConfig(new OpenAiProviderConfig("test-api-key"))
+ .providerConfig(OpenAiProviderConfig.builder().apiKey("test-api-key").build())
.modelId("o3-pro")
.build())
.build(), AiModel.class);
@@ -594,7 +597,10 @@ public class AiModelControllerTest extends AbstractControllerTest {
private AiModel constructValidOpenAiModel(String name) {
var modelConfig = OpenAiChatModelConfig.builder()
- .providerConfig(new OpenAiProviderConfig("test-api-key"))
+ .providerConfig(OpenAiProviderConfig.builder()
+ .baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL)
+ .apiKey("test-api-key")
+ .build())
.modelId("gpt-4o")
.temperature(0.5)
.topP(0.3)
diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java
index 2321446b44..b6be136f82 100644
--- a/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java
@@ -253,7 +253,7 @@ class DefaultTbAiModelServiceTest {
private static AiModelConfig constructValidOpenAiModelConfig() {
return OpenAiChatModelConfig.builder()
- .providerConfig(new OpenAiProviderConfig("test-api-key"))
+ .providerConfig(OpenAiProviderConfig.builder().apiKey("test-api-key").build())
.modelId("gpt-4o")
.temperature(0.5)
.topP(0.3)
diff --git a/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java b/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java
index da20b9489c..a146730465 100644
--- a/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java
@@ -682,7 +682,10 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest {
private AiModel constructValidOpenAiModel(String name) {
var modelConfig = OpenAiChatModelConfig.builder()
- .providerConfig(new OpenAiProviderConfig("test-api-key"))
+ .providerConfig(OpenAiProviderConfig.builder()
+ .baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL)
+ .apiKey("test-api-key")
+ .build())
.modelId("gpt-4o")
.temperature(0.5)
.topP(0.3)
@@ -699,6 +702,7 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest {
.configuration(modelConfig)
.build();
}
+
@Test
public void testFindTenantResourcesByTenantId() throws Exception {
loginSysAdmin();
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java
index 09ffda837b..f7864db6e3 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java
@@ -15,8 +15,32 @@
*/
package org.thingsboard.server.common.data.ai.provider;
-import jakarta.validation.constraints.NotNull;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import jakarta.validation.constraints.AssertTrue;
+import lombok.Builder;
+import org.apache.commons.lang3.StringUtils;
+import java.util.Objects;
+
+@Builder
public record OpenAiProviderConfig(
- @NotNull String apiKey
-) implements AiProviderConfig {}
+ String baseUrl,
+ String apiKey
+) implements AiProviderConfig {
+
+ public static final String OPENAI_OFFICIAL_BASE_URL = "https://api.openai.com/v1";
+
+ public OpenAiProviderConfig {
+ baseUrl = Objects.requireNonNullElse(baseUrl, OPENAI_OFFICIAL_BASE_URL);
+ }
+
+ @JsonIgnore
+ @AssertTrue(message = "API key is required when using the official OpenAI API")
+ public boolean isValid() {
+ if (baseUrl.equals(OPENAI_OFFICIAL_BASE_URL)) {
+ return StringUtils.isNotBlank(apiKey);
+ }
+ return true;
+ }
+
+}
diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java
index c5b7f2c44b..a786cdd536 100644
--- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java
+++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java
@@ -129,7 +129,10 @@ class TbAiNodeTest {
config = new TbAiNodeConfiguration();
modelConfig = OpenAiChatModelConfig.builder()
- .providerConfig(new OpenAiProviderConfig("test-api-key"))
+ .providerConfig(OpenAiProviderConfig.builder()
+ .baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL)
+ .apiKey("test-api-key")
+ .build())
.modelId("gpt-4o")
.temperature(0.5)
.topP(0.3)
From 22dc482b9ee43672ad99138833929de1e5c9aa33 Mon Sep 17 00:00:00 2001
From: ArtemDzhereleiko
Date: Tue, 30 Sep 2025 09:47:30 +0300
Subject: [PATCH 16/31] UI: Add baseUrl for openAI provider
---
.../ai-model/ai-model-dialog.component.html | 24 +++++++++---------
.../ai-model/ai-model-dialog.component.ts | 25 ++++++++++++++++++-
.../src/app/shared/models/ai-model.models.ts | 2 +-
.../assets/locale/locale.constant-en_US.json | 1 +
4 files changed, 38 insertions(+), 14 deletions(-)
diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html
index abfe8500b4..5aa323fe7d 100644
--- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html
+++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html
@@ -116,14 +116,24 @@
}
+ @if (providerFieldsList.includes('baseUrl')) {
+
+ ai-models.baseurl
+
+
+ {{ 'ai-models.baseurl-required' | translate }}
+
+
+ }
@if (providerFieldsList.includes('apiKey')) {
ai-models.api-key
-
+
- {{ 'ai-models.api-key-required' | translate }}
+ {{ ( provider === aiProvider.OPENAI ? 'ai-models.api-key-open-ai-required' : 'ai-models.api-key-required') | translate }}
}
@@ -158,16 +168,6 @@
}
- @if (providerFieldsList.includes('baseUrl')) {
-
- ai-models.baseurl
-
-
- {{ 'ai-models.baseurl-required' | translate }}
-
-
- }
@if (provider === aiProvider.OLLAMA) {