From 896a378f6f26303689061e70f13ef87fe41f5807 Mon Sep 17 00:00:00 2001 From: Paolo Cristiani <42511852+pgrisu@users.noreply.github.com> Date: Wed, 14 May 2025 18:11:20 +0200 Subject: [PATCH 01/11] Add show-total legend setting to latest-chart widgets --- .../basic/chart/latest-chart-basic-config.component.html | 3 +++ .../basic/chart/latest-chart-basic-config.component.ts | 4 ++++ .../components/widget/lib/chart/latest-chart.component.ts | 5 ++++- .../home/components/widget/lib/chart/latest-chart.models.ts | 2 ++ .../chart/latest-chart-widget-settings.component.html | 3 +++ .../settings/chart/latest-chart-widget-settings.component.ts | 3 +++ 6 files changed, 19 insertions(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html index 4a9569fb29..4601eaa4f9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html @@ -143,6 +143,9 @@ + + {{ '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 13:16:31 +0300 Subject: [PATCH 02/11] 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> 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 b9a9348eac7251d7e53b941c0caf6e945dfd82df Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Tue, 2 Sep 2025 14:30:09 +0300 Subject: [PATCH 03/11] UI: Add new resource type text --- ui-ngx/src/app/core/http/entity.service.ts | 8 +- ui-ngx/src/app/core/http/resource.service.ts | 8 +- .../home/components/home-components.module.ts | 6 + .../resources/resources-dialog.component.html | 54 +++++++++ .../resources/resources-dialog.component.scss | 24 ++++ .../resources/resources-dialog.component.ts | 113 ++++++++++++++++++ .../resources-library.component.html | 2 +- .../resources}/resources-library.component.ts | 18 ++- .../external/ai-config.component.html | 10 ++ .../rule-node/external/ai-config.component.ts | 24 ++++ .../modules/home/pages/admin/admin.module.ts | 2 - .../resources-library-table-config.resolve.ts | 2 +- .../resources-table-header.component.ts | 2 +- .../entity/entity-list.component.html | 11 ++ .../entity/entity-list.component.ts | 29 ++++- .../src/app/shared/models/resource.models.ts | 12 +- .../assets/locale/locale.constant-en_US.json | 6 +- 17 files changed, 310 insertions(+), 21 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts rename ui-ngx/src/app/modules/home/{pages/admin/resource => components/resources}/resources-library.component.html (98%) rename ui-ngx/src/app/modules/home/{pages/admin/resource => components/resources}/resources-library.component.ts (89%) diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 572d6cc473..53a55bfe7f 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -100,6 +100,7 @@ import { OAuth2Service } from '@core/http/oauth2.service'; import { MobileAppService } from '@core/http/mobile-app.service'; import { PlatformType } from '@shared/models/oauth2.models'; import { AiModelService } from '@core/http/ai-model.service'; +import { ResourceType } from "@shared/models/resource.models"; @Injectable({ providedIn: 'root' @@ -297,6 +298,11 @@ export class EntityService { (id) => this.ruleChainService.getRuleChain(id, config), entityIds); break; + case EntityType.TB_RESOURCE: + observable = this.getEntitiesByIdsObservable( + (id) => this.resourceService.getResource(id, config), + entityIds); + break; } return observable; } @@ -472,7 +478,7 @@ export class EntityService { break; case EntityType.TB_RESOURCE: pageLink.sortOrder.property = 'title'; - entitiesObservable = this.resourceService.getTenantResources(pageLink, config); + entitiesObservable = this.resourceService.getTenantResources(pageLink, subType as ResourceType, 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 615b721b97..52c92d96e4 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/ui-ngx/src/app/core/http/resource.service.ts @@ -47,8 +47,12 @@ export class ResourceService { 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 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 getResource(resourceId: string, config?: RequestConfig): Observable { diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 31a3066edf..060f804cdf 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -205,6 +205,8 @@ import { } from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component'; import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component'; import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialog.component'; +import { ResourcesDialogComponent } from "@home/components/resources/resources-dialog.component"; +import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component"; @NgModule({ declarations: @@ -358,6 +360,8 @@ import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialo CalculatedFieldTestArgumentsComponent, CheckConnectivityDialogComponent, AIModelDialogComponent, + ResourcesDialogComponent, + ResourcesLibraryComponent, ], imports: [ CommonModule, @@ -505,6 +509,8 @@ import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialo CalculatedFieldTestArgumentsComponent, CheckConnectivityDialogComponent, AIModelDialogComponent, + ResourcesDialogComponent, + ResourcesLibraryComponent, ], providers: [ WidgetComponentService, 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 new file mode 100644 index 0000000000..c6813ff0f2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html @@ -0,0 +1,54 @@ + +
+ +

{{ 'resource.add' | translate }}

+ + +
+ + +
+
+ + +
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss new file mode 100644 index 0000000000..b32c3933c5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss @@ -0,0 +1,24 @@ +/** + * 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. + */ + +:host ::ng-deep { + .mat-mdc-dialog-content { + display: flex; + flex-direction: column; + height: 100%; + padding: 0 !important; + } +} 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 new file mode 100644 index 0000000000..a06c72827a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts @@ -0,0 +1,113 @@ +/// +/// 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. +/// + +import { AfterViewInit, Component, Inject, SkipSelf, ViewChild } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms'; +import { EntityType } from '@shared/models/entity-type.models'; +import { map } from 'rxjs/operators'; +import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component"; +import { ErrorStateMatcher } from "@angular/material/core"; +import { Resource, ResourceType } from "@shared/models/resource.models"; +import { ResourceService } from "@core/http/resource.service"; + +export interface ResourcesDialogData { + resources?: Resource; + isAdd?: boolean; +} + +@Component({ + selector: 'tb-resources-dialog', + templateUrl: './resources-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: ResourcesDialogComponent}], + styleUrls: ['./resources-dialog.component.scss'] +}) +export class ResourcesDialogComponent extends DialogComponent implements ErrorStateMatcher, AfterViewInit { + + readonly entityType = EntityType; + + ResourceType = ResourceType; + + isAdd = false; + + submitted = false; + + resources: Resource; + + @ViewChild('resourcesComponent', {static: true}) resourcesComponent: ResourcesLibraryComponent; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ResourcesDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + private resourceService: ResourceService) { + super(store, router, dialogRef); + + if (this.data.isAdd) { + this.isAdd = true; + } + + if (this.data.resources) { + this.resources = this.data.resources; + } + } + + ngAfterViewInit(): void { + if (this.isAdd) { + setTimeout(() => { + this.resourcesComponent.entityForm.markAsDirty(); + }, 0); + } + } + + isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.submitted = true; + if (this.resourcesComponent.entityForm.valid) { + const resource = {...this.resourcesComponent.entityFormValue()}; + if (Array.isArray(resource.data)) { + const resources = []; + resource.data.forEach((data, index) => { + resources.push({ + resourceType: resource.resourceType, + data, + fileName: resource.fileName[index], + title: resource.title + }); + }); + this.resourceService.saveResources(resources, {resendRequest: true}).pipe( + map((response) => response[0]) + ).subscribe(result => this.dialogRef.close(result)); + } else { + this.resourceService.saveResource(resource).subscribe(result => this.dialogRef.close(result)); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html similarity index 98% rename from ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html rename to ui-ngx/src/app/modules/home/components/resources/resources-library.component.html index ebb946ccbc..c602906be1 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html +++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
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 bfba25afa1..a4d973e998 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 @@ -24,6 +24,8 @@ import { AiModel, AiRuleNodeResponseFormatTypeOnlyText, ResponseFormat } from '@ import { deepTrim } from '@core/utils'; import { TranslateService } from '@ngx-translate/core'; import { jsonRequired } from '@shared/components/json-object-edit.component'; +import { Resource, ResourceType } from "@shared/models/resource.models"; +import { ResourcesDialogComponent, ResourcesDialogData } from "@home/components/resources/resources-dialog.component"; @Component({ selector: 'tb-external-node-ai-config', @@ -38,6 +40,9 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { responseFormat = ResponseFormat; + EntityType = EntityType; + ResourceType = ResourceType; + constructor(private fb: UntypedFormBuilder, private translate: TranslateService, private dialog: MatDialog) { @@ -53,6 +58,7 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { modelId: [configuration?.modelId ?? null, [Validators.required]], systemPrompt: [configuration?.systemPrompt ?? '', [Validators.maxLength(500_000), Validators.pattern(/.*\S.*/)]], userPrompt: [configuration?.userPrompt ?? '', [Validators.required, Validators.maxLength(500_000), Validators.pattern(/.*\S.*/)]], + resourceIds: [configuration?.resourceIds ?? []], responseFormat: this.fb.group({ type: [configuration?.responseFormat?.type ?? ResponseFormat.JSON, []], schema: [configuration?.responseFormat?.schema ?? null, [jsonRequired]], @@ -116,5 +122,23 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { this.aiConfigForm.get(formControl).markAsDirty(); } }); + }; + + createAiResources(name: string, formControl: string) { + this.dialog.open(ResourcesDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + resources: {title: name, resourceType: ResourceType.TEXT}, + isAdd: true + } + }).afterClosed() + .subscribe((resource) => { + if (resource) { + const resourceIds = [...(this.aiConfigForm.get(formControl).value || []), resource.id.id]; + this.aiConfigForm.get(formControl).patchValue(resourceIds); + this.aiConfigForm.get(formControl).markAsDirty(); + } + }); } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts index 10721ade5a..60790edd74 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts @@ -26,7 +26,6 @@ import { HomeComponentsModule } from '@modules/home/components/home-components.m import { SmsProviderComponent } from '@home/pages/admin/sms-provider.component'; import { SendTestSmsDialogComponent } from '@home/pages/admin/send-test-sms-dialog.component'; import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component'; -import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component'; import { ResourceTabsComponent } from '@home/pages/admin/resource/resource-tabs.component'; import { ResourcesTableHeaderComponent } from '@home/pages/admin/resource/resources-table-header.component'; import { QueueComponent } from '@home/pages/admin/queue/queue.component'; @@ -49,7 +48,6 @@ import { ResourceLibraryTabsComponent } from '@home/pages/admin/resource/resourc SendTestSmsDialogComponent, SecuritySettingsComponent, HomeSettingsComponent, - ResourcesLibraryComponent, ResourceTabsComponent, ResourceLibraryTabsComponent, ResourcesTableHeaderComponent, 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 f39ed9ff7b..dc85ca4914 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 @@ -32,7 +32,7 @@ import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { Authority } from '@shared/models/authority.enum'; -import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component'; +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'; 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 80c760c6ca..d4b5d18493 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]; + readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.TEXT]; readonly resourceTypesTranslationMap = ResourceTypeTranslationMap; constructor(protected store: Store) { diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-list.component.html index 6bf7cdb78d..e0e7dfe3f7 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.html @@ -40,6 +40,12 @@ [matAutocompleteConnectedTo]="origin" [matAutocomplete]="entityAutocomplete" [matChipInputFor]="chipList"> + {{ 'entity.no-entities-matching' | translate: {entity: searchText} }} + @if (allowCreateNew) { + + entity.create-new-key + + } 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 055a1ae56d0361e9356a2a08bc394cfa20f04473 Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Wed, 10 Sep 2025 09:41:47 +0300 Subject: [PATCH 04/11] 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 @@
- - resource.resource-type - - - {{ resourceTypesTranslationMap.get(resourceType) | translate }} - - - + @if (resourceTypes.length > 1) { + + resource.resource-type + + + {{ resourceTypesTranslationMap.get(resourceType) | translate }} + + + + } resource.title @@ -66,26 +68,29 @@ {{ 'resource.title-max-length' | translate }} - - -
- - resource.file-name - - -
+ @if (isAdd || ((isAdd || isEdit) && entityForm.get('resourceType').value === resourceType.GENERAL)) { + + + } @else { +
+ + resource.file-name + + +
+ }
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 8a561456b40fba456e0cac5649f0492e0b3b908a Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Fri, 12 Sep 2025 16:23:53 +0300 Subject: [PATCH 05/11] 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 e88114de720bf8171500fa0031cea84b0e7f64a3 Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Fri, 12 Sep 2025 16:46:38 +0300 Subject: [PATCH 06/11] 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 5c7f20a1514124cafbd9f26f6a94adc0fac516b5 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 15 Sep 2025 17:32:57 +0300 Subject: [PATCH 07/11] 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/11] 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')) { +
+
+ {{ 'ai-models.context-length' | 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 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/11] 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 8a6015f04e7b2b4471f9d801517c9e40da6ef0d3 Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Thu, 25 Sep 2025 15:44:50 +0300 Subject: [PATCH 10/11] 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 @@
-
+
@if (providerFieldsList.includes('personalAccessToken')) { ai-models.personal-access-token - + {{ 'ai-models.personal-access-token-required' | translate }} } @if (providerFieldsList.includes('projectId')) { - + ai-models.project-id - + {{ 'ai-models.project-id-required' | translate }} } @if (providerFieldsList.includes('location')) { - + ai-models.location - + {{ 'ai-models.location-required' | translate }} @@ -98,16 +101,17 @@ } @if (providerFieldsList.includes('endpoint')) { - + ai-models.endpoint - + {{ 'ai-models.endpoint-required' | translate }} } @if (providerFieldsList.includes('serviceVersion')) { - + ai-models.service-version @@ -117,25 +121,28 @@ ai-models.api-key - + {{ 'ai-models.api-key-required' | translate }} } @if (providerFieldsList.includes('region')) { - + ai-models.region - + {{ 'ai-models.region-required' | translate }} } @if (providerFieldsList.includes('accessKeyId')) { - + ai-models.access-key-id - + {{ 'ai-models.access-key-id-required' | translate }} @@ -145,7 +152,8 @@ ai-models.secret-access-key - + {{ 'ai-models.secret-access-key-required' | translate }} @@ -154,11 +162,58 @@ ai-models.baseurl - + {{ 'ai-models.baseurl-required' | translate }} } + @if (provider === aiProvider.OLLAMA) { +
+
+
+ {{ 'ai-models.authentication' | translate }} +
+ + {{ 'ai-models.authentication-type.none' | translate }} + {{ 'ai-models.authentication-type.basic' | translate }} + {{ 'ai-models.authentication-type.token' | translate }} + +
+
+ @if (aiModelForms.get('configuration.providerConfig.auth.type').value === AuthenticationType.BASIC) { + + ai-models.username + + + {{ 'ai-models.username-required' | translate }} + + + + ai-models.password + + + + {{ 'ai-models.password-required' | translate }} + + + } + @if (aiModelForms.get('configuration.providerConfig.auth.type').value === AuthenticationType.TOKEN) { + + ai-models.token + + + + {{ 'ai-models.token-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 3294c6ac76..9d0d28e627 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 @@ -30,6 +30,7 @@ import { AiModelMap, AiProvider, AiProviderTranslations, + AuthenticationType, ModelType, ProviderFieldsAllList } from '@shared/models/ai-model.models'; @@ -37,6 +38,7 @@ import { AiModelService } from '@core/http/ai-model.service'; import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component'; import { map } from 'rxjs/operators'; import { deepTrim } from '@core/utils'; +import { TranslateService } from '@ngx-translate/core'; export interface AIModelDialogData { AIModel?: AiModel; @@ -62,18 +64,23 @@ export class AIModelDialogComponent extends DialogComponent, protected router: Router, protected dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: AIModelDialogData, private fb: FormBuilder, private aiModelService: AiModelService, + private translate: TranslateService, private dialog: MatDialog) { super(store, router, dialogRef); @@ -89,18 +96,24 @@ export class AIModelDialogComponent extends DialogComponent { + this.getAuthenticationHint(type); + this.aiModelForms.get('configuration.providerConfig.auth.username').disable(); + this.aiModelForms.get('configuration.providerConfig.auth.password').disable(); + this.aiModelForms.get('configuration.providerConfig.auth.token').disable(); + if (type === AuthenticationType.BASIC) { + this.aiModelForms.get('configuration.providerConfig.auth.username').enable(); + this.aiModelForms.get('configuration.providerConfig.auth.password').enable(); + } + if (type === AuthenticationType.TOKEN) { + this.aiModelForms.get('configuration.providerConfig.auth.token').enable(); + } + }); this.updateValidation(this.provider); } @@ -132,6 +161,16 @@ export class AIModelDialogComponent extends DialogComponent { if (AiModelMap.get(provider).providerFieldsList.includes(key)) { @@ -139,7 +178,13 @@ export class AIModelDialogComponent extends DialogComponent, 'label'>, HasTenantId accessKeyId?: string; secretAccessKey?: string; baseUrl?: string; + auth?: { + type: AuthenticationType; + username?: string; + password?: string; + token?: string + } }; modelId: string; temperature?: number; @@ -242,3 +248,8 @@ export interface CheckConnectivityResult { status: string; errorDetails: string; } +export enum AuthenticationType { + NONE = 'NONE', + BASIC = 'BASIC', + TOKEN = 'TOKEN' +} 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 c1acd62c00..d39078f56a 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1169,7 +1169,21 @@ "check-connectivity-failed": "Test request failed", "no-model-matching": "No models matching '{{entity}}' were found.", "model-required": "Model is required.", - "no-model-text": "No models found." + "no-model-text": "No models found.", + "authentication": "Authentication", + "authentication-basic-hint": "Uses standard HTTP Basic authentication. The username and password will be combined, Base64-encoded, and sent in an \"Authorization\" header with each request to the Ollama server.", + "authentication-token-hint": "Uses Bearer token authentication. The provided token will be sent directly in an \"Authorization\" eader with each request to the Ollama server.", + "authentication-type": { + "none": "None", + "basic": "Basic", + "token": "Token" + }, + "username": "Username", + "username-required": "Username is required.", + "password": "Password", + "password-required": "Password is required.", + "token": "Token", + "token-required": "Token is required." }, "confirm-on-exit": { "message": "You have unsaved changes. Are you sure you want to leave this page?", From d989d1e7d75e1fa54f484bea444c2b2d08bc5d5a Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 30 Sep 2025 13:52:05 +0300 Subject: [PATCH 11/11] UI: Refactoring show total value in legend --- .../basic/chart/latest-chart-basic-config.component.html | 8 +++++--- .../widget/lib/chart/bar-chart-widget.models.ts | 1 + .../components/widget/lib/chart/doughnut-widget.models.ts | 1 + .../components/widget/lib/chart/latest-chart.component.ts | 5 +---- .../components/widget/lib/chart/latest-chart.models.ts | 5 ++--- .../home/components/widget/lib/chart/latest-chart.ts | 8 +++++--- .../widget/lib/chart/pie-chart-widget.models.ts | 1 + .../widget/lib/chart/polar-area-widget.models.ts | 1 + .../widget/lib/chart/radar-chart-widget.models.ts | 1 + .../chart/latest-chart-widget-settings.component.html | 8 +++++--- 10 files changed, 23 insertions(+), 16 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html index b5c1a460fb..31d0e6cd7e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html @@ -144,9 +144,11 @@
- - {{ 'legend.show-total' | translate }} - +
+ + {{ 'legend.show-total' | translate }} + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-widget.models.ts index 036ba70c04..3f76eb18cf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-widget.models.ts @@ -68,6 +68,7 @@ export const barChartWidgetBarsChartSettings = (settings: BarChartWidgetSettings showTotal: false, animation: settings.animation, showLegend: settings.showLegend, + legendShowTotal: settings.legendShowTotal, showTooltip: settings.showTooltip, tooltipValueType: settings.tooltipValueType, tooltipValueDecimals: settings.tooltipValueDecimals, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.models.ts index 0d8c1ba9fa..0ef277ea8b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.models.ts @@ -88,6 +88,7 @@ export const doughnutPieChartSettings = (settings: DoughnutWidgetSettings): Deep showTotal: settings.layout === DoughnutLayout.with_total, animation: settings.animation, showLegend: settings.showLegend, + legendShowTotal: settings.legendShowTotal, totalValueFont: settings.totalValueFont, totalValueColor: settings.totalValueColor, showLabel: false, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.ts index 80be75d98d..85905dfd3e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.ts @@ -82,8 +82,7 @@ export class LatestChartComponent implements OnInit, OnDestroy, AfterViewInit { padding: string; get legendItems(): LatestChartLegendItem[] { - let items = this.latestChart ? this.latestChart.getLegendItems() : []; - return this.legendShowTotal ? items : items.filter(item => !item.total); + return this.latestChart ? this.latestChart.getLegendItems() : []; } legendLabelStyle: ComponentStyle; @@ -93,7 +92,6 @@ export class LatestChartComponent implements OnInit, OnDestroy, AfterViewInit { private shapeResize$: ResizeObserver; private legendHorizontal: boolean; - private legendShowTotal: boolean; private latestChart: TbLatestChart; @@ -121,7 +119,6 @@ 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 639938bad6..aa1ac43644 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 @@ -87,6 +87,7 @@ export interface LatestChartSettings extends LatestChartTooltipSettings { sortSeries: boolean; showTotal?: boolean; showLegend: boolean; + legendShowTotal: boolean; animation: ChartAnimationSettings; } @@ -96,6 +97,7 @@ export const latestChartDefaultSettings: LatestChartSettings = { sortSeries: false, showTotal: false, showLegend: true, + legendShowTotal: true, animation: mergeDeep({} as ChartAnimationSettings, chartAnimationDefaultSettings) }; @@ -105,14 +107,12 @@ export interface LatestChartWidgetSettings extends LatestChartSettings { legendLabelColor: string; legendValueFont: Font; legendValueColor: string; - legendShowTotal: boolean; background: BackgroundSettings; padding: string; } export const latestChartWidgetDefaultSettings: LatestChartWidgetSettings = { ...latestChartDefaultSettings, - showLegend: true, legendPosition: LegendPosition.bottom, legendLabelFont: { family: 'Roboto', @@ -132,7 +132,6 @@ 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/chart/latest-chart.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.ts index b9f0940b52..9e525cd794 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.ts @@ -36,6 +36,7 @@ import { ValueFormatProcessor } from '@shared/models/widget-settings.models'; export abstract class TbLatestChart { private readonly shapeResize$: ResizeObserver; + private showTotalValueInLegend: boolean; protected readonly settings: S; @@ -121,7 +122,8 @@ export abstract class TbLatestChart { this.legendItems.sort((a, b) => a.label.localeCompare(b.label)); } } - if (this.settings.showLegend && !this.settings.showTotal) { + this.showTotalValueInLegend = this.settings.showLegend && !this.settings.showTotal && this.settings.legendShowTotal; + if (this.showTotalValueInLegend) { this.legendItems.push( { value: '--', @@ -252,11 +254,11 @@ export abstract class TbLatestChart { if (this.settings.showTotal || this.settings.showLegend) { if (hasValue) { this.totalText = this.valueFormatter.format(this.total); - if (this.settings.showLegend && !this.settings.showTotal) { + if (this.showTotalValueInLegend) { this.legendItems[this.legendItems.length - 1].hasValue = true; this.legendItems[this.legendItems.length - 1].value = this.totalText; } - } else if (this.settings.showLegend && !this.settings.showTotal) { + } else if (this.showTotalValueInLegend) { this.legendItems[this.legendItems.length - 1].hasValue = false; this.legendItems[this.legendItems.length - 1].value = '--'; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart-widget.models.ts index a98cdfdd4a..cddf75b9f9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart-widget.models.ts @@ -64,6 +64,7 @@ export const pieChartWidgetPieChartSettings = (settings: PieChartWidgetSettings) showTotal: false, animation: settings.animation, showLegend: settings.showLegend, + legendShowTotal: settings.legendShowTotal, showLabel: settings.showLabel, labelPosition: settings.labelPosition, labelFont: settings.labelFont, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/polar-area-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/polar-area-widget.models.ts index 70b4520159..4b744ce9c3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/polar-area-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/polar-area-widget.models.ts @@ -72,6 +72,7 @@ export const polarAreaChartWidgetBarsChartSettings = (settings: PolarAreaChartWi showTotal: false, animation: settings.animation, showLegend: settings.showLegend, + legendShowTotal: settings.legendShowTotal, showTooltip: settings.showTooltip, tooltipValueType: settings.tooltipValueType, tooltipValueDecimals: settings.tooltipValueDecimals, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.models.ts index ec42955b4d..b3be129489 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.models.ts @@ -135,6 +135,7 @@ export const radarChartWidgetRadarChartSettings = (settings: RadarChartWidgetSet showTotal: false, animation: settings.animation, showLegend: settings.showLegend, + legendShowTotal: settings.legendShowTotal, showTooltip: settings.showTooltip, tooltipValueType: settings.tooltipValueType, tooltipValueDecimals: settings.tooltipValueDecimals, 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 fe071f16a0..66d0234914 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,9 +61,11 @@ - - {{ 'legend.show-total' | translate }} - +
+ + {{ 'legend.show-total' | translate }} + +