From 0f17e5f457b2e2ce54a8faaaedd5a095f072f184 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 13 May 2025 15:39:46 +0300 Subject: [PATCH 001/249] AI rule node: draft implementation --- application/pom.xml | 12 ++ .../main/data/upgrade/basic/schema_update.sql | 10 ++ .../server/actors/ActorSystemContext.java | 5 + .../actors/ruleChain/DefaultTbContext.java | 6 + .../controller/AiSettingsController.java | 80 +++++++++++ .../server/service/ai/AiServiceImpl.java | 64 +++++++++ .../server/dao/ai/AiSettingsService.java | 39 ++++++ .../server/common/data/BaseData.java | 6 + .../server/common/data/EntityType.java | 8 +- .../server/common/data/ai/AiSettings.java | 94 +++++++++++++ .../data/edqs/fields/AiSettingsFields.java | 40 ++++++ .../server/common/data/id/AiSettingsId.java | 51 +++++++ .../common/data/id/EntityIdFactory.java | 106 +++++--------- common/proto/src/main/proto/queue.proto | 1 + .../server/dao/ai/AiSettingsDao.java | 34 +++++ .../server/dao/ai/AiSettingsServiceImpl.java | 88 ++++++++++++ .../server/dao/model/ModelConstants.java | 10 ++ .../dao/model/sql/AiSettingsEntity.java | 96 +++++++++++++ .../dao/sql/ai/AiSettingsRepository.java | 51 +++++++ .../server/dao/sql/ai/JpaAiSettingsDao.java | 91 ++++++++++++ .../main/resources/sql/schema-entities.sql | 11 ++ pom.xml | 8 ++ rule-engine/rule-engine-api/pom.xml | 4 + .../rule/engine/api/RuleEngineAiService.java | 26 ++++ .../rule/engine/api/TbContext.java | 2 + rule-engine/rule-engine-components/pom.xml | 4 + .../thingsboard/rule/engine/ai/TbAiNode.java | 129 ++++++++++++++++++ .../rule/engine/ai/TbAiNodeConfiguration.java | 42 ++++++ 28 files changed, 1048 insertions(+), 70 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java create mode 100644 application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AiSettingsFields.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/AiSettingsId.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiSettingsDao.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAiService.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java diff --git a/application/pom.xml b/application/pom.xml index 557179e918..5be51761f3 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -377,6 +377,18 @@ org.rocksdb rocksdbjni + + dev.langchain4j + langchain4j-open-ai + + + dev.langchain4j + langchain4j-anthropic + + + dev.langchain4j + langchain4j-google-ai-gemini + diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 016e786776..8bd41a9208 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -14,3 +14,13 @@ -- limitations under the License. -- +CREATE TABLE ai_settings ( + id UUID NOT NULL PRIMARY KEY, + created_time BIGINT NOT NULL, + tenant_id UUID NOT NULL, + version BIGINT NOT NULL DEFAULT 1, + name VARCHAR(255) NOT NULL, + provider VARCHAR(255) NOT NULL, + model VARCHAR(255) NOT NULL, + api_key VARCHAR(1000) NOT NULL +); 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 61d9586095..785ac8da9e 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -34,6 +34,7 @@ import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; +import org.thingsboard.rule.engine.api.RuleEngineAiService; import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.rule.engine.api.notification.SlackService; import org.thingsboard.rule.engine.api.sms.SmsSenderFactory; @@ -309,6 +310,10 @@ public class ActorSystemContext { @Getter private AuditLogService auditLogService; + @Autowired + @Getter + private RuleEngineAiService aiService; + @Autowired @Getter private EntityViewService entityViewService; 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 c07134105c..14bab1a560 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 @@ -27,6 +27,7 @@ import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; +import org.thingsboard.rule.engine.api.RuleEngineAiService; import org.thingsboard.rule.engine.api.RuleEngineAlarmService; import org.thingsboard.rule.engine.api.RuleEngineApiUsageStateService; import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache; @@ -1012,6 +1013,11 @@ public class DefaultTbContext implements TbContext { return mainCtx.getAuditLogService(); } + @Override + public RuleEngineAiService getAiService() { + return mainCtx.getAiService(); + } + @Override public MqttClientSettings getMqttClientSettings() { return mainCtx.getMqttClientSettings(); diff --git a/application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java b/application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java new file mode 100644 index 0000000000..0c981a5d2f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java @@ -0,0 +1,80 @@ +/** + * 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.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.ai.AiSettingsService; +import org.thingsboard.server.service.security.model.SecurityUser; + +import java.util.UUID; + +// TODO: TbAiSettingsService? + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/ai-settings") +public class AiSettingsController extends BaseController { + + private final AiSettingsService aiSettingsService; + + @PostMapping + public AiSettings saveAiSettings( + @RequestBody AiSettings aiSettings, + + @AuthenticationPrincipal SecurityUser requestingUser + ) { + return aiSettingsService.save(requestingUser.getTenantId(), aiSettings); + } + + @GetMapping("/{aiSettingsId}") + public AiSettings getAiSettingsById( + @PathVariable("aiSettingsId") UUID aiSettingsUuid, + + @AuthenticationPrincipal SecurityUser requestingUser + ) throws ThingsboardException { + return checkNotNull(aiSettingsService.findAiSettingsByTenantIdAndId(requestingUser.getTenantId(), new AiSettingsId(aiSettingsUuid))); + } + + @GetMapping + public PageData getAllAiSettings( + @AuthenticationPrincipal SecurityUser requestingUser + ) { + return aiSettingsService.findAiSettingsByTenantId(requestingUser.getTenantId(), new PageLink(Integer.MAX_VALUE)); + } + + @DeleteMapping("/{aiSettingsId}") + public boolean deleteAiSettingsById( + @PathVariable("aiSettingsId") UUID aiSettingsUuid, + + @AuthenticationPrincipal SecurityUser requestingUser + ) { + return aiSettingsService.deleteByTenantIdAndId(requestingUser.getTenantId(), new AiSettingsId(aiSettingsUuid)); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java new file mode 100644 index 0000000000..6324623177 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java @@ -0,0 +1,64 @@ +/** + * 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.ai; + +import dev.langchain4j.model.anthropic.AnthropicChatModel; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel; +import dev.langchain4j.model.openai.OpenAiChatModel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.rule.engine.api.RuleEngineAiService; +import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.ai.AiSettingsService; + +import java.util.NoSuchElementException; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +class AiServiceImpl implements RuleEngineAiService { + + private final AiSettingsService aiSettingsService; + + @Override + public ChatModel configureChatModel(TenantId tenantId, AiSettingsId aiSettingsId) { + Optional aiSettingsOpt = aiSettingsService.findAiSettingsById(tenantId, aiSettingsId); + if (aiSettingsOpt.isEmpty()) { + throw new NoSuchElementException("AI settings with ID: " + aiSettingsId + " were not found"); + } + var aiSettings = aiSettingsOpt.get(); + + return switch (aiSettings.getProvider()) { + case "openai" -> OpenAiChatModel.builder() + .apiKey(aiSettings.getApiKey()) + .modelName(aiSettings.getModel()) + .build(); + case "anthropic" -> AnthropicChatModel.builder() + .apiKey(aiSettings.getApiKey()) + .modelName(aiSettings.getModel()) + .build(); + case "google-ai-gemini" -> GoogleAiGeminiChatModel.builder() + .apiKey(aiSettings.getApiKey()) + .modelName(aiSettings.getModel()) + .build(); + default -> throw new IllegalArgumentException("Unsupported AI provider: " + aiSettings.getProvider()); + }; + } + +} diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java new file mode 100644 index 0000000000..e932901b7d --- /dev/null +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java @@ -0,0 +1,39 @@ +/** + * 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.ai; + +import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.id.AiSettingsId; +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.dao.entity.EntityDaoService; + +import java.util.Optional; + +public interface AiSettingsService extends EntityDaoService { + + AiSettings save(TenantId tenantId, AiSettings aiSettings); + + Optional findAiSettingsById(TenantId tenantId, AiSettingsId aiSettingsId); + + PageData findAiSettingsByTenantId(TenantId tenantId, PageLink pageLink); + + Optional findAiSettingsByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId); + + boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/BaseData.java b/common/data/src/main/java/org/thingsboard/server/common/data/BaseData.java index 10ea83397b..19ce1de43d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/BaseData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/BaseData.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data; import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.id.IdBased; import org.thingsboard.server.common.data.id.UUIDBased; @@ -41,6 +42,11 @@ public abstract class BaseData extends IdBased implement this.createdTime = data.getCreatedTime(); } + @Schema( + description = "Entity creation timestamp in milliseconds since Unix epoch", + example = "1746028547220", + accessMode = Schema.AccessMode.READ_ONLY + ) public long getCreatedTime() { return createdTime; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java index 93e754eb2c..89bc456fd9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java @@ -63,7 +63,13 @@ public enum EntityType { MOBILE_APP(37), MOBILE_APP_BUNDLE(38), CALCULATED_FIELD(39), - CALCULATED_FIELD_LINK(40); + CALCULATED_FIELD_LINK(40), + AI_SETTINGS(41, "ai_settings") { + @Override + public String getNormalName() { + return "AI settings"; + } + }; @Getter private final int protoNumber; // Corresponds to EntityTypeProto diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java new file mode 100644 index 0000000000..4a6168bcf9 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java @@ -0,0 +1,94 @@ +/** + * 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; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.HasName; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.HasVersion; +import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.id.TenantId; + +import java.io.Serial; + +@Data +@Builder +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public final class AiSettings extends BaseData implements HasTenantId, HasVersion, HasName { + + @Serial + private static final long serialVersionUID = 9017108678716011604L; + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_ONLY, + description = "JSON object representing the ID of the tenant associated with these AI settings", + example = "e3c4b7d2-5678-4a9b-0c1d-2e3f4a5b6c7d" + ) + TenantId tenantId; + + @Schema( + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + accessMode = Schema.AccessMode.READ_ONLY, + description = "Version of the AI settings; increments automatically whenever the settings are changed", + example = "7", + defaultValue = "1" + ) + Long version; + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "Human-readable name of the AI settings", + example = "Default AI Settings" + ) + String name; + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "Name of the LLM provider, e.g. 'openai', 'anthropic'", + example = "openai" + ) + String provider; + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "Identifier of the LLM model to use, e.g. 'gpt-4o-mini'", + example = "gpt-4o-mini" + ) + String model; + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.WRITE_ONLY, + description = "API key for authenticating with the selected LLM provider", + example = "sk-********************************" + ) + String apiKey; + + public AiSettings(AiSettingsId id) { + super(id); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AiSettingsFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AiSettingsFields.java new file mode 100644 index 0000000000..751c8538f0 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AiSettingsFields.java @@ -0,0 +1,40 @@ +/** + * 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.edqs.fields; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +public class AiSettingsFields extends AbstractEntityFields { + + private String provider; + private String model; + private String apiKey; + + public AiSettingsFields(UUID id, long createdTime, UUID tenantId, long version, String provider, String name, String model, String apiKey) { + super(id, createdTime, tenantId, name, version); + this.provider = provider; + this.model = model; + this.apiKey = apiKey; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AiSettingsId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AiSettingsId.java new file mode 100644 index 0000000000..f9b6ab74fe --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AiSettingsId.java @@ -0,0 +1,51 @@ +/** + * 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.id; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import org.thingsboard.server.common.data.EntityType; + +import java.io.Serial; +import java.util.UUID; + +public final class AiSettingsId extends UUIDBased implements EntityId { + + @Serial + private static final long serialVersionUID = 3021036138554389754L; + + @JsonCreator + public AiSettingsId(@JsonProperty("id") UUID id) { + super(id); + } + + @Override + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + description = "Entity type of the AI settings, always 'AI_SETTINGS'", + example = "AI_SETTINGS", + allowableValues = "AI_SETTINGS" + ) + public EntityType getEntityType() { + return EntityType.AI_SETTINGS; + } + + public static AiSettingsId fromString(String uuid) { + return new AiSettingsId(UUID.fromString(uuid)); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java index f5dd4b12a0..fcdf1e0a1b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java @@ -50,75 +50,43 @@ public class EntityIdFactory { } public static EntityId getByTypeAndUuid(EntityType type, UUID uuid) { - switch (type) { - case TENANT: - return TenantId.fromUUID(uuid); - case CUSTOMER: - return new CustomerId(uuid); - case USER: - return new UserId(uuid); - case DASHBOARD: - return new DashboardId(uuid); - case DEVICE: - return new DeviceId(uuid); - case ASSET: - return new AssetId(uuid); - case ALARM: - return new AlarmId(uuid); - case RULE_CHAIN: - return new RuleChainId(uuid); - case RULE_NODE: - return new RuleNodeId(uuid); - case ENTITY_VIEW: - return new EntityViewId(uuid); - case WIDGETS_BUNDLE: - return new WidgetsBundleId(uuid); - case WIDGET_TYPE: - return new WidgetTypeId(uuid); - case DEVICE_PROFILE: - return new DeviceProfileId(uuid); - case ASSET_PROFILE: - return new AssetProfileId(uuid); - case TENANT_PROFILE: - return new TenantProfileId(uuid); - case API_USAGE_STATE: - return new ApiUsageStateId(uuid); - case TB_RESOURCE: - return new TbResourceId(uuid); - case OTA_PACKAGE: - return new OtaPackageId(uuid); - case EDGE: - return new EdgeId(uuid); - case RPC: - return new RpcId(uuid); - case QUEUE: - return new QueueId(uuid); - case NOTIFICATION_TARGET: - return new NotificationTargetId(uuid); - case NOTIFICATION_REQUEST: - return new NotificationRequestId(uuid); - case NOTIFICATION_RULE: - return new NotificationRuleId(uuid); - case NOTIFICATION_TEMPLATE: - return new NotificationTemplateId(uuid); - case NOTIFICATION: - return new NotificationId(uuid); - case QUEUE_STATS: - return new QueueStatsId(uuid); - case OAUTH2_CLIENT: - return new OAuth2ClientId(uuid); - case MOBILE_APP: - return new MobileAppId(uuid); - case DOMAIN: - return new DomainId(uuid); - case MOBILE_APP_BUNDLE: - return new MobileAppBundleId(uuid); - case CALCULATED_FIELD: - return new CalculatedFieldId(uuid); - case CALCULATED_FIELD_LINK: - return new CalculatedFieldLinkId(uuid); - } - throw new IllegalArgumentException("EntityType " + type + " is not supported!"); + return switch (type) { + case TENANT -> TenantId.fromUUID(uuid); + case CUSTOMER -> new CustomerId(uuid); + case USER -> new UserId(uuid); + case DASHBOARD -> new DashboardId(uuid); + case DEVICE -> new DeviceId(uuid); + case ASSET -> new AssetId(uuid); + case ALARM -> new AlarmId(uuid); + case RULE_CHAIN -> new RuleChainId(uuid); + case RULE_NODE -> new RuleNodeId(uuid); + case ENTITY_VIEW -> new EntityViewId(uuid); + case WIDGETS_BUNDLE -> new WidgetsBundleId(uuid); + case WIDGET_TYPE -> new WidgetTypeId(uuid); + case DEVICE_PROFILE -> new DeviceProfileId(uuid); + case ASSET_PROFILE -> new AssetProfileId(uuid); + case TENANT_PROFILE -> new TenantProfileId(uuid); + case API_USAGE_STATE -> new ApiUsageStateId(uuid); + case TB_RESOURCE -> new TbResourceId(uuid); + case OTA_PACKAGE -> new OtaPackageId(uuid); + case EDGE -> new EdgeId(uuid); + case RPC -> new RpcId(uuid); + case QUEUE -> new QueueId(uuid); + case NOTIFICATION_TARGET -> new NotificationTargetId(uuid); + case NOTIFICATION_REQUEST -> new NotificationRequestId(uuid); + case NOTIFICATION_RULE -> new NotificationRuleId(uuid); + case NOTIFICATION_TEMPLATE -> new NotificationTemplateId(uuid); + case NOTIFICATION -> new NotificationId(uuid); + case QUEUE_STATS -> new QueueStatsId(uuid); + case OAUTH2_CLIENT -> new OAuth2ClientId(uuid); + case MOBILE_APP -> new MobileAppId(uuid); + case DOMAIN -> new DomainId(uuid); + case MOBILE_APP_BUNDLE -> new MobileAppBundleId(uuid); + case CALCULATED_FIELD -> new CalculatedFieldId(uuid); + case CALCULATED_FIELD_LINK -> new CalculatedFieldLinkId(uuid); + case AI_SETTINGS -> new AiSettingsId(uuid); + default -> throw new IllegalArgumentException("EntityType " + type + " is not supported!"); + }; } public static EntityId getByEdgeEventTypeAndUuid(EdgeEventType edgeEventType, UUID uuid) { diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 2a97fd35d0..e5a94c21fc 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -63,6 +63,7 @@ enum EntityTypeProto { MOBILE_APP_BUNDLE = 38; CALCULATED_FIELD = 39; CALCULATED_FIELD_LINK = 40; + AI_SETTINGS = 41; } enum ApiUsageRecordKeyProto { diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsDao.java new file mode 100644 index 0000000000..a94131a642 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsDao.java @@ -0,0 +1,34 @@ +/** + * 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.ai; + +import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.Dao; +import org.thingsboard.server.dao.TenantEntityDao; + +import java.util.Optional; + +public interface AiSettingsDao extends Dao, TenantEntityDao { + + Optional findByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId); + + void deleteByTenantId(TenantId tenantId); + + boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java new file mode 100644 index 0000000000..356bf1d19a --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java @@ -0,0 +1,88 @@ +/** + * 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.ai; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; +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 java.util.Optional; + +@Service +@RequiredArgsConstructor +class AiSettingsServiceImpl implements AiSettingsService { + + private final AiSettingsDao aiSettingsDao; + + @Override + public AiSettings save(TenantId tenantId, AiSettings aiSettings) { + aiSettings.setTenantId(tenantId); + return aiSettingsDao.saveAndFlush(tenantId, aiSettings); + } + + @Override + public Optional findAiSettingsById(TenantId tenantId, AiSettingsId aiSettingsId) { + return Optional.ofNullable(aiSettingsDao.findById(tenantId, aiSettingsId.getId())); + } + + @Override + public PageData findAiSettingsByTenantId(TenantId tenantId, PageLink pageLink) { + return aiSettingsDao.findAllByTenantId(tenantId, pageLink); + } + + @Override + public Optional findAiSettingsByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) { + return aiSettingsDao.findByTenantIdAndId(tenantId, aiSettingsId); + } + + @Override + public boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) { + return aiSettingsDao.deleteByTenantIdAndId(tenantId, aiSettingsId); + } + + @Override + public Optional> findEntity(TenantId tenantId, EntityId entityId) { + return Optional.ofNullable(aiSettingsDao.findById(tenantId, entityId.getId())); + } + + @Override + public long countByTenantId(TenantId tenantId) { + return aiSettingsDao.countByTenantId(tenantId); + } + + @Override + public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { + aiSettingsDao.removeById(tenantId, id.getId()); + } + + @Override + public void deleteByTenantId(TenantId tenantId) { + aiSettingsDao.deleteByTenantId(tenantId); + } + + @Override + public EntityType getEntityType() { + return EntityType.AI_SETTINGS; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 148908d063..ac09ce088b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -739,6 +739,16 @@ public class ModelConstants { public static final String CALCULATED_FIELD_LINK_ENTITY_ID = ENTITY_ID_COLUMN; public static final String CALCULATED_FIELD_LINK_CALCULATED_FIELD_ID = "calculated_field_id"; + /** + * AI settings constants. + */ + public static final String AI_SETTINGS_TABLE_NAME = "ai_settings"; + public static final String AI_SETTINGS_TENANT_ID_COLUMN_NAME = TENANT_ID_COLUMN; + public static final String AI_SETTINGS_NAME_COLUMN_NAME = NAME_PROPERTY; + public static final String AI_SETTINGS_PROVIDER_COLUMN_NAME = "provider"; + public static final String AI_SETTINGS_MODEL_COLUMN_NAME = "model"; + public static final String AI_SETTINGS_API_KEY_COLUMN_NAME = "api_key"; + protected static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, JSON_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN}; protected static final String[] COUNT_AGGREGATION_COLUMNS = new String[]{count(LONG_VALUE_COLUMN), count(DOUBLE_VALUE_COLUMN), count(BOOLEAN_VALUE_COLUMN), count(STRING_VALUE_COLUMN), count(JSON_VALUE_COLUMN), max(TS_COLUMN)}; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java new file mode 100644 index 0000000000..f238f57d4b --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java @@ -0,0 +1,96 @@ +/** + * 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.model.sql; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.proxy.HibernateProxy; +import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.model.BaseVersionedEntity; +import org.thingsboard.server.dao.model.ModelConstants; + +import java.util.Objects; +import java.util.UUID; + +@Getter +@Setter +@ToString +@Entity +@Table(name = ModelConstants.AI_SETTINGS_TABLE_NAME) +public class AiSettingsEntity extends BaseVersionedEntity { + + @Column(name = ModelConstants.AI_SETTINGS_TENANT_ID_COLUMN_NAME, nullable = false, columnDefinition = "uuid") + private UUID tenantId; + + @Column(name = ModelConstants.AI_SETTINGS_NAME_COLUMN_NAME, nullable = false) + private String name; + + @Column(name = ModelConstants.AI_SETTINGS_PROVIDER_COLUMN_NAME, nullable = false) + private String provider; + + @Column(name = ModelConstants.AI_SETTINGS_MODEL_COLUMN_NAME, nullable = false) + private String model; + + @Column(name = ModelConstants.AI_SETTINGS_API_KEY_COLUMN_NAME, nullable = false) + private String apiKey; + + public AiSettingsEntity() {} + + public AiSettingsEntity(AiSettings aiSettings) { + super(aiSettings); + tenantId = getTenantUuid(aiSettings.getTenantId()); + name = aiSettings.getName(); + provider = aiSettings.getProvider(); + model = aiSettings.getModel(); + apiKey = aiSettings.getApiKey(); + } + + @Override + public AiSettings toData() { + var settings = new AiSettings(new AiSettingsId(id)); + settings.setCreatedTime(createdTime); + settings.setVersion(version); + settings.setTenantId(TenantId.fromUUID(tenantId)); + settings.setName(name); + settings.setProvider(provider); + settings.setModel(model); + settings.setApiKey(apiKey); + return settings; + } + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass(); + Class thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + AiSettingsEntity that = (AiSettingsEntity) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java new file mode 100644 index 0000000000..b64fc99bcb --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java @@ -0,0 +1,51 @@ +/** + * 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.sql.ai; + +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Page; +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.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.common.data.edqs.fields.AiSettingsFields; +import org.thingsboard.server.dao.model.sql.AiSettingsEntity; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface AiSettingsRepository extends JpaRepository { + + Page findByTenantId(UUID tenantId, Pageable pageable); + + Optional findByTenantIdAndId(UUID tenantId, UUID id); + + @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.AiSettingsFields(" + + "ai.id, ai.createdTime, ai.tenantId, ai.version, ai.provider, ai.name, ai.model, ai.apiKey) " + + "FROM AiSettingsEntity ai WHERE ai.id > :id ORDER BY ai.id") + List findNextBatch(@Param("id") UUID id, Limit limit); + + Long countByTenantId(UUID tenantId); + + @Transactional + void deleteByTenantId(UUID tenantId); + + @Transactional + boolean deleteByTenantIdAndId(UUID tenantId, UUID id); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiSettingsDao.java new file mode 100644 index 0000000000..8d08aaefff --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiSettingsDao.java @@ -0,0 +1,91 @@ +/** + * 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.sql.ai; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Limit; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.edqs.fields.AiSettingsFields; +import org.thingsboard.server.common.data.id.AiSettingsId; +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.dao.DaoUtil; +import org.thingsboard.server.dao.ai.AiSettingsDao; +import org.thingsboard.server.dao.model.sql.AiSettingsEntity; +import org.thingsboard.server.dao.sql.JpaAbstractDao; +import org.thingsboard.server.dao.util.SqlDao; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@SqlDao +@Component +@RequiredArgsConstructor +class JpaAiSettingsDao extends JpaAbstractDao implements AiSettingsDao { + + private final AiSettingsRepository aiSettingsRepository; + + @Override + public Optional findByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) { + return aiSettingsRepository.findByTenantIdAndId(tenantId.getId(), aiSettingsId.getId()).map(DaoUtil::getData); + } + + @Override + public List findNextBatch(UUID id, int batchSize) { + return aiSettingsRepository.findNextBatch(id, Limit.of(batchSize)); + } + + @Override + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return DaoUtil.toPageData(aiSettingsRepository.findByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); + } + + @Override + public Long countByTenantId(TenantId tenantId) { + return aiSettingsRepository.countByTenantId(tenantId.getId()); + } + + @Override + public void deleteByTenantId(TenantId tenantId) { + aiSettingsRepository.deleteByTenantId(tenantId.getId()); + } + + @Override + public boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) { + return aiSettingsRepository.deleteByTenantIdAndId(tenantId.getId(), aiSettingsId.getId()); + } + + @Override + public EntityType getEntityType() { + return EntityType.AI_SETTINGS; + } + + @Override + protected Class getEntityClass() { + return AiSettingsEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return aiSettingsRepository; + } + +} diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index b425550e7e..aaff15b2d4 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -948,3 +948,14 @@ CREATE TABLE IF NOT EXISTS cf_debug_event ( e_result varchar, e_error varchar ) PARTITION BY RANGE (ts); + +CREATE TABLE IF NOT EXISTS ai_settings ( + id UUID NOT NULL PRIMARY KEY, + created_time BIGINT NOT NULL, + tenant_id UUID NOT NULL, + version BIGINT NOT NULL DEFAULT 1, + name VARCHAR(255) NOT NULL, + provider VARCHAR(255) NOT NULL, + model VARCHAR(255) NOT NULL, + api_key VARCHAR(1000) NOT NULL +); diff --git a/pom.xml b/pom.xml index 6082745758..8f92b66906 100755 --- a/pom.xml +++ b/pom.xml @@ -134,6 +134,7 @@ 1.7.5 3.8.0 2.9.0 + 1.0.0-beta4 4.2.1 2.7.3 @@ -2322,6 +2323,13 @@ rocksdbjni ${rocksdbjni.version} + + dev.langchain4j + langchain4j-bom + ${langchain4j.version} + pom + import + diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index d943759257..cbd6df2d28 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -98,6 +98,10 @@ jakarta.mail provided + + dev.langchain4j + langchain4j + org.springframework.boot spring-boot-starter-test diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAiService.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAiService.java new file mode 100644 index 0000000000..ae455d3b51 --- /dev/null +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAiService.java @@ -0,0 +1,26 @@ +/** + * 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.rule.engine.api; + +import dev.langchain4j.model.chat.ChatModel; +import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.id.TenantId; + +public interface RuleEngineAiService { + + ChatModel configureChatModel(TenantId tenantId, AiSettingsId aiSettingsId); + +} 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 7989b8f9ce..1fc7432a08 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 @@ -417,6 +417,8 @@ public interface TbContext { AuditLogService getAuditLogService(); + RuleEngineAiService getAiService(); + // Configuration parameters for the MQTT client that is used in the MQTT node and Azure IoT hub node MqttClientSettings getMqttClientSettings(); diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 9666fa8059..26dc2a9004 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -153,6 +153,10 @@ com.jayway.jsonpath json-path + + dev.langchain4j + langchain4j + 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 new file mode 100644 index 0000000000..e148e0397a --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java @@ -0,0 +1,129 @@ +/** + * 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.rule.engine.ai; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.ListenableFuture; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.request.ResponseFormat; +import dev.langchain4j.model.input.PromptTemplate; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.RuleNode; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNode; +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.plugin.ComponentType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.dao.exception.DataValidationException; + +import java.util.List; +import java.util.Map; + +import static com.google.common.util.concurrent.Futures.addCallback; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; +import static java.util.Objects.requireNonNullElse; +import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; + +@RuleNode( + type = ComponentType.EXTERNAL, + name = "AI", + nodeDescription = "Interact with AI", + nodeDetails = "This node makes requests to LLM based on a prompt and a input message and returns a response in a form of output message", + configClazz = TbAiNodeConfiguration.class +) +public final class TbAiNode extends TbAbstractExternalNode implements TbNode { + + private static final SystemMessage SYSTEM_MESSAGE = SystemMessage.from(""" + Take a deep breath and work on this step by step. + You are an industry-leading IoT domain expert with deep experience in telemetry data analysis. + Your task is to complete the user-provided task or answer a question. + You may use additional context information called "Rule engine message payload", "Rule engine message metadata" and "Rule engine message type". + Your response must be in JSON format."""); + + private TbAiNodeConfiguration config; + + private PromptTemplate userPromptTemplate; + private ChatModel chatModel; + + @Override + public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { + config = TbNodeUtils.convert(configuration, TbAiNodeConfiguration.class); + String errorPrefix = "'" + ctx.getSelf().getName() + "' node configuration is invalid: "; + try { + validateFields(config, errorPrefix); + } catch (DataValidationException e) { + throw new TbNodeException(e, true); + } + userPromptTemplate = PromptTemplate.from(""" + User-provided task or question: %s + Rule engine message payload: {{msgPayload}} + Rule engine message metadata: {{msgMetadata}} + Rule engine message type: {{msgType}}""" + .formatted(config.getUserPrompt()) + ); + chatModel = ctx.getAiService().configureChatModel(ctx.getTenantId(), config.getAiSettingsId()); + } + + @Override + public void onMsg(TbContext ctx, TbMsg msg) { + var ackedMsg = ackIfNeeded(ctx, msg); + + Map variables = Map.of( + "msgPayload", msg.getData(), + "msgMetadata", requireNonNullElse(JacksonUtil.toString(msg.getMetaData().getData()), "{}"), + "msgType", msg.getType() + ); + UserMessage userMessage = userPromptTemplate.apply(variables).toUserMessage(); + + var chatRequest = ChatRequest.builder() + .messages(List.of(SYSTEM_MESSAGE, userMessage)) + .responseFormat(ResponseFormat.JSON) + .build(); + + addCallback(sendChatRequest(ctx, chatRequest), new FutureCallback<>() { + @Override + public void onSuccess(String response) { + tellSuccess(ctx, ackedMsg.transform() + .data(response) + .build()); + } + + @Override + public void onFailure(@NonNull Throwable t) { + tellFailure(ctx, ackedMsg, t); + } + }, directExecutor()); + } + + private ListenableFuture sendChatRequest(TbContext ctx, ChatRequest chatRequest) { + return ctx.getExternalCallExecutor().submit(() -> chatModel.chat(chatRequest).aiMessage().text()); + } + + @Override + public void destroy() { + config = null; + userPromptTemplate = null; + chatModel = 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 new file mode 100644 index 0000000000..f7d45e1b76 --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java @@ -0,0 +1,42 @@ +/** + * 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.rule.engine.ai; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.thingsboard.rule.engine.api.NodeConfiguration; +import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.validation.Length; + +@Data +public class TbAiNodeConfiguration implements NodeConfiguration { + + @NotNull + private AiSettingsId aiSettingsId; + + @NotBlank + @Length(min = 1, max = 1000) + private String userPrompt; + + @Override + public TbAiNodeConfiguration defaultConfiguration() { + var configuration = new TbAiNodeConfiguration(); + configuration.setUserPrompt("Tell me a joke"); + return configuration; + } + +} From 9ef4295d906b0c14b5164e653b3d1e23cbfe87a1 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 15 May 2025 18:49:21 +0300 Subject: [PATCH 002/249] AI rule node: bump langchain4j version --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 8f92b66906..074bbbce03 100755 --- a/pom.xml +++ b/pom.xml @@ -134,7 +134,7 @@ 1.7.5 3.8.0 2.9.0 - 1.0.0-beta4 + 1.0.0 4.2.1 2.7.3 From c0480ed9302ce2e89152cd3f454bc7db250580ae Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 15 May 2025 18:50:00 +0300 Subject: [PATCH 003/249] AI rule node: move system prompt to rule node config --- .../thingsboard/rule/engine/ai/TbAiNode.java | 17 +++++------------ .../rule/engine/ai/TbAiNodeConfiguration.java | 10 ++++++++++ 2 files changed, 15 insertions(+), 12 deletions(-) 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 e148e0397a..5682529224 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 @@ -53,27 +53,20 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie ) public final class TbAiNode extends TbAbstractExternalNode implements TbNode { - private static final SystemMessage SYSTEM_MESSAGE = SystemMessage.from(""" - Take a deep breath and work on this step by step. - You are an industry-leading IoT domain expert with deep experience in telemetry data analysis. - Your task is to complete the user-provided task or answer a question. - You may use additional context information called "Rule engine message payload", "Rule engine message metadata" and "Rule engine message type". - Your response must be in JSON format."""); - - private TbAiNodeConfiguration config; - + private SystemMessage systemMessage; private PromptTemplate userPromptTemplate; private ChatModel chatModel; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - config = TbNodeUtils.convert(configuration, TbAiNodeConfiguration.class); + var config = TbNodeUtils.convert(configuration, TbAiNodeConfiguration.class); String errorPrefix = "'" + ctx.getSelf().getName() + "' node configuration is invalid: "; try { validateFields(config, errorPrefix); } catch (DataValidationException e) { throw new TbNodeException(e, true); } + systemMessage = SystemMessage.from(config.getSystemPrompt()); userPromptTemplate = PromptTemplate.from(""" User-provided task or question: %s Rule engine message payload: {{msgPayload}} @@ -96,7 +89,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { UserMessage userMessage = userPromptTemplate.apply(variables).toUserMessage(); var chatRequest = ChatRequest.builder() - .messages(List.of(SYSTEM_MESSAGE, userMessage)) + .messages(List.of(systemMessage, userMessage)) .responseFormat(ResponseFormat.JSON) .build(); @@ -121,7 +114,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { @Override public void destroy() { - config = null; + systemMessage = null; userPromptTemplate = null; chatModel = 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 f7d45e1b76..9d8ac5fbd1 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 @@ -28,6 +28,10 @@ public class TbAiNodeConfiguration implements NodeConfiguration Date: Thu, 15 May 2025 18:51:53 +0300 Subject: [PATCH 004/249] AI rule node: remove `findNextBatch()` method --- .../data/edqs/fields/AiSettingsFields.java | 40 ------------------- .../dao/sql/ai/AiSettingsRepository.java | 7 +--- 2 files changed, 1 insertion(+), 46 deletions(-) delete mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AiSettingsFields.java diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AiSettingsFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AiSettingsFields.java deleted file mode 100644 index 751c8538f0..0000000000 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AiSettingsFields.java +++ /dev/null @@ -1,40 +0,0 @@ -/** - * 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.edqs.fields; - -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.NoArgsConstructor; - -import java.util.UUID; - -@Data -@EqualsAndHashCode(callSuper = true) -@NoArgsConstructor -public class AiSettingsFields extends AbstractEntityFields { - - private String provider; - private String model; - private String apiKey; - - public AiSettingsFields(UUID id, long createdTime, UUID tenantId, long version, String provider, String name, String model, String apiKey) { - super(id, createdTime, tenantId, name, version); - this.provider = provider; - this.model = model; - this.apiKey = apiKey; - } - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java index b64fc99bcb..109557095d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java @@ -35,12 +35,7 @@ public interface AiSettingsRepository extends JpaRepository findByTenantIdAndId(UUID tenantId, UUID id); - @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.AiSettingsFields(" + - "ai.id, ai.createdTime, ai.tenantId, ai.version, ai.provider, ai.name, ai.model, ai.apiKey) " + - "FROM AiSettingsEntity ai WHERE ai.id > :id ORDER BY ai.id") - List findNextBatch(@Param("id") UUID id, Limit limit); - - Long countByTenantId(UUID tenantId); + long countByTenantId(UUID tenantId); @Transactional void deleteByTenantId(UUID tenantId); From 1a3b1c3fe499fe174ac3fc98668c3f2b1dd13277 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 15 May 2025 18:52:31 +0300 Subject: [PATCH 005/249] AI rule node: REST API for CRUD operations on AI settings --- .../controller/AiSettingsController.java | 123 ++++++++++++++---- .../server/controller/BaseController.java | 13 ++ .../controller/ControllerConstants.java | 3 +- .../service/security/permission/Resource.java | 3 +- .../permission/TenantAdminPermissions.java | 17 +++ .../server/dao/ai/AiSettingsService.java | 2 +- .../server/common/data/ai/AiSettings.java | 6 +- .../server/dao/ai/AiSettingsServiceImpl.java | 8 +- .../dao/sql/ai/AiSettingsRepository.java | 14 +- .../server/dao/sql/ai/JpaAiSettingsDao.java | 15 +-- 10 files changed, 153 insertions(+), 51 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java b/application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java index 0c981a5d2f..2f7aa1dd43 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java @@ -15,66 +15,135 @@ */ package org.thingsboard.server.controller; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.annotation.AuthenticationPrincipal; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.ai.AiSettings; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.AiSettingsId; import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.dao.ai.AiSettingsService; -import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.config.annotations.ApiOperation; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; +import java.util.Optional; +import java.util.Set; import java.util.UUID; -// TODO: TbAiSettingsService? +import static org.thingsboard.server.controller.ControllerConstants.AI_SETTINGS_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; @RestController -@RequiredArgsConstructor @RequestMapping("/api/ai-settings") public class AiSettingsController extends BaseController { - private final AiSettingsService aiSettingsService; + private static final Set ALLOWED_SORT_PROPERTIES = Set.of("createdTime", "name", "provider", "model"); + @ApiOperation( + value = "Create or update AI settings (saveAiSettings)", + notes = "Creates or updates an AI settings record.\n\n" + + "• **Create:** Omit the `id` to create a new record. The platform assigns a UUID to the new settings and returns it in the `id` field of the response.\n\n" + + "• **Update:** Include an existing `id` to modify that record. If no matching record exists, the API responds with **404 Not Found**.\n\n" + + "Tenant ID for the AI settings will be taken from the authenticated user making the request, regardless of any value provided in the request body." + + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") @PostMapping - public AiSettings saveAiSettings( - @RequestBody AiSettings aiSettings, - - @AuthenticationPrincipal SecurityUser requestingUser - ) { - return aiSettingsService.save(requestingUser.getTenantId(), aiSettings); + public AiSettings saveAiSettings(@RequestBody AiSettings aiSettings) throws ThingsboardException { + aiSettings.setTenantId(getTenantId()); + checkEntity(aiSettings.getId(), aiSettings, Resource.AI_SETTINGS); + return aiSettingsService.save(aiSettings); } + @ApiOperation( + value = "Get AI settings by ID (getAiSettingsById)", + notes = "Fetches an AI settings record by its `id`." + + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") @GetMapping("/{aiSettingsId}") public AiSettings getAiSettingsById( - @PathVariable("aiSettingsId") UUID aiSettingsUuid, - - @AuthenticationPrincipal SecurityUser requestingUser + @Parameter( + description = "ID of the AI settings record", + required = true, + example = "de7900d4-30e2-11f0-9cd2-0242ac120002" + ) + @PathVariable("aiSettingsId") UUID aiSettingsUuid ) throws ThingsboardException { - return checkNotNull(aiSettingsService.findAiSettingsByTenantIdAndId(requestingUser.getTenantId(), new AiSettingsId(aiSettingsUuid))); + return checkAiSettingsId(new AiSettingsId(aiSettingsUuid), Operation.READ); } + @ApiOperation( + value = "Get AI settings (getAiSettings)", + notes = "Returns a page of AI settings. " + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") @GetMapping - public PageData getAllAiSettings( - @AuthenticationPrincipal SecurityUser requestingUser - ) { - return aiSettingsService.findAiSettingsByTenantId(requestingUser.getTenantId(), new PageLink(Integer.MAX_VALUE)); + public PageData getAiSettings( + @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @Parameter(description = AI_SETTINGS_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name", "provider", "model"})) + @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) + @RequestParam(required = false) String sortOrder + ) throws ThingsboardException { + var user = getCurrentUser(); + accessControlService.checkPermission(user, Resource.AI_SETTINGS, Operation.READ); + validateSortProperty(sortProperty); + var pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return aiSettingsService.findAiSettingsByTenantId(user.getTenantId(), pageLink); } + private static void validateSortProperty(String sortProperty) { + if (sortProperty != null && !ALLOWED_SORT_PROPERTIES.contains(sortProperty)) { + throw new IllegalArgumentException("Unsupported sort property '" + sortProperty + "'! Only '" + String.join("', '", ALLOWED_SORT_PROPERTIES) + "' are allowed."); + } + } + + @ApiOperation( + value = "Delete AI settings by ID (deleteAiSettingsById)", + notes = "Deletes the AI settings record by its `id`. " + + "If a record with the specified `id` exists, the record is deleted and the endpoint returns `true`. " + + "If no such record exists, the endpoint returns `false`." + + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") @DeleteMapping("/{aiSettingsId}") public boolean deleteAiSettingsById( - @PathVariable("aiSettingsId") UUID aiSettingsUuid, - - @AuthenticationPrincipal SecurityUser requestingUser - ) { - return aiSettingsService.deleteByTenantIdAndId(requestingUser.getTenantId(), new AiSettingsId(aiSettingsUuid)); + @Parameter( + description = "ID of the AI settings record", + required = true, + example = "de7900d4-30e2-11f0-9cd2-0242ac120002" + ) + @PathVariable("aiSettingsId") UUID aiSettingsUuid + ) throws ThingsboardException { + var user = getCurrentUser(); + var aiSettingsId = new AiSettingsId(aiSettingsUuid); + accessControlService.checkPermission(user, Resource.AI_SETTINGS, Operation.DELETE); + Optional aiSettingsOpt = aiSettingsService.findAiSettingsByTenantIdAndId(user.getTenantId(), aiSettingsId); + if (aiSettingsOpt.isEmpty()) { + return false; + } + accessControlService.checkPermission(user, Resource.AI_SETTINGS, Operation.DELETE, aiSettingsId, aiSettingsOpt.get()); + return aiSettingsService.deleteByTenantIdAndId(user.getTenantId(), aiSettingsId); } } diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 73e278389a..ef50a6552c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -63,6 +63,7 @@ import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantInfo; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiSettings; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmInfo; @@ -77,6 +78,7 @@ import org.thingsboard.server.common.data.edge.EdgeInfo; import org.thingsboard.server.common.data.exception.EntityVersionMismatchException; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AiSettingsId; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.AssetId; @@ -129,6 +131,7 @@ import org.thingsboard.server.common.data.util.ThrowingBiFunction; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.ai.AiSettingsService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -373,6 +376,9 @@ public abstract class BaseController { @Autowired protected CalculatedFieldService calculatedFieldService; + @Autowired + protected AiSettingsService aiSettingsService; + @Value("${server.log_controller_error_stack_trace}") @Getter private boolean logControllerErrorStackTrace; @@ -681,6 +687,9 @@ public abstract class BaseController { case CALCULATED_FIELD: checkCalculatedFieldId(new CalculatedFieldId(entityId.getId()), operation); return; + case AI_SETTINGS: + checkAiSettingsId(new AiSettingsId(entityId.getId()), operation); + return; default: checkEntityId(entityId, entitiesService::findEntityByTenantIdAndId, operation); } @@ -881,6 +890,10 @@ public abstract class BaseController { return checkEntityId(notificationTargetId, notificationTargetService::findNotificationTargetById, operation); } + AiSettings checkAiSettingsId(AiSettingsId aiSettingsId, Operation operation) throws ThingsboardException { + return checkEntityId(aiSettingsId, (tenantId, id) -> aiSettingsService.findAiSettingsByTenantIdAndId(tenantId, id).orElse(null), operation); + } + protected I emptyId(EntityType entityType) { return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID); } diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index 8817c24efe..097cb4e577 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -31,7 +31,7 @@ public class ControllerConstants { protected static final String ASSIGNEE_ID = "assigneeId"; protected static final String PAGE_DATA_PARAMETERS = "You can specify parameters to filter the results. " + "The result is wrapped with PageData object that allows you to iterate over result set using pagination. " + - "See the 'Model' tab of the Response Class for more details. "; + "See response schema for more details. "; protected static final String INLINE_IMAGES = "inlineImages"; protected static final String INLINE_IMAGES_DESCRIPTION = "Inline images as a data URL (Base64)"; @@ -90,6 +90,7 @@ public class ControllerConstants { protected static final String TENANT_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the tenant profile name."; protected static final String RULE_CHAIN_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the rule chain name."; protected static final String DEVICE_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the device profile name."; + protected static final String AI_SETTINGS_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the AI settings name"; protected static final String ASSET_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the asset profile name."; protected static final String CUSTOMER_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the customer title."; diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java index 9d7590f786..55b7577ebe 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java @@ -51,7 +51,8 @@ public enum Resource { NOTIFICATION(EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE), MOBILE_APP_SETTINGS, - CALCULATED_FIELD(EntityType.CALCULATED_FIELD); + CALCULATED_FIELD(EntityType.CALCULATED_FIELD), + AI_SETTINGS(EntityType.AI_SETTINGS); private final Set entityTypes; diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java index a072cf2738..990f29798c 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java @@ -18,6 +18,8 @@ package org.thingsboard.server.service.security.permission; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.id.AiSettingsId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; @@ -56,6 +58,7 @@ public class TenantAdminPermissions extends AbstractPermissions { put(Resource.MOBILE_APP, tenantEntityPermissionChecker); put(Resource.MOBILE_APP_BUNDLE, tenantEntityPermissionChecker); put(Resource.CALCULATED_FIELD, tenantEntityPermissionChecker); + put(Resource.AI_SETTINGS, aiSettingsPermissionChecker); } public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() { @@ -146,4 +149,18 @@ public class TenantAdminPermissions extends AbstractPermissions { }; + private static final PermissionChecker aiSettingsPermissionChecker = new PermissionChecker<>() { + + @Override + public boolean hasPermission(SecurityUser user, Operation operation) { + return true; + } + + @Override + public boolean hasPermission(SecurityUser user, Operation operation, AiSettingsId entityId, AiSettings entity) { + return user.getTenantId().equals(entity.getTenantId()); + } + + }; + } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java index e932901b7d..f06923b008 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java @@ -26,7 +26,7 @@ import java.util.Optional; public interface AiSettingsService extends EntityDaoService { - AiSettings save(TenantId tenantId, AiSettings aiSettings); + AiSettings save(AiSettings aiSettings); Optional findAiSettingsById(TenantId tenantId, AiSettingsId aiSettingsId); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java index 4a6168bcf9..64a2231ef7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java @@ -47,7 +47,7 @@ public final class AiSettings extends BaseData implements HasTenan TenantId tenantId; @Schema( - requiredMode = Schema.RequiredMode.NOT_REQUIRED, + requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "Version of the AI settings; increments automatically whenever the settings are changed", example = "7", @@ -66,7 +66,7 @@ public final class AiSettings extends BaseData implements HasTenan @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, - description = "Name of the LLM provider, e.g. 'openai', 'anthropic'", + description = "Name of the LLM provider", example = "openai" ) String provider; @@ -74,7 +74,7 @@ public final class AiSettings extends BaseData implements HasTenan @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, - description = "Identifier of the LLM model to use, e.g. 'gpt-4o-mini'", + description = "Identifier of the LLM model to use", example = "gpt-4o-mini" ) String model; diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java index 356bf1d19a..35710819a6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java @@ -28,6 +28,8 @@ import org.thingsboard.server.common.data.page.PageLink; import java.util.Optional; +import static org.thingsboard.server.dao.service.Validator.validatePageLink; + @Service @RequiredArgsConstructor class AiSettingsServiceImpl implements AiSettingsService { @@ -35,9 +37,8 @@ class AiSettingsServiceImpl implements AiSettingsService { private final AiSettingsDao aiSettingsDao; @Override - public AiSettings save(TenantId tenantId, AiSettings aiSettings) { - aiSettings.setTenantId(tenantId); - return aiSettingsDao.saveAndFlush(tenantId, aiSettings); + public AiSettings save(AiSettings aiSettings) { + return aiSettingsDao.saveAndFlush(aiSettings.getTenantId(), aiSettings); } @Override @@ -47,6 +48,7 @@ class AiSettingsServiceImpl implements AiSettingsService { @Override public PageData findAiSettingsByTenantId(TenantId tenantId, PageLink pageLink) { + validatePageLink(pageLink); return aiSettingsDao.findAllByTenantId(tenantId, pageLink); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java index 109557095d..e52177d247 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java @@ -15,23 +15,25 @@ */ package org.thingsboard.server.dao.sql.ai; -import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; -import org.thingsboard.server.common.data.edqs.fields.AiSettingsFields; import org.thingsboard.server.dao.model.sql.AiSettingsEntity; -import java.util.List; import java.util.Optional; import java.util.UUID; public interface AiSettingsRepository extends JpaRepository { - Page findByTenantId(UUID tenantId, Pageable pageable); + @Query("SELECT ai " + + "FROM AiSettingsEntity ai " + + "WHERE ai.tenantId = :tenantId " + + "AND (:textSearch IS NULL OR ilike(ai.name, CONCAT('%', :textSearch, '%')) = true)") + Page findByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable); Optional findByTenantIdAndId(UUID tenantId, UUID id); @@ -41,6 +43,8 @@ public interface AiSettingsRepository extends JpaRepository impl return aiSettingsRepository.findByTenantIdAndId(tenantId.getId(), aiSettingsId.getId()).map(DaoUtil::getData); } - @Override - public List findNextBatch(UUID id, int batchSize) { - return aiSettingsRepository.findNextBatch(id, Limit.of(batchSize)); - } - @Override public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { - return DaoUtil.toPageData(aiSettingsRepository.findByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); + return DaoUtil.toPageData(aiSettingsRepository.findByTenantId( + tenantId.getId(), StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), DaoUtil.toPageable(pageLink)) + ); } @Override @@ -70,7 +65,7 @@ class JpaAiSettingsDao extends JpaAbstractDao impl @Override public boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) { - return aiSettingsRepository.deleteByTenantIdAndId(tenantId.getId(), aiSettingsId.getId()); + return aiSettingsRepository.deleteByTenantIdAndId(tenantId.getId(), aiSettingsId.getId()) > 0; } @Override From 18c75998efc3903190c33680c411efca73b84a27 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 16 May 2025 15:21:17 +0300 Subject: [PATCH 006/249] AI rule node: replace Anthropic with Mistral AI --- application/pom.xml | 4 ++-- .../java/org/thingsboard/server/service/ai/AiServiceImpl.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/application/pom.xml b/application/pom.xml index 5be51761f3..976ec99fbe 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -383,11 +383,11 @@ dev.langchain4j - langchain4j-anthropic + langchain4j-google-ai-gemini dev.langchain4j - langchain4j-google-ai-gemini + langchain4j-mistral-ai diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java index 6324623177..b7c91de260 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java @@ -15,9 +15,9 @@ */ package org.thingsboard.server.service.ai; -import dev.langchain4j.model.anthropic.AnthropicChatModel; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel; +import dev.langchain4j.model.mistralai.MistralAiChatModel; import dev.langchain4j.model.openai.OpenAiChatModel; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -49,7 +49,7 @@ class AiServiceImpl implements RuleEngineAiService { .apiKey(aiSettings.getApiKey()) .modelName(aiSettings.getModel()) .build(); - case "anthropic" -> AnthropicChatModel.builder() + case "mistral-ai" -> MistralAiChatModel.builder() .apiKey(aiSettings.getApiKey()) .modelName(aiSettings.getModel()) .build(); From e695ce32837487ea9796e02c49c525d0ecc9ac14 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 16 May 2025 15:26:43 +0300 Subject: [PATCH 007/249] AI rule node: make name of the AI settings unique within the scope of the tenant --- application/src/main/data/upgrade/basic/schema_update.sql | 3 ++- .../org/thingsboard/server/common/data/ai/AiSettings.java | 2 +- .../thingsboard/server/dao/ai/AiSettingsServiceImpl.java | 8 +++++++- dao/src/main/resources/sql/schema-entities.sql | 3 ++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 8bd41a9208..f25ca02939 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -22,5 +22,6 @@ CREATE TABLE ai_settings ( name VARCHAR(255) NOT NULL, provider VARCHAR(255) NOT NULL, model VARCHAR(255) NOT NULL, - api_key VARCHAR(1000) NOT NULL + api_key VARCHAR(1000) NOT NULL, + CONSTRAINT ai_settings_name_unq_key UNIQUE (tenant_id, name) ); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java index 64a2231ef7..162828da8d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java @@ -58,7 +58,7 @@ public final class AiSettings extends BaseData implements HasTenan @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, - description = "Human-readable name of the AI settings", + description = "Human-readable name of the AI settings; must be unique within the scope of the tenant", example = "Default AI Settings" ) String name; diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java index 35710819a6..ca23b6bc56 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.page.PageLink; import java.util.Optional; +import static org.thingsboard.server.dao.entity.AbstractEntityService.checkConstraintViolation; import static org.thingsboard.server.dao.service.Validator.validatePageLink; @Service @@ -38,7 +39,12 @@ class AiSettingsServiceImpl implements AiSettingsService { @Override public AiSettings save(AiSettings aiSettings) { - return aiSettingsDao.saveAndFlush(aiSettings.getTenantId(), aiSettings); + try { + return aiSettingsDao.saveAndFlush(aiSettings.getTenantId(), aiSettings); + } catch (Exception e) { + checkConstraintViolation(e, "ai_settings_name_unq_key", "AI settings record with such name already exists!"); + throw e; + } } @Override diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index aaff15b2d4..9d5ae69c95 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -957,5 +957,6 @@ CREATE TABLE IF NOT EXISTS ai_settings ( name VARCHAR(255) NOT NULL, provider VARCHAR(255) NOT NULL, model VARCHAR(255) NOT NULL, - api_key VARCHAR(1000) NOT NULL + api_key VARCHAR(1000) NOT NULL, + CONSTRAINT ai_settings_name_unq_key UNIQUE (tenant_id, name) ); From a3a1971a6851bcb5b2daa11374be0686806a47e8 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 16 May 2025 15:37:13 +0300 Subject: [PATCH 008/249] AI rule node: add support for AI settings in TenantIdLoader --- .../thingsboard/server/actors/ActorSystemContext.java | 5 +++++ .../server/actors/ruleChain/DefaultTbContext.java | 6 ++++++ .../thingsboard/server/common/data/ai/AiSettings.java | 2 ++ .../org/thingsboard/rule/engine/api/TbContext.java | 3 +++ .../thingsboard/rule/engine/util/TenantIdLoader.java | 4 ++++ .../rule/engine/util/TenantIdLoaderTest.java | 11 +++++++++++ 6 files changed, 31 insertions(+) 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 785ac8da9e..e420d6a6f6 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -62,6 +62,7 @@ import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.common.msg.tools.TbRateLimits; import org.thingsboard.server.common.stats.TbApiUsageReportClient; +import org.thingsboard.server.dao.ai.AiSettingsService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -314,6 +315,10 @@ public class ActorSystemContext { @Getter private RuleEngineAiService aiService; + @Autowired + @Getter + private AiSettingsService aiSettingsService; + @Autowired @Getter private EntityViewService entityViewService; 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 14bab1a560..3aba1f5379 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 @@ -76,6 +76,7 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.TbMsgProcessingStackItem; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.ai.AiSettingsService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -1018,6 +1019,11 @@ public class DefaultTbContext implements TbContext { return mainCtx.getAiService(); } + @Override + public AiSettingsService getAiSettingsService() { + return mainCtx.getAiSettingsService(); + } + @Override public MqttClientSettings getMqttClientSettings() { return mainCtx.getMqttClientSettings(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java index 162828da8d..0c5b9c1a63 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java @@ -87,6 +87,8 @@ public final class AiSettings extends BaseData implements HasTenan ) String apiKey; + public AiSettings() {} + public AiSettings(AiSettingsId id) { super(id); } 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 1fc7432a08..4f9e786357 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 @@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.rule.RuleNodeState; import org.thingsboard.server.common.data.script.ScriptLanguage; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.ai.AiSettingsService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -419,6 +420,8 @@ public interface TbContext { RuleEngineAiService getAiService(); + AiSettingsService getAiSettingsService(); + // Configuration parameters for the MQTT client that is used in the MQTT node and Azure IoT hub node MqttClientSettings getMqttClientSettings(); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java index f12a856567..62ca65db1b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java @@ -19,6 +19,7 @@ import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.id.AiSettingsId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.ApiUsageStateId; import org.thingsboard.server.common.data.id.AssetId; @@ -175,6 +176,9 @@ public class TenantIdLoader { tenantEntity = null; } break; + case AI_SETTINGS: + tenantEntity = ctx.getAiSettingsService().findAiSettingsById(ctxTenantId, new AiSettingsId(id)).orElse(null); + break; default: throw new RuntimeException("Unexpected entity type: " + entityId.getEntityType()); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java index 38417c3922..c763a1b069 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java @@ -40,6 +40,7 @@ import org.thingsboard.server.common.data.OtaPackage; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiSettings; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; @@ -68,6 +69,7 @@ import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.ai.AiSettingsService; import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.customer.CustomerService; @@ -92,6 +94,7 @@ import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetsBundleService; +import java.util.Optional; import java.util.UUID; import static org.mockito.ArgumentMatchers.any; @@ -160,6 +163,8 @@ public class TenantIdLoaderTest { private MobileAppBundleService mobileAppBundleService; @Mock private CalculatedFieldService calculatedFieldService; + @Mock + private AiSettingsService aiSettingsService; private TenantId tenantId; private TenantProfileId tenantProfileId; @@ -419,6 +424,12 @@ public class TenantIdLoaderTest { when(ctx.getCalculatedFieldService()).thenReturn(calculatedFieldService); doReturn(calculatedFieldLink).when(calculatedFieldService).findCalculatedFieldLinkById(eq(tenantId), any()); break; + case AI_SETTINGS: + AiSettings aiSettings = new AiSettings(); + aiSettings.setTenantId(tenantId); + when(ctx.getAiSettingsService()).thenReturn(aiSettingsService); + doReturn(Optional.of(aiSettings)).when(aiSettingsService).findAiSettingsById(eq(tenantId), any()); + break; default: throw new RuntimeException("Unexpected originator EntityType " + entityType); } From ad0161e3dfddd60b50f1ed02b5db4f9562eaf710 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 16 May 2025 16:46:00 +0300 Subject: [PATCH 009/249] AI rule node: add AiProvider enum --- .../server/service/ai/AiServiceImpl.java | 7 +++--- .../server/common/data/ai/AiProvider.java | 24 +++++++++++++++++++ .../server/common/data/ai/AiSettings.java | 12 ++++++---- .../dao/model/sql/AiSettingsEntity.java | 6 ++++- .../thingsboard/rule/engine/ai/TbAiNode.java | 2 +- 5 files changed, 40 insertions(+), 11 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/AiProvider.java diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java index b7c91de260..9306ce5c71 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java @@ -45,19 +45,18 @@ class AiServiceImpl implements RuleEngineAiService { var aiSettings = aiSettingsOpt.get(); return switch (aiSettings.getProvider()) { - case "openai" -> OpenAiChatModel.builder() + case OPENAI -> OpenAiChatModel.builder() .apiKey(aiSettings.getApiKey()) .modelName(aiSettings.getModel()) .build(); - case "mistral-ai" -> MistralAiChatModel.builder() + case MISTRAL_AI -> MistralAiChatModel.builder() .apiKey(aiSettings.getApiKey()) .modelName(aiSettings.getModel()) .build(); - case "google-ai-gemini" -> GoogleAiGeminiChatModel.builder() + case GOOGLE_AI_GEMINI -> GoogleAiGeminiChatModel.builder() .apiKey(aiSettings.getApiKey()) .modelName(aiSettings.getModel()) .build(); - default -> throw new IllegalArgumentException("Unsupported AI provider: " + aiSettings.getProvider()); }; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiProvider.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiProvider.java new file mode 100644 index 0000000000..e2938cdddc --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiProvider.java @@ -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. + */ +package org.thingsboard.server.common.data.ai; + +public enum AiProvider { + + OPENAI, + GOOGLE_AI_GEMINI, + MISTRAL_AI + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java index 0c5b9c1a63..81a27ae914 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java @@ -66,15 +66,17 @@ public final class AiSettings extends BaseData implements HasTenan @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, - description = "Name of the LLM provider", - example = "openai" + description = "Name of the AI provider", + example = "OPENAI", + allowableValues = {"OPENAI", "GOOGLE_AI_GEMINI", "MISTRAL_AI"}, + type = "string" ) - String provider; + AiProvider provider; @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, - description = "Identifier of the LLM model to use", + description = "Identifier of the AI model to use", example = "gpt-4o-mini" ) String model; @@ -82,7 +84,7 @@ public final class AiSettings extends BaseData implements HasTenan @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.WRITE_ONLY, - description = "API key for authenticating with the selected LLM provider", + description = "API key for authenticating with the selected AI provider", example = "sk-********************************" ) String apiKey; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java index f238f57d4b..f473fe6acb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java @@ -17,11 +17,14 @@ package org.thingsboard.server.dao.model.sql; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Table; import lombok.Getter; import lombok.Setter; import lombok.ToString; import org.hibernate.proxy.HibernateProxy; +import org.thingsboard.server.common.data.ai.AiProvider; import org.thingsboard.server.common.data.ai.AiSettings; import org.thingsboard.server.common.data.id.AiSettingsId; import org.thingsboard.server.common.data.id.TenantId; @@ -44,8 +47,9 @@ public class AiSettingsEntity extends BaseVersionedEntity { @Column(name = ModelConstants.AI_SETTINGS_NAME_COLUMN_NAME, nullable = false) private String name; + @Enumerated(EnumType.STRING) @Column(name = ModelConstants.AI_SETTINGS_PROVIDER_COLUMN_NAME, nullable = false) - private String provider; + private AiProvider provider; @Column(name = ModelConstants.AI_SETTINGS_MODEL_COLUMN_NAME, nullable = false) private String model; 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 5682529224..8810e20599 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 @@ -48,7 +48,7 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie type = ComponentType.EXTERNAL, name = "AI", nodeDescription = "Interact with AI", - nodeDetails = "This node makes requests to LLM based on a prompt and a input message and returns a response in a form of output message", + nodeDetails = "This node makes requests to AI based on a prompt and a input message and returns a response in a form of output message", configClazz = TbAiNodeConfiguration.class ) public final class TbAiNode extends TbAbstractExternalNode implements TbNode { From f2075c6c39c58a84f0d32a455ba3cb2733cabe60 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 16 May 2025 20:06:42 +0300 Subject: [PATCH 010/249] AI rule node: add polymorphic JSON config to AI settings --- .../main/data/upgrade/basic/schema_update.sql | 16 +++---- .../server/service/ai/AiServiceImpl.java | 6 +-- .../server/common/data/ai/AiConfig.java | 47 +++++++++++++++++++ .../server/common/data/ai/AiSettings.java | 7 ++- .../common/data/ai/GoogleAiGeminiConfig.java | 40 ++++++++++++++++ .../common/data/ai/MistralAiConfig.java | 40 ++++++++++++++++ .../server/common/data/ai/OpenAiConfig.java | 40 ++++++++++++++++ .../server/common/data/id/AiSettingsId.java | 2 +- .../server/dao/model/ModelConstants.java | 2 +- .../dao/model/sql/AiSettingsEntity.java | 14 ++++-- .../main/resources/sql/schema-entities.sql | 16 +++---- 11 files changed, 200 insertions(+), 30 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/AiConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/GoogleAiGeminiConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/MistralAiConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/OpenAiConfig.java diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index f25ca02939..e148403be2 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -15,13 +15,13 @@ -- CREATE TABLE ai_settings ( - id UUID NOT NULL PRIMARY KEY, - created_time BIGINT NOT NULL, - tenant_id UUID NOT NULL, - version BIGINT NOT NULL DEFAULT 1, - name VARCHAR(255) NOT NULL, - provider VARCHAR(255) NOT NULL, - model VARCHAR(255) NOT NULL, - api_key VARCHAR(1000) NOT NULL, + id UUID NOT NULL PRIMARY KEY, + created_time BIGINT NOT NULL, + tenant_id UUID NOT NULL, + version BIGINT NOT NULL DEFAULT 1, + name VARCHAR(255) NOT NULL, + provider VARCHAR(255) NOT NULL, + model VARCHAR(255) NOT NULL, + configuration JSONB NOT NULL, CONSTRAINT ai_settings_name_unq_key UNIQUE (tenant_id, name) ); diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java index 9306ce5c71..7207501d7e 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java @@ -46,15 +46,15 @@ class AiServiceImpl implements RuleEngineAiService { return switch (aiSettings.getProvider()) { case OPENAI -> OpenAiChatModel.builder() - .apiKey(aiSettings.getApiKey()) + .apiKey(aiSettings.getConfiguration().getApiKey()) .modelName(aiSettings.getModel()) .build(); case MISTRAL_AI -> MistralAiChatModel.builder() - .apiKey(aiSettings.getApiKey()) + .apiKey(aiSettings.getConfiguration().getApiKey()) .modelName(aiSettings.getModel()) .build(); case GOOGLE_AI_GEMINI -> GoogleAiGeminiChatModel.builder() - .apiKey(aiSettings.getApiKey()) + .apiKey(aiSettings.getConfiguration().getApiKey()) .modelName(aiSettings.getModel()) .build(); }; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiConfig.java new file mode 100644 index 0000000000..5c37d21454 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiConfig.java @@ -0,0 +1,47 @@ +/** + * 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; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "provider", + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = OpenAiConfig.class, name = "OPENAI"), + @JsonSubTypes.Type(value = GoogleAiGeminiConfig.class, name = "GOOGLE_AI_GEMINI"), + @JsonSubTypes.Type(value = MistralAiConfig.class, name = "MISTRAL_AI") +}) +public abstract class AiConfig { + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "API key for authenticating with the AI provider", + example = "sk-********************************" + ) + private String apiKey; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java index 81a27ae914..22aff4bb62 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java @@ -83,11 +83,10 @@ public final class AiSettings extends BaseData implements HasTenan @Schema( requiredMode = Schema.RequiredMode.REQUIRED, - accessMode = Schema.AccessMode.WRITE_ONLY, - description = "API key for authenticating with the selected AI provider", - example = "sk-********************************" + accessMode = Schema.AccessMode.READ_WRITE, + description = "Provider-specific settings for the chosen AI model" ) - String apiKey; + AiConfig configuration; public AiSettings() {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/GoogleAiGeminiConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/GoogleAiGeminiConfig.java new file mode 100644 index 0000000000..915fb41d55 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/GoogleAiGeminiConfig.java @@ -0,0 +1,40 @@ +/** + * 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; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@Schema( + name = "GoogleAiGemini", + description = "Configuration properties for the Google AI Gemini" +) +public class GoogleAiGeminiConfig extends AiConfig { + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "Name of the AI provider", + example = "GOOGLE_AI_GEMINI", + allowableValues = "GOOGLE_AI_GEMINI", + type = "string" + ) + private AiProvider provider = AiProvider.GOOGLE_AI_GEMINI; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/MistralAiConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/MistralAiConfig.java new file mode 100644 index 0000000000..bbfd849f59 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/MistralAiConfig.java @@ -0,0 +1,40 @@ +/** + * 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; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@Schema( + name = "MistralAi", + description = "Configuration properties for the Mistral AI" +) +public class MistralAiConfig extends AiConfig { + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "Name of the AI provider", + example = "MISTRAL_AI", + allowableValues = "MISTRAL_AI", + type = "string" + ) + private AiProvider provider = AiProvider.MISTRAL_AI; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/OpenAiConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/OpenAiConfig.java new file mode 100644 index 0000000000..cce6e3aa90 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/OpenAiConfig.java @@ -0,0 +1,40 @@ +/** + * 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; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@Schema( + name = "OpenAiConfig", + description = "Configuration properties for the OpenAI" +) +public class OpenAiConfig extends AiConfig { + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "Name of the AI provider", + example = "OPENAI", + allowableValues = "OPENAI", + type = "string" + ) + private AiProvider provider = AiProvider.OPENAI; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AiSettingsId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AiSettingsId.java index f9b6ab74fe..8aac27051a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AiSettingsId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AiSettingsId.java @@ -36,7 +36,7 @@ public final class AiSettingsId extends UUIDBased implements EntityId { @Override @Schema( requiredMode = Schema.RequiredMode.REQUIRED, - description = "Entity type of the AI settings, always 'AI_SETTINGS'", + description = "Entity type of the AI settings", example = "AI_SETTINGS", allowableValues = "AI_SETTINGS" ) diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index ac09ce088b..20ef6ceb08 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -747,7 +747,7 @@ public class ModelConstants { public static final String AI_SETTINGS_NAME_COLUMN_NAME = NAME_PROPERTY; public static final String AI_SETTINGS_PROVIDER_COLUMN_NAME = "provider"; public static final String AI_SETTINGS_MODEL_COLUMN_NAME = "model"; - public static final String AI_SETTINGS_API_KEY_COLUMN_NAME = "api_key"; + public static final String AI_SETTINGS_CONFIGURATION_COLUMN_NAME = "configuration"; protected static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, JSON_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN}; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java index f473fe6acb..458b434711 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.model.sql; +import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -23,7 +24,9 @@ import jakarta.persistence.Table; import lombok.Getter; import lombok.Setter; import lombok.ToString; +import org.hibernate.annotations.Type; import org.hibernate.proxy.HibernateProxy; +import org.thingsboard.server.common.data.ai.AiConfig; import org.thingsboard.server.common.data.ai.AiProvider; import org.thingsboard.server.common.data.ai.AiSettings; import org.thingsboard.server.common.data.id.AiSettingsId; @@ -41,7 +44,7 @@ import java.util.UUID; @Table(name = ModelConstants.AI_SETTINGS_TABLE_NAME) public class AiSettingsEntity extends BaseVersionedEntity { - @Column(name = ModelConstants.AI_SETTINGS_TENANT_ID_COLUMN_NAME, nullable = false, columnDefinition = "uuid") + @Column(name = ModelConstants.AI_SETTINGS_TENANT_ID_COLUMN_NAME, nullable = false, columnDefinition = "UUID") private UUID tenantId; @Column(name = ModelConstants.AI_SETTINGS_NAME_COLUMN_NAME, nullable = false) @@ -54,8 +57,9 @@ public class AiSettingsEntity extends BaseVersionedEntity { @Column(name = ModelConstants.AI_SETTINGS_MODEL_COLUMN_NAME, nullable = false) private String model; - @Column(name = ModelConstants.AI_SETTINGS_API_KEY_COLUMN_NAME, nullable = false) - private String apiKey; + @Type(JsonBinaryType.class) + @Column(name = ModelConstants.AI_SETTINGS_CONFIGURATION_COLUMN_NAME, nullable = false, columnDefinition = "JSONB") + private AiConfig configuration; public AiSettingsEntity() {} @@ -65,7 +69,7 @@ public class AiSettingsEntity extends BaseVersionedEntity { name = aiSettings.getName(); provider = aiSettings.getProvider(); model = aiSettings.getModel(); - apiKey = aiSettings.getApiKey(); + configuration = aiSettings.getConfiguration(); } @Override @@ -77,7 +81,7 @@ public class AiSettingsEntity extends BaseVersionedEntity { settings.setName(name); settings.setProvider(provider); settings.setModel(model); - settings.setApiKey(apiKey); + settings.setConfiguration(configuration); return settings; } diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 9d5ae69c95..b2b9ffaded 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -950,13 +950,13 @@ CREATE TABLE IF NOT EXISTS cf_debug_event ( ) PARTITION BY RANGE (ts); CREATE TABLE IF NOT EXISTS ai_settings ( - id UUID NOT NULL PRIMARY KEY, - created_time BIGINT NOT NULL, - tenant_id UUID NOT NULL, - version BIGINT NOT NULL DEFAULT 1, - name VARCHAR(255) NOT NULL, - provider VARCHAR(255) NOT NULL, - model VARCHAR(255) NOT NULL, - api_key VARCHAR(1000) NOT NULL, + id UUID NOT NULL PRIMARY KEY, + created_time BIGINT NOT NULL, + tenant_id UUID NOT NULL, + version BIGINT NOT NULL DEFAULT 1, + name VARCHAR(255) NOT NULL, + provider VARCHAR(255) NOT NULL, + model VARCHAR(255) NOT NULL, + configuration JSONB NOT NULL, CONSTRAINT ai_settings_name_unq_key UNIQUE (tenant_id, name) ); From 7c94c43d66997f817e7f2ec2b27f806db2ba8a89 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 20 May 2025 16:16:30 +0300 Subject: [PATCH 011/249] AI rule node: refactor configuration field description in AiSettings --- .../java/org/thingsboard/server/common/data/ai/AiSettings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java index 22aff4bb62..d3ef2fa419 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java @@ -84,7 +84,7 @@ public final class AiSettings extends BaseData implements HasTenan @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, - description = "Provider-specific settings for the chosen AI model" + description = "Settings specific to the selected AI provider and model" ) AiConfig configuration; From d2c275b761d06ea626fd4d4a0f592ce3a65146d7 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 20 May 2025 16:16:51 +0300 Subject: [PATCH 012/249] AI rule node: correctly call node lifecycle methods --- .../main/java/org/thingsboard/rule/engine/ai/TbAiNode.java | 4 ++++ 1 file changed, 4 insertions(+) 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 8810e20599..e3e9bdbf61 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 @@ -59,6 +59,8 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { + super.init(ctx); + var config = TbNodeUtils.convert(configuration, TbAiNodeConfiguration.class); String errorPrefix = "'" + ctx.getSelf().getName() + "' node configuration is invalid: "; try { @@ -66,6 +68,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { } catch (DataValidationException e) { throw new TbNodeException(e, true); } + systemMessage = SystemMessage.from(config.getSystemPrompt()); userPromptTemplate = PromptTemplate.from(""" User-provided task or question: %s @@ -114,6 +117,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { @Override public void destroy() { + super.destroy(); systemMessage = null; userPromptTemplate = null; chatModel = null; From 70930325164e7616b57bc5d5acda0d11c0e22576 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Sat, 10 May 2025 14:24:04 +0200 Subject: [PATCH 013/249] refactored obsolete com.github.java-json-tools:json-schema-validator with actively updated com.networknt:json-schema-validator --- common/cluster-api/pom.xml | 4 - common/dao-api/pom.xml | 4 - dao/pom.xml | 4 + .../BaseComponentDescriptorService.java | 20 ++-- .../BaseComponentDescriptorServiceTest.java | 98 +++++++++++++++++++ pom.xml | 10 +- 6 files changed, 116 insertions(+), 24 deletions(-) create mode 100644 dao/src/test/java/org/thingsboard/server/dao/component/BaseComponentDescriptorServiceTest.java diff --git a/common/cluster-api/pom.xml b/common/cluster-api/pom.xml index d92b87fbb0..d2ad3b48a8 100644 --- a/common/cluster-api/pom.xml +++ b/common/cluster-api/pom.xml @@ -60,10 +60,6 @@ jakarta.annotation jakarta.annotation-api - - com.github.java-json-tools - json-schema-validator - org.slf4j slf4j-api diff --git a/common/dao-api/pom.xml b/common/dao-api/pom.xml index cb8e578760..a4b4d807dd 100644 --- a/common/dao-api/pom.xml +++ b/common/dao-api/pom.xml @@ -56,10 +56,6 @@ jakarta.annotation jakarta.annotation-api - - com.github.java-json-tools - json-schema-validator - org.slf4j slf4j-api diff --git a/dao/pom.xml b/dao/pom.xml index 9dca643b5d..2206d8a7d7 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -59,6 +59,10 @@ org.thingsboard.common util + + com.networknt + json-schema-validator + org.slf4j slf4j-api diff --git a/dao/src/main/java/org/thingsboard/server/dao/component/BaseComponentDescriptorService.java b/dao/src/main/java/org/thingsboard/server/dao/component/BaseComponentDescriptorService.java index 5acf1b0f97..2c5d6fb3c5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/component/BaseComponentDescriptorService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/component/BaseComponentDescriptorService.java @@ -16,10 +16,10 @@ package org.thingsboard.server.dao.component; import com.fasterxml.jackson.databind.JsonNode; -import com.github.fge.jsonschema.core.exceptions.ProcessingException; -import com.github.fge.jsonschema.core.report.ProcessingReport; -import com.github.fge.jsonschema.main.JsonSchemaFactory; -import com.github.fge.jsonschema.main.JsonValidator; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -36,6 +36,7 @@ import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.Validator; import java.util.Optional; +import java.util.Set; /** * @author Andrew Shvayka @@ -89,15 +90,18 @@ public class BaseComponentDescriptorService implements ComponentDescriptorServic @Override public boolean validate(TenantId tenantId, ComponentDescriptor component, JsonNode configuration) { - JsonValidator validator = JsonSchemaFactory.byDefault().getValidator(); try { if (!component.getConfigurationDescriptor().has("schema")) { throw new DataValidationException("Configuration descriptor doesn't contain schema property!"); } JsonNode configurationSchema = component.getConfigurationDescriptor().get("schema"); - ProcessingReport report = validator.validate(configurationSchema, configuration); - return report.isSuccess(); - } catch (ProcessingException e) { + + JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V4); + JsonSchema schema = factory.getSchema(configurationSchema); + + Set validationMessages = schema.validate(configuration); + return validationMessages.isEmpty(); + } catch (Exception e) { throw new IncorrectParameterException(e.getMessage(), e); } } diff --git a/dao/src/test/java/org/thingsboard/server/dao/component/BaseComponentDescriptorServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/component/BaseComponentDescriptorServiceTest.java new file mode 100644 index 0000000000..d76c11f708 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/component/BaseComponentDescriptorServiceTest.java @@ -0,0 +1,98 @@ +/** + * 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.component; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentClusteringMode; +import org.thingsboard.server.common.data.plugin.ComponentDescriptor; +import org.thingsboard.server.common.data.plugin.ComponentScope; +import org.thingsboard.server.common.data.plugin.ComponentType; +import org.thingsboard.server.dao.exception.IncorrectParameterException; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BaseComponentDescriptorServiceTest { + + private BaseComponentDescriptorService service; + private ComponentDescriptor componentDescriptor; + private TenantId tenantId; + + @BeforeEach + void setUp() { + service = Mockito.spy(BaseComponentDescriptorService.class); + tenantId = TenantId.SYS_TENANT_ID; + + // Create a simple component descriptor + componentDescriptor = new ComponentDescriptor(); + componentDescriptor.setType(ComponentType.ACTION); + componentDescriptor.setScope(ComponentScope.TENANT); + componentDescriptor.setClusteringMode(ComponentClusteringMode.ENABLED); + componentDescriptor.setName("Test Component"); + componentDescriptor.setClazz("org.thingsboard.test.TestComponent"); + + // Create configuration descriptor with schema from JSON string + String configDescriptorJson = """ + { + "schema": { + "type": "object", + "properties": { + "testField": { + "type": "string" + } + }, + "required": ["testField"] + } + }"""; + + componentDescriptor.setConfigurationDescriptor(JacksonUtil.toJsonNode(configDescriptorJson)); + } + + @Test + void testValidate() { + // Create valid configuration from JSON string + String validConfigJson = "{\"testField\": \"test value\"}"; + JsonNode validConfig = JacksonUtil.toJsonNode(validConfigJson); + + // Create invalid configuration (missing required field) from JSON string + String invalidConfigJson = "{}"; + JsonNode invalidConfig = JacksonUtil.toJsonNode(invalidConfigJson); + + // Test valid configuration + boolean validResult = service.validate(tenantId, componentDescriptor, validConfig); + assertTrue(validResult, "Valid configuration should pass validation"); + + // Test invalid configuration + boolean invalidResult = service.validate(tenantId, componentDescriptor, invalidConfig); + assertFalse(invalidResult, "Invalid configuration should fail validation"); + + // Test with component descriptor without schema + ComponentDescriptor noSchemaDescriptor = new ComponentDescriptor(componentDescriptor); + noSchemaDescriptor.setConfigurationDescriptor(JacksonUtil.toJsonNode("{}")); + + // Should throw exception when schema is missing + assertThrows(IncorrectParameterException.class, () -> { + service.validate(tenantId, noSchemaDescriptor, validConfig); + }, "Should throw exception when schema is missing"); + } + +} diff --git a/pom.xml b/pom.xml index 074bbbce03..02d950b830 100755 --- a/pom.xml +++ b/pom.xml @@ -74,7 +74,7 @@ 2.17.2 1.7.0 4.4.0 - 2.2.14 + 1.5.6 0.6.12 3.12.1 2.0.0-M15 @@ -1601,15 +1601,9 @@ ${auth0-jwt.version} - com.github.java-json-tools + com.networknt json-schema-validator ${json-schema-validator.version} - - - com.sun.mail - mailapi - - org.eclipse.leshan From c2776ce7a6371d1ce0262c9e06f6cf3eac14a831 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 20 May 2025 17:59:58 +0300 Subject: [PATCH 014/249] AI rule node: add JSON Schema to rule node config --- common/util/pom.xml | 5 +- .../common/util/JsonSchemaUtils.java | 47 +++++++++++++++++++ .../rule/engine/ai/TbAiNodeConfiguration.java | 20 ++++++-- 3 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 common/util/src/main/java/org/thingsboard/common/util/JsonSchemaUtils.java diff --git a/common/util/pom.xml b/common/util/pom.xml index 6719dc628a..a50cead962 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -114,7 +114,10 @@ net.objecthunter exp4j - ${exp4j.version} + + + com.networknt + json-schema-validator diff --git a/common/util/src/main/java/org/thingsboard/common/util/JsonSchemaUtils.java b/common/util/src/main/java/org/thingsboard/common/util/JsonSchemaUtils.java new file mode 100644 index 0000000000..db45994826 --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/JsonSchemaUtils.java @@ -0,0 +1,47 @@ +/** + * 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.common.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SchemaId; +import com.networknt.schema.SchemaLocation; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; + +import java.util.Set; + +public final class JsonSchemaUtils { + + private JsonSchemaUtils() { + throw new AssertionError("Can't instantiate utility class"); + } + + /** + * Validates that the provided JsonNode is a valid JSON Schema (Draft 2020-12). + * + * @param schemaNode the JSON Schema document as a JsonNode + * @return true if the schema is well-formed, false otherwise + */ + public static boolean isValidJsonSchema(JsonNode schemaNode) { + Set errors = JsonSchemaFactory + .getInstance(SpecVersion.VersionFlag.V202012) + .getSchema(SchemaLocation.of(SchemaId.V202012)) + .validate(schemaNode); + return errors.isEmpty(); + } + +} 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 9d8ac5fbd1..6dd4337aed 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 @@ -15,9 +15,12 @@ */ package org.thingsboard.rule.engine.ai; +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data; +import org.thingsboard.common.util.JsonSchemaUtils; import org.thingsboard.rule.engine.api.NodeConfiguration; import org.thingsboard.server.common.data.id.AiSettingsId; import org.thingsboard.server.common.data.validation.Length; @@ -36,15 +39,22 @@ public class TbAiNodeConfiguration implements NodeConfiguration Date: Tue, 20 May 2025 18:51:10 +0300 Subject: [PATCH 015/249] AI rule node: bump langchain4j version --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 02d950b830..c68cc9e1e1 100755 --- a/pom.xml +++ b/pom.xml @@ -134,7 +134,7 @@ 1.7.5 3.8.0 2.9.0 - 1.0.0 + 1.0.1 4.2.1 2.7.3 From b64b5795a3b7b0a9dbc6df1f0bbfc23b5dbc1c41 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Wed, 21 May 2025 16:03:26 +0300 Subject: [PATCH 016/249] AI rule node: change default prompts --- .../rule/engine/ai/TbAiNodeConfiguration.java | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) 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 6dd4337aed..380e5ccd55 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 @@ -49,13 +49,8 @@ public class TbAiNodeConfiguration implements NodeConfiguration Date: Wed, 21 May 2025 16:04:31 +0300 Subject: [PATCH 017/249] AI rule node: support text and JSON Schema response formats --- .../ai/Langchain4jJsonSchemaAdapter.java | 135 ++++++++++++++++++ .../thingsboard/rule/engine/ai/TbAiNode.java | 34 ++++- .../rule/engine/ai/TbAiNodeConfiguration.java | 5 + 3 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java new file mode 100644 index 0000000000..9ad745f3ed --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java @@ -0,0 +1,135 @@ +/** + * 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.rule.engine.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import dev.langchain4j.model.chat.request.json.JsonArraySchema; +import dev.langchain4j.model.chat.request.json.JsonBooleanSchema; +import dev.langchain4j.model.chat.request.json.JsonEnumSchema; +import dev.langchain4j.model.chat.request.json.JsonIntegerSchema; +import dev.langchain4j.model.chat.request.json.JsonNullSchema; +import dev.langchain4j.model.chat.request.json.JsonNumberSchema; +import dev.langchain4j.model.chat.request.json.JsonObjectSchema; +import dev.langchain4j.model.chat.request.json.JsonSchema; +import dev.langchain4j.model.chat.request.json.JsonSchemaElement; +import dev.langchain4j.model.chat.request.json.JsonStringSchema; + +import java.util.ArrayList; +import java.util.List; + +/** + * Converts a Jackson {@link JsonNode} JSON Schema into a Langchain4j {@link JsonSchema} model. + */ +final class Langchain4jJsonSchemaAdapter { + + private Langchain4jJsonSchemaAdapter() { + throw new AssertionError("Can't instantiate utility class"); + } + + /** + * Creates a Langchain4j {@link JsonSchema} from the given root JSON Schema node. + * + * @param rootSchemaNode a valid JSON Schema as a Jackson {@link JsonNode} + * @return the corresponding Langchain4j {@link JsonSchema} + */ + public static JsonSchema fromJsonNode(JsonNode rootSchemaNode) { + return JsonSchema.builder() + .name(rootSchemaNode.get("title").textValue()) + .rootElement(parse(rootSchemaNode)) + .build(); + } + + private static JsonSchemaElement parse(JsonNode schemaNode) { + String description = schemaNode.hasNonNull("description") ? schemaNode.get("description").textValue() : null; + + if (schemaNode.has("enum")) { // enum schemas can be defined without 'type' + return parseEnum(schemaNode).description(description).build(); + } + + String type = schemaNode.get("type").textValue(); + + return switch (type) { + case "string" -> JsonStringSchema.builder().description(description).build(); + case "integer" -> JsonIntegerSchema.builder().description(description).build(); + case "boolean" -> JsonBooleanSchema.builder().description(description).build(); + case "number" -> JsonNumberSchema.builder().description(description).build(); + case "null" -> new JsonNullSchema(); + case "object" -> parseObject(schemaNode).description(description).build(); + case "array" -> parseArray(schemaNode).description(description).build(); + default -> throw new IllegalArgumentException("Unsupported JSON Schema type: " + type); + }; + } + + private static JsonEnumSchema.Builder parseEnum(JsonNode enumSchema) { + var builder = new JsonEnumSchema.Builder(); + + List enumValues = new ArrayList<>(); + for (JsonNode element : enumSchema.get("enum")) { + if (!element.isTextual()) { + throw new IllegalArgumentException("Expected each 'enum' element to be a string, but found: " + element.getNodeType()); + } + enumValues.add(element.textValue()); + } + builder.enumValues(enumValues); + + return builder; + } + + private static JsonObjectSchema.Builder parseObject(JsonNode objectSchema) { + var builder = new JsonObjectSchema.Builder(); + + JsonNode propertiesNode = objectSchema.get("properties"); + if (propertiesNode != null) { + propertiesNode.fields().forEachRemaining(entry -> { + String key = entry.getKey(); + JsonNode value = entry.getValue(); + builder.addProperty(key, parse(value)); + }); + } + + List required = new ArrayList<>(); + JsonNode requiredNode = objectSchema.get("required"); + if (requiredNode != null) { + for (JsonNode value : requiredNode) { + required.add(value.textValue()); + } + } + builder.required(required); + + boolean additionalProperties = true; // default value if 'additionalProperties' is not set + JsonNode additionalPropertiesNode = objectSchema.get("additionalProperties"); + if (additionalPropertiesNode != null) { + if (!additionalPropertiesNode.isBoolean()) { + throw new IllegalArgumentException("Expected 'additionalProperties' to be a boolean, but found: " + additionalPropertiesNode.getNodeType()); + } + additionalProperties = additionalPropertiesNode.booleanValue(); + } + builder.additionalProperties(additionalProperties); + + return builder; + } + + private static JsonArraySchema.Builder parseArray(JsonNode arraySchema) { + var builder = new JsonArraySchema.Builder(); + + if (arraySchema.hasNonNull("items")) { + builder.items(parse(arraySchema.get("items"))); + } + + return builder; + } + +} 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 e3e9bdbf61..315a02a3b9 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 @@ -15,6 +15,7 @@ */ package org.thingsboard.rule.engine.ai; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.ListenableFuture; import dev.langchain4j.data.message.SystemMessage; @@ -22,6 +23,8 @@ import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.request.ResponseFormat; +import dev.langchain4j.model.chat.request.ResponseFormatType; +import dev.langchain4j.model.chat.request.json.JsonSchema; import dev.langchain4j.model.input.PromptTemplate; import org.checkerframework.checker.nullness.qual.NonNull; import org.thingsboard.common.util.JacksonUtil; @@ -55,6 +58,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { private SystemMessage systemMessage; private PromptTemplate userPromptTemplate; + private ResponseFormat responseFormat; private ChatModel chatModel; @Override @@ -69,6 +73,11 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { throw new TbNodeException(e, true); } + responseFormat = ResponseFormat.builder() + .type(config.getResponseFormatType()) + .jsonSchema(getJsonSchema(config.getResponseFormatType(), config.getJsonSchema())) + .build(); + systemMessage = SystemMessage.from(config.getSystemPrompt()); userPromptTemplate = PromptTemplate.from(""" User-provided task or question: %s @@ -80,6 +89,13 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { chatModel = ctx.getAiService().configureChatModel(ctx.getTenantId(), config.getAiSettingsId()); } + private static JsonSchema getJsonSchema(ResponseFormatType responseFormatType, JsonNode jsonSchema) { + if (responseFormatType == ResponseFormatType.TEXT) { + return null; + } + return responseFormatType == ResponseFormatType.JSON && jsonSchema != null ? Langchain4jJsonSchemaAdapter.fromJsonNode(jsonSchema) : null; + } + @Override public void onMsg(TbContext ctx, TbMsg msg) { var ackedMsg = ackIfNeeded(ctx, msg); @@ -93,12 +109,15 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { var chatRequest = ChatRequest.builder() .messages(List.of(systemMessage, userMessage)) - .responseFormat(ResponseFormat.JSON) + .responseFormat(responseFormat) .build(); addCallback(sendChatRequest(ctx, chatRequest), new FutureCallback<>() { @Override public void onSuccess(String response) { + if (!isValidJson(response)) { + response = wrapInJsonObject(response); + } tellSuccess(ctx, ackedMsg.transform() .data(response) .build()); @@ -115,11 +134,24 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { return ctx.getExternalCallExecutor().submit(() -> chatModel.chat(chatRequest).aiMessage().text()); } + private static boolean isValidJson(String jsonString) { + try { + return JacksonUtil.toJsonNode(jsonString) != null; + } catch (IllegalArgumentException e) { + return false; + } + } + + private static String wrapInJsonObject(String response) { + return JacksonUtil.newObjectNode().put("response", response).toString(); + } + @Override public void destroy() { super.destroy(); systemMessage = null; userPromptTemplate = null; + responseFormat = null; chatModel = 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 380e5ccd55..c3234f61bb 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 @@ -16,6 +16,7 @@ package org.thingsboard.rule.engine.ai; import com.fasterxml.jackson.databind.JsonNode; +import dev.langchain4j.model.chat.request.ResponseFormatType; import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -39,6 +40,9 @@ public class TbAiNodeConfiguration implements NodeConfiguration Date: Thu, 22 May 2025 11:28:21 +0300 Subject: [PATCH 018/249] AI rule node: split AI config into AI provider config and AI model config; add support for model temperature --- .../main/data/upgrade/basic/schema_update.sql | 17 ++++--- .../server/service/ai/AiServiceImpl.java | 47 ++++++++++++----- .../server/common/data/ai/AiSettings.java | 21 ++++++-- .../common/data/ai/model/AiModelConfig.java | 47 +++++++++++++++++ .../model/GoogleAiGeminiChatModelConfig.java | 50 +++++++++++++++++++ .../ai/model/MistralAiChatModelConfig.java | 50 +++++++++++++++++++ .../data/ai/model/OpenAiChatModelConfig.java | 50 +++++++++++++++++++ .../data/ai/{ => provider}/AiProvider.java | 2 +- .../AiProviderConfig.java} | 13 +++-- .../GoogleAiGeminiProviderConfig.java} | 8 +-- .../MistralAiProviderConfig.java} | 8 +-- .../OpenAiProviderConfig.java} | 8 +-- .../server/dao/model/ModelConstants.java | 3 +- .../dao/model/sql/AiSettingsEntity.java | 19 ++++--- .../main/resources/sql/schema-entities.sql | 17 ++++--- 15 files changed, 301 insertions(+), 59 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/GoogleAiGeminiChatModelConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/MistralAiChatModelConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/OpenAiChatModelConfig.java rename common/data/src/main/java/org/thingsboard/server/common/data/ai/{ => provider}/AiProvider.java (92%) rename common/data/src/main/java/org/thingsboard/server/common/data/ai/{AiConfig.java => provider/AiProviderConfig.java} (76%) rename common/data/src/main/java/org/thingsboard/server/common/data/ai/{GoogleAiGeminiConfig.java => provider/GoogleAiGeminiProviderConfig.java} (82%) rename common/data/src/main/java/org/thingsboard/server/common/data/ai/{MistralAiConfig.java => provider/MistralAiProviderConfig.java} (82%) rename common/data/src/main/java/org/thingsboard/server/common/data/ai/{OpenAiConfig.java => provider/OpenAiProviderConfig.java} (83%) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index e148403be2..9685847278 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -15,13 +15,14 @@ -- CREATE TABLE ai_settings ( - id UUID NOT NULL PRIMARY KEY, - created_time BIGINT NOT NULL, - tenant_id UUID NOT NULL, - version BIGINT NOT NULL DEFAULT 1, - name VARCHAR(255) NOT NULL, - provider VARCHAR(255) NOT NULL, - model VARCHAR(255) NOT NULL, - configuration JSONB NOT NULL, + id UUID NOT NULL PRIMARY KEY, + created_time BIGINT NOT NULL, + tenant_id UUID NOT NULL, + version BIGINT NOT NULL DEFAULT 1, + name VARCHAR(255) NOT NULL, + provider VARCHAR(255) NOT NULL, + provider_config JSONB NOT NULL, + model VARCHAR(255) NOT NULL, + model_config JSONB, CONSTRAINT ai_settings_name_unq_key UNIQUE (tenant_id, name) ); diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java index 7207501d7e..864c0034c1 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java @@ -23,6 +23,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.thingsboard.rule.engine.api.RuleEngineAiService; import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.ai.model.MistralAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.OpenAiChatModelConfig; import org.thingsboard.server.common.data.id.AiSettingsId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.ai.AiSettingsService; @@ -45,18 +47,39 @@ class AiServiceImpl implements RuleEngineAiService { var aiSettings = aiSettingsOpt.get(); return switch (aiSettings.getProvider()) { - case OPENAI -> OpenAiChatModel.builder() - .apiKey(aiSettings.getConfiguration().getApiKey()) - .modelName(aiSettings.getModel()) - .build(); - case MISTRAL_AI -> MistralAiChatModel.builder() - .apiKey(aiSettings.getConfiguration().getApiKey()) - .modelName(aiSettings.getModel()) - .build(); - case GOOGLE_AI_GEMINI -> GoogleAiGeminiChatModel.builder() - .apiKey(aiSettings.getConfiguration().getApiKey()) - .modelName(aiSettings.getModel()) - .build(); + case OPENAI -> { + var modelBuilder = OpenAiChatModel.builder() + .apiKey(aiSettings.getProviderConfig().getApiKey()) + .modelName(aiSettings.getModel()); + + if (aiSettings.getModelConfig() instanceof OpenAiChatModelConfig config) { + modelBuilder.temperature(config.getTemperature()); + } + + yield modelBuilder.build(); + } + case MISTRAL_AI -> { + var modelBuilder = MistralAiChatModel.builder() + .apiKey(aiSettings.getProviderConfig().getApiKey()) + .modelName(aiSettings.getModel()); + + if (aiSettings.getModelConfig() instanceof MistralAiChatModelConfig config) { + modelBuilder.temperature(config.getTemperature()); + } + + yield modelBuilder.build(); + } + case GOOGLE_AI_GEMINI -> { + var modelBuilder = GoogleAiGeminiChatModel.builder() + .apiKey(aiSettings.getProviderConfig().getApiKey()) + .modelName(aiSettings.getModel()); + + if (aiSettings.getModelConfig() instanceof OpenAiChatModelConfig config) { + modelBuilder.temperature(config.getTemperature()); + } + + yield modelBuilder.build(); + } }; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java index d3ef2fa419..6083f09f90 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java @@ -24,6 +24,9 @@ import org.thingsboard.server.common.data.BaseData; import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.HasVersion; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; import org.thingsboard.server.common.data.id.AiSettingsId; import org.thingsboard.server.common.data.id.TenantId; @@ -76,17 +79,27 @@ public final class AiSettings extends BaseData implements HasTenan @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, - description = "Identifier of the AI model to use", + description = "Configuration specific to the AI provider" + ) + AiProviderConfig providerConfig; + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "Identifier of the AI model", example = "gpt-4o-mini" ) String model; @Schema( - requiredMode = Schema.RequiredMode.REQUIRED, + requiredMode = Schema.RequiredMode.NOT_REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, - description = "Settings specific to the selected AI provider and model" + description = """ + Optional configuration specific to the AI model. + If provided, it must be one of the known AiModelConfig subtypes and any settings + you specify will override the model’s defaults; if omitted, the model will run with its built-in defaults.""" ) - AiConfig configuration; + AiModelConfig modelConfig; public AiSettings() {} 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 new file mode 100644 index 0000000000..5112df3213 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java @@ -0,0 +1,47 @@ +/** + * 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; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "model", + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = OpenAiChatModelConfig.class, name = "gpt-4o"), + @JsonSubTypes.Type(value = OpenAiChatModelConfig.class, name = "gpt-4o-mini"), + @JsonSubTypes.Type(value = GoogleAiGeminiChatModelConfig.class, name = "gemini-2.0-flash"), + @JsonSubTypes.Type(value = MistralAiChatModelConfig.class, name = "mistral-medium-latest") +}) +public abstract class AiModelConfig { + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "Identifier of the AI model" + ) + private String model; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/GoogleAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/GoogleAiGeminiChatModelConfig.java new file mode 100644 index 0000000000..761ec998cc --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/GoogleAiGeminiChatModelConfig.java @@ -0,0 +1,50 @@ +/** + * 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; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@Schema( + name = "GoogleAiGeminiChatModelConfig", + description = "Configuration for Google AI Gemini chat models" +) +public final class GoogleAiGeminiChatModelConfig extends AiModelConfig { + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "Identifier of the AI model", + allowableValues = "gemini-2.0-flash", + example = "gemini-2.0-flash" + ) + public String getModel() { + return super.getModel(); + } + + @Schema( + accessMode = Schema.AccessMode.READ_WRITE, + description = "Sampling temperature to control randomness: 0.0 (most deterministic) to 1.0 (most creative)", + example = "0.7" + ) + private Double temperature; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/MistralAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/MistralAiChatModelConfig.java new file mode 100644 index 0000000000..20a8cfea7c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/MistralAiChatModelConfig.java @@ -0,0 +1,50 @@ +/** + * 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; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@Schema( + name = "MistralAiChatModelConfig", + description = "Configuration for Mistral AI chat models" +) +public final class MistralAiChatModelConfig extends AiModelConfig { + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "Identifier of the AI model", + allowableValues = "mistral-medium-latest", + example = "mistral-medium-latest" + ) + public String getModel() { + return super.getModel(); + } + + @Schema( + accessMode = Schema.AccessMode.READ_WRITE, + description = "Sampling temperature to control randomness: 0.0 (most deterministic) to 1.0 (most creative)", + example = "0.7" + ) + private Double temperature; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/OpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/OpenAiChatModelConfig.java new file mode 100644 index 0000000000..a7bc3725ef --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/OpenAiChatModelConfig.java @@ -0,0 +1,50 @@ +/** + * 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; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@Schema( + name = "OpenAiChatModelConfig", + description = "Configuration for OpenAI chat models" +) +public final class OpenAiChatModelConfig extends AiModelConfig { + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "Identifier of the AI model", + allowableValues = {"gpt-4o", "gpt-4o-mini"}, + example = "gpt-4o" + ) + public String getModel() { + return super.getModel(); + } + + @Schema( + accessMode = Schema.AccessMode.READ_WRITE, + description = "Sampling temperature to control randomness: 0.0 (most deterministic) to 1.0 (most creative)", + example = "0.7" + ) + private Double temperature; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiProvider.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java similarity index 92% rename from common/data/src/main/java/org/thingsboard/server/common/data/ai/AiProvider.java rename to common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java index e2938cdddc..068d75873b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiProvider.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.data.ai; +package org.thingsboard.server.common.data.ai.provider; public enum AiProvider { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java similarity index 76% rename from common/data/src/main/java/org/thingsboard/server/common/data/ai/AiConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java index 5c37d21454..06b82cdef2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.data.ai; +package org.thingsboard.server.common.data.ai.provider; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; @@ -30,17 +30,16 @@ import lombok.NoArgsConstructor; visible = true ) @JsonSubTypes({ - @JsonSubTypes.Type(value = OpenAiConfig.class, name = "OPENAI"), - @JsonSubTypes.Type(value = GoogleAiGeminiConfig.class, name = "GOOGLE_AI_GEMINI"), - @JsonSubTypes.Type(value = MistralAiConfig.class, name = "MISTRAL_AI") + @JsonSubTypes.Type(value = OpenAiProviderConfig.class, name = "OPENAI"), + @JsonSubTypes.Type(value = GoogleAiGeminiProviderConfig.class, name = "GOOGLE_AI_GEMINI"), + @JsonSubTypes.Type(value = MistralAiProviderConfig.class, name = "MISTRAL_AI") }) -public abstract class AiConfig { +public abstract class AiProviderConfig { @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, - description = "API key for authenticating with the AI provider", - example = "sk-********************************" + description = "API key for authenticating with the AI provider" ) private String apiKey; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/GoogleAiGeminiConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java similarity index 82% rename from common/data/src/main/java/org/thingsboard/server/common/data/ai/GoogleAiGeminiConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java index 915fb41d55..9ca8643173 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/GoogleAiGeminiConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.data.ai; +package org.thingsboard.server.common.data.ai.provider; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -22,10 +22,10 @@ import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) @Schema( - name = "GoogleAiGemini", - description = "Configuration properties for the Google AI Gemini" + name = "GoogleAiGeminiProviderConfig", + description = "Configuration for the Google AI Gemini provider" ) -public class GoogleAiGeminiConfig extends AiConfig { +public final class GoogleAiGeminiProviderConfig extends AiProviderConfig { @Schema( requiredMode = Schema.RequiredMode.REQUIRED, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/MistralAiConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java similarity index 82% rename from common/data/src/main/java/org/thingsboard/server/common/data/ai/MistralAiConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java index bbfd849f59..bc17f34220 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/MistralAiConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.data.ai; +package org.thingsboard.server.common.data.ai.provider; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -22,10 +22,10 @@ import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) @Schema( - name = "MistralAi", - description = "Configuration properties for the Mistral AI" + name = "MistralAiProviderConfig", + description = "Configuration for the Mistral AI provider" ) -public class MistralAiConfig extends AiConfig { +public final class MistralAiProviderConfig extends AiProviderConfig { @Schema( requiredMode = Schema.RequiredMode.REQUIRED, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/OpenAiConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java similarity index 83% rename from common/data/src/main/java/org/thingsboard/server/common/data/ai/OpenAiConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java index cce6e3aa90..36ac8b4b35 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/OpenAiConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.data.ai; +package org.thingsboard.server.common.data.ai.provider; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -22,10 +22,10 @@ import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) @Schema( - name = "OpenAiConfig", - description = "Configuration properties for the OpenAI" + name = "OpenAiProviderConfig", + description = "Configuration for the OpenAI provider" ) -public class OpenAiConfig extends AiConfig { +public final class OpenAiProviderConfig extends AiProviderConfig { @Schema( requiredMode = Schema.RequiredMode.REQUIRED, diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 20ef6ceb08..ecd93df6fd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -746,8 +746,9 @@ public class ModelConstants { public static final String AI_SETTINGS_TENANT_ID_COLUMN_NAME = TENANT_ID_COLUMN; public static final String AI_SETTINGS_NAME_COLUMN_NAME = NAME_PROPERTY; public static final String AI_SETTINGS_PROVIDER_COLUMN_NAME = "provider"; + public static final String AI_SETTINGS_PROVIDER_CONFIG_COLUMN_NAME = "provider_config"; public static final String AI_SETTINGS_MODEL_COLUMN_NAME = "model"; - public static final String AI_SETTINGS_CONFIGURATION_COLUMN_NAME = "configuration"; + public static final String AI_SETTINGS_MODEL_CONFIG_COLUMN_NAME = "model_config"; protected static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, JSON_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN}; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java index 458b434711..e2775e3cbb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java @@ -26,9 +26,10 @@ import lombok.Setter; import lombok.ToString; import org.hibernate.annotations.Type; import org.hibernate.proxy.HibernateProxy; -import org.thingsboard.server.common.data.ai.AiConfig; -import org.thingsboard.server.common.data.ai.AiProvider; import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; import org.thingsboard.server.common.data.id.AiSettingsId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.model.BaseVersionedEntity; @@ -54,12 +55,16 @@ public class AiSettingsEntity extends BaseVersionedEntity { @Column(name = ModelConstants.AI_SETTINGS_PROVIDER_COLUMN_NAME, nullable = false) private AiProvider provider; + @Type(JsonBinaryType.class) + @Column(name = ModelConstants.AI_SETTINGS_PROVIDER_CONFIG_COLUMN_NAME, nullable = false, columnDefinition = "JSONB") + private AiProviderConfig providerConfig; + @Column(name = ModelConstants.AI_SETTINGS_MODEL_COLUMN_NAME, nullable = false) private String model; @Type(JsonBinaryType.class) - @Column(name = ModelConstants.AI_SETTINGS_CONFIGURATION_COLUMN_NAME, nullable = false, columnDefinition = "JSONB") - private AiConfig configuration; + @Column(name = ModelConstants.AI_SETTINGS_MODEL_CONFIG_COLUMN_NAME, columnDefinition = "JSONB") + private AiModelConfig modelConfig; public AiSettingsEntity() {} @@ -68,8 +73,9 @@ public class AiSettingsEntity extends BaseVersionedEntity { tenantId = getTenantUuid(aiSettings.getTenantId()); name = aiSettings.getName(); provider = aiSettings.getProvider(); + providerConfig = aiSettings.getProviderConfig(); model = aiSettings.getModel(); - configuration = aiSettings.getConfiguration(); + modelConfig = aiSettings.getModelConfig(); } @Override @@ -80,8 +86,9 @@ public class AiSettingsEntity extends BaseVersionedEntity { settings.setTenantId(TenantId.fromUUID(tenantId)); settings.setName(name); settings.setProvider(provider); + settings.setProviderConfig(providerConfig); settings.setModel(model); - settings.setConfiguration(configuration); + settings.setModelConfig(modelConfig); return settings; } diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index b2b9ffaded..d2fabc71ec 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -950,13 +950,14 @@ CREATE TABLE IF NOT EXISTS cf_debug_event ( ) PARTITION BY RANGE (ts); CREATE TABLE IF NOT EXISTS ai_settings ( - id UUID NOT NULL PRIMARY KEY, - created_time BIGINT NOT NULL, - tenant_id UUID NOT NULL, - version BIGINT NOT NULL DEFAULT 1, - name VARCHAR(255) NOT NULL, - provider VARCHAR(255) NOT NULL, - model VARCHAR(255) NOT NULL, - configuration JSONB NOT NULL, + id UUID NOT NULL PRIMARY KEY, + created_time BIGINT NOT NULL, + tenant_id UUID NOT NULL, + version BIGINT NOT NULL DEFAULT 1, + name VARCHAR(255) NOT NULL, + provider VARCHAR(255) NOT NULL, + provider_config JSONB NOT NULL, + model VARCHAR(255) NOT NULL, + model_config JSONB, CONSTRAINT ai_settings_name_unq_key UNIQUE (tenant_id, name) ); From 71d1e3640a38fc40c4a565a0e452197818c7cb07 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 22 May 2025 16:41:16 +0300 Subject: [PATCH 019/249] AI rule node: fix type when configuring temperature for Gemini models --- .../java/org/thingsboard/server/service/ai/AiServiceImpl.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java index 864c0034c1..60d7dca7a8 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java @@ -23,6 +23,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.thingsboard.rule.engine.api.RuleEngineAiService; import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.ai.model.GoogleAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.MistralAiChatModelConfig; import org.thingsboard.server.common.data.ai.model.OpenAiChatModelConfig; import org.thingsboard.server.common.data.id.AiSettingsId; @@ -74,7 +75,7 @@ class AiServiceImpl implements RuleEngineAiService { .apiKey(aiSettings.getProviderConfig().getApiKey()) .modelName(aiSettings.getModel()); - if (aiSettings.getModelConfig() instanceof OpenAiChatModelConfig config) { + if (aiSettings.getModelConfig() instanceof GoogleAiGeminiChatModelConfig config) { modelBuilder.temperature(config.getTemperature()); } From 9111574eca75d1f0a4fa7a1981bb7ad64c8742f5 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 22 May 2025 16:42:11 +0300 Subject: [PATCH 020/249] AI rule node: formatting fix for `modelConfig` field doc --- .../java/org/thingsboard/server/common/data/ai/AiSettings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java index 6083f09f90..d38ac45992 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java @@ -96,7 +96,7 @@ public final class AiSettings extends BaseData implements HasTenan accessMode = Schema.AccessMode.READ_WRITE, description = """ Optional configuration specific to the AI model. - If provided, it must be one of the known AiModelConfig subtypes and any settings + If provided, it must be one of the known `AiModelConfig` subtypes and any settings you specify will override the model’s defaults; if omitted, the model will run with its built-in defaults.""" ) AiModelConfig modelConfig; From fa667a9e661a623cc345b3307a38ede03080c834 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 22 May 2025 16:43:01 +0300 Subject: [PATCH 021/249] AI rule node: add TbAiSettingsService --- .../controller/AiSettingsController.java | 7 +- .../server/controller/BaseController.java | 4 + .../ai/DefaultTbAiSettingsService.java | 79 +++++++++++++++++++ .../entitiy/ai/TbAiSettingsService.java | 27 +++++++ .../src/main/resources/thingsboard.yml | 33 ++++---- .../app/shared/models/entity-type.models.ts | 8 ++ .../assets/locale/locale.constant-en_US.json | 1 + 7 files changed, 140 insertions(+), 19 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiSettingsService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiSettingsService.java diff --git a/application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java b/application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java index 2f7aa1dd43..530fc1e56a 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java @@ -63,9 +63,10 @@ public class AiSettingsController extends BaseController { @PreAuthorize("hasAuthority('TENANT_ADMIN')") @PostMapping public AiSettings saveAiSettings(@RequestBody AiSettings aiSettings) throws ThingsboardException { - aiSettings.setTenantId(getTenantId()); + var user = getCurrentUser(); + aiSettings.setTenantId(user.getTenantId()); checkEntity(aiSettings.getId(), aiSettings, Resource.AI_SETTINGS); - return aiSettingsService.save(aiSettings); + return tbAiSettingsService.save(aiSettings, user); } @ApiOperation( @@ -143,7 +144,7 @@ public class AiSettingsController extends BaseController { return false; } accessControlService.checkPermission(user, Resource.AI_SETTINGS, Operation.DELETE, aiSettingsId, aiSettingsOpt.get()); - return aiSettingsService.deleteByTenantIdAndId(user.getTenantId(), aiSettingsId); + return tbAiSettingsService.delete(aiSettingsOpt.get(), user); } } diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index ef50a6552c..26ff7c1a94 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -177,6 +177,7 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.action.EntityActionService; import org.thingsboard.server.service.component.ComponentDiscoveryService; import org.thingsboard.server.service.entitiy.TbLogEntityActionService; +import org.thingsboard.server.service.entitiy.ai.TbAiSettingsService; import org.thingsboard.server.service.entitiy.user.TbUserSettingsService; import org.thingsboard.server.service.ota.OtaPackageStateService; import org.thingsboard.server.service.profile.TbAssetProfileCache; @@ -379,6 +380,9 @@ public abstract class BaseController { @Autowired protected AiSettingsService aiSettingsService; + @Autowired + protected TbAiSettingsService tbAiSettingsService; + @Value("${server.log_controller_error_stack_trace}") @Getter private boolean logControllerErrorStackTrace; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiSettingsService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiSettingsService.java new file mode 100644 index 0000000000..08e663633d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiSettingsService.java @@ -0,0 +1,79 @@ +/** + * 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.entitiy.ai; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.dao.ai.AiSettingsService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; + +import static java.util.Objects.requireNonNullElseGet; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +class DefaultTbAiSettingsService extends AbstractTbEntityService implements TbAiSettingsService { + + private final AiSettingsService aiSettingsService; + + @Override + public AiSettings save(AiSettings aiSettings, User user) { + var actionType = aiSettings.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + + var tenantId = user.getTenantId(); + aiSettings.setTenantId(tenantId); + + AiSettings savedSettings; + try { + savedSettings = aiSettingsService.save(aiSettings); + } catch (Exception e) { + logEntityActionService.logEntityAction(tenantId, requireNonNullElseGet(aiSettings.getId(), () -> emptyId(EntityType.AI_SETTINGS)), aiSettings, actionType, user, e); + throw e; + } + + logEntityActionService.logEntityAction(tenantId, savedSettings.getId(), savedSettings, actionType, user); + + return savedSettings; + } + + @Override + public boolean delete(AiSettings aiSettings, User user) { + var actionType = ActionType.DELETED; + + var tenantId = user.getTenantId(); + var aiSettingsId = aiSettings.getId(); + + boolean deleted; + try { + deleted = aiSettingsService.deleteByTenantIdAndId(tenantId, aiSettingsId); + } catch (Exception e) { + logEntityActionService.logEntityAction(tenantId, aiSettingsId, aiSettings, actionType, user, e, aiSettingsId.toString()); + throw e; + } + + if (deleted) { + logEntityActionService.logEntityAction(tenantId, aiSettingsId, aiSettings, actionType, user, aiSettingsId.toString()); + } + + return deleted; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiSettingsService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiSettingsService.java new file mode 100644 index 0000000000..399a3b2bdd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiSettingsService.java @@ -0,0 +1,27 @@ +/** + * 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.entitiy.ai; + +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiSettings; + +public interface TbAiSettingsService { + + AiSettings save(AiSettings aiSettings, User user); + + boolean delete(AiSettings aiSettings, User user); + +} diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index d7dbc0d6ac..5104f2e264 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -846,22 +846,23 @@ audit-log: # Allowed values: OFF (disable), W (log write operations), RW (log read and write operations) logging-level: mask: - "device": "${AUDIT_LOG_MASK_DEVICE:W}" # Device logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "asset": "${AUDIT_LOG_MASK_ASSET:W}" # Asset logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "dashboard": "${AUDIT_LOG_MASK_DASHBOARD:W}" # Dashboard logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "widget_type": "${AUDIT_LOG_MASK_WIDGET_TYPE:W}" # Widget type logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "widgets_bundle": "${AUDIT_LOG_MASK_WIDGETS_BUNDLE:W}" # Widget bundles logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "customer": "${AUDIT_LOG_MASK_CUSTOMER:W}" # Customer logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "user": "${AUDIT_LOG_MASK_USER:W}" # User logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "rule_chain": "${AUDIT_LOG_MASK_RULE_CHAIN:W}" # Rule chain logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "alarm": "${AUDIT_LOG_MASK_ALARM:W}" # Alarm logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "entity_view": "${AUDIT_LOG_MASK_ENTITY_VIEW:W}" # Entity view logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "device_profile": "${AUDIT_LOG_MASK_DEVICE_PROFILE:W}" # Device profile logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "asset_profile": "${AUDIT_LOG_MASK_ASSET_PROFILE:W}" # Asset profile logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "edge": "${AUDIT_LOG_MASK_EDGE:W}" # Edge logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "tb_resource": "${AUDIT_LOG_MASK_RESOURCE:W}" # TB resource logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "ota_package": "${AUDIT_LOG_MASK_OTA_PACKAGE:W}" # Ota package logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "calculated_field": "${AUDIT_LOG_MASK_CALCULATED_FIELD:W}" # Calculated field logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation + "device": "${AUDIT_LOG_MASK_DEVICE:W}" # Device logging levels. + "asset": "${AUDIT_LOG_MASK_ASSET:W}" # Asset logging levels. + "dashboard": "${AUDIT_LOG_MASK_DASHBOARD:W}" # Dashboard logging levels. + "widget_type": "${AUDIT_LOG_MASK_WIDGET_TYPE:W}" # Widget type logging levels. + "widgets_bundle": "${AUDIT_LOG_MASK_WIDGETS_BUNDLE:W}" # Widget bundles logging levels. + "customer": "${AUDIT_LOG_MASK_CUSTOMER:W}" # Customer logging levels. + "user": "${AUDIT_LOG_MASK_USER:W}" # User logging levels. + "rule_chain": "${AUDIT_LOG_MASK_RULE_CHAIN:W}" # Rule chain logging levels. + "alarm": "${AUDIT_LOG_MASK_ALARM:W}" # Alarm logging levels. + "entity_view": "${AUDIT_LOG_MASK_ENTITY_VIEW:W}" # Entity view logging levels. + "device_profile": "${AUDIT_LOG_MASK_DEVICE_PROFILE:W}" # Device profile logging levels. + "asset_profile": "${AUDIT_LOG_MASK_ASSET_PROFILE:W}" # Asset profile logging levels. + "edge": "${AUDIT_LOG_MASK_EDGE:W}" # Edge logging levels. + "tb_resource": "${AUDIT_LOG_MASK_RESOURCE:W}" # TB resource logging levels. + "ota_package": "${AUDIT_LOG_MASK_OTA_PACKAGE:W}" # Ota package logging levels. + "calculated_field": "${AUDIT_LOG_MASK_CALCULATED_FIELD:W}" # Calculated field logging levels. + "ai_settings": "${AUDIT_LOG_MASK_AI_SETTINGS:W}" # AI settings logging levels. sink: # Type of external sink. possible options: none, elasticsearch type: "${AUDIT_LOG_SINK_TYPE:none}" diff --git a/ui-ngx/src/app/shared/models/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts index 48cad42e7b..0c7b6ca152 100644 --- a/ui-ngx/src/app/shared/models/entity-type.models.ts +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -51,6 +51,7 @@ export enum EntityType { MOBILE_APP_BUNDLE = 'MOBILE_APP_BUNDLE', MOBILE_APP = 'MOBILE_APP', CALCULATED_FIELD = 'CALCULATED_FIELD', + AI_SETTINGS = 'AI_SETTINGS', } export enum AliasEntityType { @@ -491,6 +492,13 @@ export const entityTypeTranslations = new Map Date: Thu, 22 May 2025 17:37:12 +0300 Subject: [PATCH 022/249] AI rule node: delete AI settings on tenant deletion --- .../org/thingsboard/server/dao/tenant/TenantServiceImpl.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index 8c40ca3e14..505fe2a50a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -182,7 +182,8 @@ public class TenantServiceImpl extends AbstractCachedEntityService Date: Thu, 22 May 2025 19:02:19 +0300 Subject: [PATCH 023/249] AI rule node: implement data validator for AI settings --- .../data/ai/provider/AiProviderConfig.java | 2 + .../server/dao/ai/AiSettingsServiceImpl.java | 4 + .../validator/AiSettingsDataValidator.java | 109 ++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/AiSettingsDataValidator.java 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 06b82cdef2..f350ed75ec 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 @@ -36,6 +36,8 @@ import lombok.NoArgsConstructor; }) public abstract class AiProviderConfig { + public abstract AiProvider getProvider(); + @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java index ca23b6bc56..24571458ec 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java @@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.id.HasId; 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.dao.service.DataValidator; import java.util.Optional; @@ -37,8 +38,11 @@ class AiSettingsServiceImpl implements AiSettingsService { private final AiSettingsDao aiSettingsDao; + private final DataValidator aiSettingsValidator; + @Override public AiSettings save(AiSettings aiSettings) { + aiSettingsValidator.validate(aiSettings, AiSettings::getTenantId); try { return aiSettingsDao.saveAndFlush(aiSettings.getTenantId(), aiSettings); } catch (Exception e) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiSettingsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiSettingsDataValidator.java new file mode 100644 index 0000000000..ecfe9ad92a --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiSettingsDataValidator.java @@ -0,0 +1,109 @@ +/** + * 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.service.validator; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.ai.AiSettingsDao; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.dao.tenant.TenantService; + +import java.util.Objects; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +class AiSettingsDataValidator extends DataValidator { + + private final TenantService tenantService; + private final AiSettingsDao aiSettingsDao; + + @Override + protected void validateCreate(TenantId tenantId, AiSettings aiSettings) { + validateNumberOfEntitiesPerTenant(tenantId, EntityType.AI_SETTINGS); + } + + @Override + protected AiSettings validateUpdate(TenantId tenantId, AiSettings aiSettings) { + Optional old = aiSettingsDao.findByTenantIdAndId(tenantId, aiSettings.getId()); + if (old.isEmpty()) { + throw new DataValidationException("Can't update non existing AI settings!"); + } + return old.get(); + } + + @Override + protected void validateDataImpl(TenantId tenantId, AiSettings aiSettings) { + // ID validation + if (aiSettings.getId() != null) { + if (aiSettings.getUuidId() == null) { + throw new DataValidationException("AI settings UUID should be specified!"); + } + if (aiSettings.getId().isNullUid()) { + throw new DataValidationException("AI settings UUID must not be the reserved null value!"); + } + } + + // tenant ID validation + if (aiSettings.getTenantId() == null || aiSettings.getTenantId().getId() == null) { + throw new DataValidationException("AI settings should be assigned to tenant!"); + } + if (aiSettings.getTenantId().isSysTenantId()) { + throw new DataValidationException("AI settings cannot be assigned to the system tenant!"); + } + if (!tenantService.tenantExists(tenantId)) { + throw new DataValidationException("AI settings reference a non-existent tenant!"); + } + + // name validation + validateString("AI settings name", aiSettings.getName()); + if (aiSettings.getName().length() > 255) { + throw new DataValidationException("AI settings name should be between 1 and 255 symbols!"); + } + + // provider validation + if (aiSettings.getProvider() == null) { + throw new DataValidationException("AI provider should be specified!"); + } + + // provider config validation + if (aiSettings.getProviderConfig() == null) { + throw new DataValidationException("AI provider config should be specified!"); + } + if (aiSettings.getProviderConfig().getProvider() != aiSettings.getProvider()) { + throw new DataValidationException("AI provider configuration should match the selected AI provider!"); + } + validateString("AI provider API key", aiSettings.getProviderConfig().getApiKey()); + + // model identifier validation + validateString("AI model identifier", aiSettings.getModel()); + if (aiSettings.getModel().length() > 255) { + throw new DataValidationException("AI model identifier should be between 1 and 255 symbols!"); + } + + // model config validation + if (aiSettings.getModelConfig() != null) { + if (!Objects.equals(aiSettings.getModelConfig().getModel(), aiSettings.getModel())) { + throw new DataValidationException("AI model configuration should match the selected AI model!"); + } + } + } + +} From 255ab3b39131028fcbd517cacdf64ac2b3a9770e Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 23 May 2025 12:52:01 +0300 Subject: [PATCH 024/249] AI rule node: minor correction to AI settings data validation message --- .../server/dao/service/validator/AiSettingsDataValidator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiSettingsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiSettingsDataValidator.java index ecfe9ad92a..24d4df23ee 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiSettingsDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiSettingsDataValidator.java @@ -44,7 +44,7 @@ class AiSettingsDataValidator extends DataValidator { protected AiSettings validateUpdate(TenantId tenantId, AiSettings aiSettings) { Optional old = aiSettingsDao.findByTenantIdAndId(tenantId, aiSettings.getId()); if (old.isEmpty()) { - throw new DataValidationException("Can't update non existing AI settings!"); + throw new DataValidationException("Cannot update non-existent AI settings!"); } return old.get(); } From b902c0029da8d0c19607b9ba6891e76547e6ca68 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 23 May 2025 14:47:08 +0300 Subject: [PATCH 025/249] AI rule node: publish save/delete events for AI settings --- .../server/dao/ai/AiSettingsDao.java | 4 +- .../server/dao/ai/AiSettingsServiceImpl.java | 50 ++++++++++++++++--- .../dao/sql/ai/AiSettingsRepository.java | 12 +++-- .../server/dao/sql/ai/JpaAiSettingsDao.java | 12 +++-- 4 files changed, 65 insertions(+), 13 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsDao.java index a94131a642..94a096d679 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsDao.java @@ -27,7 +27,9 @@ public interface AiSettingsDao extends Dao, TenantEntityDao findByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId); - void deleteByTenantId(TenantId tenantId); + boolean deleteById(TenantId tenantId, AiSettingsId aiSettingsId); + + int deleteByTenantId(TenantId tenantId); boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java index 24571458ec..281efbefc1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java @@ -16,7 +16,9 @@ package org.thingsboard.server.dao.ai; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ai.AiSettings; import org.thingsboard.server.common.data.id.AiSettingsId; @@ -25,8 +27,11 @@ import org.thingsboard.server.common.data.id.HasId; 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.dao.eventsourcing.DeleteEntityEvent; +import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.service.DataValidator; +import java.util.List; import java.util.Optional; import static org.thingsboard.server.dao.entity.AbstractEntityService.checkConstraintViolation; @@ -36,19 +41,33 @@ import static org.thingsboard.server.dao.service.Validator.validatePageLink; @RequiredArgsConstructor class AiSettingsServiceImpl implements AiSettingsService { - private final AiSettingsDao aiSettingsDao; - + private final ApplicationEventPublisher eventPublisher; private final DataValidator aiSettingsValidator; + private final AiSettingsDao aiSettingsDao; + @Override public AiSettings save(AiSettings aiSettings) { - aiSettingsValidator.validate(aiSettings, AiSettings::getTenantId); + AiSettings oldSettings = aiSettingsValidator.validate(aiSettings, AiSettings::getTenantId); + + AiSettings savedSettings; try { - return aiSettingsDao.saveAndFlush(aiSettings.getTenantId(), aiSettings); + savedSettings = aiSettingsDao.saveAndFlush(aiSettings.getTenantId(), aiSettings); } catch (Exception e) { checkConstraintViolation(e, "ai_settings_name_unq_key", "AI settings record with such name already exists!"); throw e; } + + eventPublisher.publishEvent(SaveEntityEvent.builder() + .tenantId(savedSettings.getTenantId()) + .entity(savedSettings) + .oldEntity(oldSettings) + .entityId(savedSettings.getId()) + .created(oldSettings == null) + .broadcastEvent(true) + .build()); + + return savedSettings; } @Override @@ -69,7 +88,15 @@ class AiSettingsServiceImpl implements AiSettingsService { @Override public boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) { - return aiSettingsDao.deleteByTenantIdAndId(tenantId, aiSettingsId); + Optional aiSettingsOpt = aiSettingsDao.findByTenantIdAndId(tenantId, aiSettingsId); + if (aiSettingsOpt.isEmpty()) { + return false; + } + boolean deleted = aiSettingsDao.deleteByTenantIdAndId(tenantId, aiSettingsId); + if (deleted) { + publishDeleteEvent(aiSettingsOpt.get()); + } + return deleted; } @Override @@ -84,12 +111,23 @@ class AiSettingsServiceImpl implements AiSettingsService { @Override public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { - aiSettingsDao.removeById(tenantId, id.getId()); + deleteByTenantIdAndId(tenantId, new AiSettingsId(id.getId())); } @Override + @Transactional public void deleteByTenantId(TenantId tenantId) { + List deletedSettings = aiSettingsDao.findAllByTenantId(tenantId, new PageLink(Integer.MAX_VALUE)).getData(); aiSettingsDao.deleteByTenantId(tenantId); + deletedSettings.forEach(this::publishDeleteEvent); + } + + private void publishDeleteEvent(AiSettings settings) { + eventPublisher.publishEvent(DeleteEntityEvent.builder() + .tenantId(settings.getTenantId()) + .entityId(settings.getId()) + .entity(settings) + .build()); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java index e52177d247..691709f8b8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java @@ -25,6 +25,7 @@ import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.dao.model.sql.AiSettingsEntity; import java.util.Optional; +import java.util.Set; import java.util.UUID; public interface AiSettingsRepository extends JpaRepository { @@ -40,11 +41,16 @@ public interface AiSettingsRepository extends JpaRepository ids); + + @Transactional + int deleteByTenantId(UUID tenantId); @Transactional @Modifying - @Query("DELETE FROM AiSettingsEntity ai WHERE ai.tenantId = :tenantId AND ai.id = :id") - int deleteByTenantIdAndId(@Param("tenantId") UUID tenantId, @Param("id") UUID id); + @Query("DELETE FROM AiSettingsEntity ai WHERE ai.tenantId = :tenantId AND ai.id IN (:ids)") + int deleteByTenantIdAndIdIn(@Param("tenantId") UUID tenantId, @Param("ids") Set ids); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiSettingsDao.java index 14e43cb095..c56c29b835 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiSettingsDao.java @@ -32,6 +32,7 @@ import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.util.SqlDao; import java.util.Optional; +import java.util.Set; import java.util.UUID; @SqlDao @@ -59,13 +60,18 @@ class JpaAiSettingsDao extends JpaAbstractDao impl } @Override - public void deleteByTenantId(TenantId tenantId) { - aiSettingsRepository.deleteByTenantId(tenantId.getId()); + public boolean deleteById(TenantId tenantId, AiSettingsId aiSettingsId) { + return aiSettingsRepository.deleteByIdIn(Set.of(aiSettingsId.getId())) > 0; + } + + @Override + public int deleteByTenantId(TenantId tenantId) { + return aiSettingsRepository.deleteByTenantId(tenantId.getId()); } @Override public boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) { - return aiSettingsRepository.deleteByTenantIdAndId(tenantId.getId(), aiSettingsId.getId()) > 0; + return aiSettingsRepository.deleteByTenantIdAndIdIn(tenantId.getId(), Set.of(aiSettingsId.getId())) > 0; } @Override From c738d3a906cfe1627d9c9befad688fa1068b80a5 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 23 May 2025 14:48:47 +0300 Subject: [PATCH 026/249] AI rule node: return early optimization in `deleteByTenantId()` --- .../org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java index 281efbefc1..1bfa316c73 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java @@ -118,6 +118,9 @@ class AiSettingsServiceImpl implements AiSettingsService { @Transactional public void deleteByTenantId(TenantId tenantId) { List deletedSettings = aiSettingsDao.findAllByTenantId(tenantId, new PageLink(Integer.MAX_VALUE)).getData(); + if (deletedSettings.isEmpty()) { + return; + } aiSettingsDao.deleteByTenantId(tenantId); deletedSettings.forEach(this::publishDeleteEvent); } From bdb657e213e41d2a73342a22da00f3b0c42f610f Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 23 May 2025 18:14:38 +0300 Subject: [PATCH 027/249] AI rule node: add AI settings lifecycle events handling in listeners --- .../edge/EdgeEventSourcingListener.java | 4 ++-- .../edge/RelatedEdgesSourcingListener.java | 8 ++++--- .../entitiy/EntityStateSourcingListener.java | 4 ++-- .../queue/DefaultTbClusterService.java | 22 ++++++++++--------- .../dao/housekeeper/CleanUpService.java | 2 +- 5 files changed, 22 insertions(+), 18 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java index 7bafd53644..dc3d1a74f7 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java @@ -112,7 +112,7 @@ public class EdgeEventSourcingListener { return; } try { - if (EntityType.TENANT.equals(entityType) || EntityType.EDGE.equals(entityType)) { + if (EntityType.TENANT == entityType || EntityType.EDGE == entityType || EntityType.AI_SETTINGS == entityType) { return; } log.trace("[{}] DeleteEntityEvent called: {}", tenantId, event); @@ -226,7 +226,7 @@ public class EdgeEventSourcingListener { break; case TENANT: return !event.getCreated(); - case API_USAGE_STATE, EDGE: + case API_USAGE_STATE, EDGE, AI_SETTINGS: return false; case DOMAIN: if (entity instanceof Domain domain) { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java index 61405e17e1..e0c24a13bc 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java @@ -58,8 +58,7 @@ public class RelatedEdgesSourcingListener { log.trace("[{}] ActionEntityEvent called: {}", event.getTenantId(), event); try { switch (event.getActionType()) { - case ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE -> - relatedEdgesService.publishRelatedEdgeIdsEvictEvent(event.getTenantId(), event.getEntityId()); + case ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE -> relatedEdgesService.publishRelatedEdgeIdsEvictEvent(event.getTenantId(), event.getEntityId()); } } catch (Exception e) { log.error("[{}] failed to process ActionEntityEvent: {}", event.getTenantId(), event, e); @@ -67,7 +66,10 @@ public class RelatedEdgesSourcingListener { }); } - @TransactionalEventListener(fallbackExecution = true) + @TransactionalEventListener( + fallbackExecution = true, + condition = "#event.entityId.getEntityType() != T(org.thingsboard.server.common.data.EntityType).AI_SETTINGS" + ) public void handleEvent(DeleteEntityEvent event) { executorService.submit(() -> { log.trace("[{}] DeleteEntityEvent called: {}", event.getTenantId(), event); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java index 648e89adc9..d0d9376f12 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java @@ -96,7 +96,7 @@ public class EntityStateSourcingListener { case ASSET -> { onAssetUpdate(event.getEntity(), event.getOldEntity()); } - case ASSET_PROFILE, ENTITY_VIEW, NOTIFICATION_RULE -> { + case ASSET_PROFILE, ENTITY_VIEW, NOTIFICATION_RULE, AI_SETTINGS -> { tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, lifecycleEvent); } case RULE_CHAIN -> { @@ -158,7 +158,7 @@ public class EntityStateSourcingListener { Asset asset = (Asset) event.getEntity(); tbClusterService.onAssetDeleted(tenantId, asset, null); } - case ASSET_PROFILE, ENTITY_VIEW, CUSTOMER, EDGE, NOTIFICATION_RULE -> { + case ASSET_PROFILE, ENTITY_VIEW, CUSTOMER, EDGE, NOTIFICATION_RULE, AI_SETTINGS -> { tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, ComponentLifecycleEvent.DELETED); } case NOTIFICATION_REQUEST -> { 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 561dc7122a..aa27b4592b 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 @@ -584,16 +584,18 @@ public class DefaultTbClusterService implements TbClusterService { TbQueueProducer> toRuleEngineProducer = producerProvider.getRuleEngineNotificationsMsgProducer(); Set tbRuleEngineServices = partitionService.getAllServiceIds(ServiceType.TB_RULE_ENGINE); EntityType entityType = msg.getEntityId().getEntityType(); - if (entityType.equals(EntityType.TENANT) - || entityType.equals(EntityType.TENANT_PROFILE) - || entityType.equals(EntityType.DEVICE_PROFILE) - || (entityType.equals(EntityType.ASSET) && msg.getEvent() == ComponentLifecycleEvent.UPDATED) - || entityType.equals(EntityType.ASSET_PROFILE) - || entityType.equals(EntityType.API_USAGE_STATE) - || (entityType.equals(EntityType.DEVICE) && msg.getEvent() == ComponentLifecycleEvent.UPDATED) - || entityType.equals(EntityType.ENTITY_VIEW) - || entityType.equals(EntityType.NOTIFICATION_RULE) - || entityType.equals(EntityType.CALCULATED_FIELD) + if (entityType.isOneOf( + EntityType.TENANT, + EntityType.API_USAGE_STATE, + EntityType.ENTITY_VIEW, + EntityType.NOTIFICATION_RULE, + EntityType.CALCULATED_FIELD, + EntityType.AI_SETTINGS, + EntityType.TENANT_PROFILE, + EntityType.DEVICE_PROFILE, + EntityType.ASSET_PROFILE) + || (entityType == EntityType.ASSET && msg.getEvent() == ComponentLifecycleEvent.UPDATED) + || (entityType == EntityType.DEVICE && msg.getEvent() == ComponentLifecycleEvent.UPDATED) ) { TbQueueProducer> toCoreNfProducer = producerProvider.getTbCoreNotificationsMsgProducer(); Set tbCoreServices = partitionService.getAllServiceIds(ServiceType.TB_CORE); diff --git a/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java b/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java index 1ca2973936..703d944e3b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java @@ -46,7 +46,7 @@ public class CleanUpService { private final Set skippedEntities = EnumSet.of( EntityType.ALARM, EntityType.QUEUE, EntityType.TB_RESOURCE, EntityType.OTA_PACKAGE, EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_TEMPLATE, - EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE + EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, EntityType.AI_SETTINGS ); @TransactionalEventListener(fallbackExecution = true) // after transaction commit From a4db17c859797d8c02b8a29d324d6979483869c3 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 2 Jun 2025 16:28:53 +0300 Subject: [PATCH 028/249] AI rule node: add cache to AI settings --- .../src/main/resources/thingsboard.yml | 3 + .../server/common/data/CacheConstants.java | 5 +- .../dao/ai/AiSettingsCacheEvictEvent.java | 29 ++++++++ .../server/dao/ai/AiSettingsCacheKey.java | 47 +++++++++++++ .../dao/ai/AiSettingsCaffeineCache.java | 33 +++++++++ .../server/dao/ai/AiSettingsRedisCache.java | 36 ++++++++++ .../server/dao/ai/AiSettingsServiceImpl.java | 68 ++++++++++++++----- 7 files changed, 202 insertions(+), 19 deletions(-) create mode 100644 dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheEvictEvent.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCaffeineCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsRedisCache.java diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 5104f2e264..54c1159003 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -647,6 +647,9 @@ cache: trendzSettings: timeToLiveInMinutes: "${CACHE_SPECS_TRENDZ_SETTINGS_TTL:1440}" # Trendz settings cache TTL maxSize: "${CACHE_SPECS_TRENDZ_SETTINGS_MAX_SIZE:10000}" # 0 means the cache is disabled + aiSettings: + timeToLiveInMinutes: "${CACHE_SPECS_AI_SETTINGS_TTL:1440}" # AI settings cache TTL + maxSize: "${CACHE_SPECS_AI_SETTINGS_MAX_SIZE:10000}" # 0 means the cache is disabled # Deliberately placed outside the 'specs' group above notificationRules: diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index 5b167c88a2..4b103d769a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -15,7 +15,8 @@ */ package org.thingsboard.server.common.data; -public class CacheConstants { +public final class CacheConstants { + public static final String DEVICE_CREDENTIALS_CACHE = "deviceCredentials"; public static final String RELATIONS_CACHE = "relations"; public static final String DEVICE_CACHE = "devices"; @@ -36,6 +37,7 @@ public class CacheConstants { public static final String NOTIFICATION_SETTINGS_CACHE = "notificationSettings"; public static final String SENT_NOTIFICATIONS_CACHE = "sentNotifications"; public static final String TRENDZ_SETTINGS_CACHE = "trendzSettings"; + public static final String AI_SETTINGS_CACHE = "aiSettings"; public static final String ASSET_PROFILE_CACHE = "assetProfiles"; public static final String ATTRIBUTES_CACHE = "attributes"; @@ -54,4 +56,5 @@ public class CacheConstants { public static final String ALARM_TYPES_CACHE = "alarmTypes"; public static final String QR_CODE_SETTINGS_CACHE = "qrCodeSettings"; public static final String MOBILE_SECRET_KEY_CACHE = "mobileSecretKey"; + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheEvictEvent.java new file mode 100644 index 0000000000..d6cad8bf16 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheEvictEvent.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.dao.ai; + +import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.Set; + +record AiSettingsCacheEvictEvent(Set keys) { + + static AiSettingsCacheEvictEvent of(TenantId tenantId, AiSettingsId aiSettingsId) { + return new AiSettingsCacheEvictEvent(Set.of(AiSettingsCacheKey.of(tenantId, aiSettingsId))); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheKey.java new file mode 100644 index 0000000000..fcf737d3a5 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheKey.java @@ -0,0 +1,47 @@ +/** + * 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.ai; + +import org.thingsboard.server.cache.VersionedCacheKey; +import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.UUID; + +import static java.util.Objects.requireNonNull; + +record AiSettingsCacheKey(UUID tenantId, UUID aiSettingsId) implements VersionedCacheKey { + + AiSettingsCacheKey { + requireNonNull(tenantId); + requireNonNull(aiSettingsId); + } + + static AiSettingsCacheKey of(TenantId tenantId, AiSettingsId aiSettingsId) { + return new AiSettingsCacheKey(tenantId.getId(), aiSettingsId.getId()); + } + + @Override + public boolean isVersioned() { + return true; + } + + @Override + public String toString() { + return tenantId + "_" + aiSettingsId; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCaffeineCache.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCaffeineCache.java new file mode 100644 index 0000000000..f006c0a757 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCaffeineCache.java @@ -0,0 +1,33 @@ +/** + * 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.ai; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; +import org.thingsboard.server.cache.VersionedCaffeineTbCache; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.ai.AiSettings; + +@Component("AiSettingsCache") +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) +class AiSettingsCaffeineCache extends VersionedCaffeineTbCache { + + AiSettingsCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.AI_SETTINGS_CACHE); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsRedisCache.java new file mode 100644 index 0000000000..0c1da26769 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsRedisCache.java @@ -0,0 +1,36 @@ +/** + * 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.ai; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Component; +import org.thingsboard.server.cache.CacheSpecsMap; +import org.thingsboard.server.cache.TBRedisCacheConfiguration; +import org.thingsboard.server.cache.TbJsonRedisSerializer; +import org.thingsboard.server.cache.VersionedRedisTbCache; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.ai.AiSettings; + +@Component("AiSettingsCache") +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") +class AiSettingsRedisCache extends VersionedRedisTbCache { + + AiSettingsRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.AI_SETTINGS_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(AiSettings.class)); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java index 1bfa316c73..517d23c496 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java @@ -15,10 +15,11 @@ */ package org.thingsboard.server.dao.ai; +import com.google.common.collect.Sets; import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ai.AiSettings; import org.thingsboard.server.common.data.id.AiSettingsId; @@ -27,26 +28,33 @@ import org.thingsboard.server.common.data.id.HasId; 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.dao.entity.CachedVersionedEntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.service.DataValidator; import java.util.List; import java.util.Optional; +import java.util.Set; -import static org.thingsboard.server.dao.entity.AbstractEntityService.checkConstraintViolation; import static org.thingsboard.server.dao.service.Validator.validatePageLink; @Service @RequiredArgsConstructor -class AiSettingsServiceImpl implements AiSettingsService { +class AiSettingsServiceImpl extends CachedVersionedEntityService implements AiSettingsService { - private final ApplicationEventPublisher eventPublisher; private final DataValidator aiSettingsValidator; private final AiSettingsDao aiSettingsDao; @Override + @TransactionalEventListener + public void handleEvictEvent(AiSettingsCacheEvictEvent event) { + cache.evict(event.keys()); + } + + @Override + @Transactional public AiSettings save(AiSettings aiSettings) { AiSettings oldSettings = aiSettingsValidator.validate(aiSettings, AiSettings::getTenantId); @@ -58,15 +66,22 @@ class AiSettingsServiceImpl implements AiSettingsService { throw e; } + boolean created = oldSettings == null; + boolean updated = oldSettings != null; + eventPublisher.publishEvent(SaveEntityEvent.builder() .tenantId(savedSettings.getTenantId()) .entity(savedSettings) .oldEntity(oldSettings) .entityId(savedSettings.getId()) - .created(oldSettings == null) + .created(created) .broadcastEvent(true) .build()); + if (updated) { + publishEvictEvent(AiSettingsCacheEvictEvent.of(savedSettings.getTenantId(), savedSettings.getId())); + } + return savedSettings; } @@ -83,25 +98,20 @@ class AiSettingsServiceImpl implements AiSettingsService { @Override public Optional findAiSettingsByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) { - return aiSettingsDao.findByTenantIdAndId(tenantId, aiSettingsId); + var cacheKey = AiSettingsCacheKey.of(tenantId, aiSettingsId); + return Optional.ofNullable(cache.get(cacheKey, () -> aiSettingsDao.findByTenantIdAndId(tenantId, aiSettingsId).orElse(null))); } @Override + @Transactional public boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) { - Optional aiSettingsOpt = aiSettingsDao.findByTenantIdAndId(tenantId, aiSettingsId); - if (aiSettingsOpt.isEmpty()) { - return false; - } - boolean deleted = aiSettingsDao.deleteByTenantIdAndId(tenantId, aiSettingsId); - if (deleted) { - publishDeleteEvent(aiSettingsOpt.get()); - } - return deleted; + return deleteByTenantIdAndIdInternal(tenantId, aiSettingsId); } @Override public Optional> findEntity(TenantId tenantId, EntityId entityId) { - return Optional.ofNullable(aiSettingsDao.findById(tenantId, entityId.getId())); + return findAiSettingsByTenantIdAndId(tenantId, (AiSettingsId) entityId) + .map(aiSettings -> aiSettings); // necessary to cast to HasId } @Override @@ -110,8 +120,22 @@ class AiSettingsServiceImpl implements AiSettingsService { } @Override + @Transactional public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { - deleteByTenantIdAndId(tenantId, new AiSettingsId(id.getId())); + deleteByTenantIdAndIdInternal(tenantId, new AiSettingsId(id.getId())); + } + + private boolean deleteByTenantIdAndIdInternal(TenantId tenantId, AiSettingsId aiSettingsId) { + Optional aiSettingsOpt = aiSettingsDao.findByTenantIdAndId(tenantId, aiSettingsId); + if (aiSettingsOpt.isEmpty()) { + return false; + } + boolean deleted = aiSettingsDao.deleteByTenantIdAndId(tenantId, aiSettingsId); + if (deleted) { + publishDeleteEvent(aiSettingsOpt.get()); + publishEvictEvent(AiSettingsCacheEvictEvent.of(tenantId, aiSettingsId)); + } + return deleted; } @Override @@ -121,8 +145,16 @@ class AiSettingsServiceImpl implements AiSettingsService { if (deletedSettings.isEmpty()) { return; } + aiSettingsDao.deleteByTenantId(tenantId); - deletedSettings.forEach(this::publishDeleteEvent); + + Set cacheKeys = Sets.newHashSetWithExpectedSize(deletedSettings.size()); + deletedSettings.forEach(settings -> { + publishDeleteEvent(settings); + cacheKeys.add(AiSettingsCacheKey.of(settings.getTenantId(), settings.getId())); + }); + + publishEvictEvent(new AiSettingsCacheEvictEvent(cacheKeys)); } private void publishDeleteEvent(AiSettings settings) { From 00d32c23741b95608129638cabb6b5c73150d3ff Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 9 Jun 2025 17:11:52 +0300 Subject: [PATCH 029/249] AI rule node: fix wrapping in JSON object if response is not a valid JSON object --- .../main/java/org/thingsboard/rule/engine/ai/TbAiNode.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 315a02a3b9..564de8d66e 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 @@ -115,7 +115,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { addCallback(sendChatRequest(ctx, chatRequest), new FutureCallback<>() { @Override public void onSuccess(String response) { - if (!isValidJson(response)) { + if (!isValidJsonObject(response)) { response = wrapInJsonObject(response); } tellSuccess(ctx, ackedMsg.transform() @@ -134,9 +134,10 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { return ctx.getExternalCallExecutor().submit(() -> chatModel.chat(chatRequest).aiMessage().text()); } - private static boolean isValidJson(String jsonString) { + private static boolean isValidJsonObject(String jsonString) { try { - return JacksonUtil.toJsonNode(jsonString) != null; + JsonNode result = JacksonUtil.toJsonNode(jsonString); + return result != null && result.isObject(); } catch (IllegalArgumentException e) { return false; } From 109c87486118171d11e3c07770b928d8b446d011 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 9 Jun 2025 17:14:39 +0300 Subject: [PATCH 030/249] AI rule node: fix cache evict when creating new entity --- .../thingsboard/server/dao/ai/AiSettingsCacheKey.java | 2 ++ .../thingsboard/server/dao/ai/AiSettingsServiceImpl.java | 9 ++------- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheKey.java index fcf737d3a5..84ad396766 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheKey.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.ai; +import org.checkerframework.checker.nullness.qual.NonNull; import org.thingsboard.server.cache.VersionedCacheKey; import org.thingsboard.server.common.data.id.AiSettingsId; import org.thingsboard.server.common.data.id.TenantId; @@ -39,6 +40,7 @@ record AiSettingsCacheKey(UUID tenantId, UUID aiSettingsId) implements Versioned return true; } + @NonNull @Override public String toString() { return tenantId + "_" + aiSettingsId; diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java index 517d23c496..15e1074b96 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java @@ -66,21 +66,16 @@ class AiSettingsServiceImpl extends CachedVersionedEntityService Date: Mon, 9 Jun 2025 17:33:04 +0300 Subject: [PATCH 031/249] AI rule node: improve text search to be based on provider and model as well --- .../thingsboard/server/controller/ControllerConstants.java | 2 +- .../thingsboard/server/dao/sql/ai/AiSettingsRepository.java | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index 097cb4e577..b27c08b52c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -90,7 +90,7 @@ public class ControllerConstants { protected static final String TENANT_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the tenant profile name."; protected static final String RULE_CHAIN_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the rule chain name."; protected static final String DEVICE_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the device profile name."; - protected static final String AI_SETTINGS_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the AI settings name"; + protected static final String AI_SETTINGS_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the AI settings name, provider and model."; protected static final String ASSET_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the asset profile name."; protected static final String CUSTOMER_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the customer title."; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java index 691709f8b8..a73569eac2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java @@ -33,7 +33,10 @@ public interface AiSettingsRepository extends JpaRepository findByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable); Optional findByTenantIdAndId(UUID tenantId, UUID id); From a9633cf1cecfb83948bf1af439ec6459eee89f2f Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 9 Jun 2025 17:38:41 +0300 Subject: [PATCH 032/249] AI rule node: improve cache key readability --- .../java/org/thingsboard/server/dao/ai/AiSettingsCacheKey.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheKey.java index 84ad396766..d732c08fc8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheKey.java @@ -43,7 +43,7 @@ record AiSettingsCacheKey(UUID tenantId, UUID aiSettingsId) implements Versioned @NonNull @Override public String toString() { - return tenantId + "_" + aiSettingsId; + return /* cache name */ "_" + tenantId + "_" + aiSettingsId; } } From 19c234fcdf58175cd1d9401a91c194ac4c01081c Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 9 Jun 2025 17:51:24 +0300 Subject: [PATCH 033/249] AI rule node: configure chat model on each message --- .../org/thingsboard/rule/engine/ai/TbAiNode.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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 564de8d66e..6b0bb3803b 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 @@ -35,6 +35,7 @@ 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.id.AiSettingsId; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.dao.exception.DataValidationException; @@ -59,7 +60,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { private SystemMessage systemMessage; private PromptTemplate userPromptTemplate; private ResponseFormat responseFormat; - private ChatModel chatModel; + private AiSettingsId aiSettingsId; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { @@ -86,7 +87,8 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { Rule engine message type: {{msgType}}""" .formatted(config.getUserPrompt()) ); - chatModel = ctx.getAiService().configureChatModel(ctx.getTenantId(), config.getAiSettingsId()); + + aiSettingsId = config.getAiSettingsId(); } private static JsonSchema getJsonSchema(ResponseFormatType responseFormatType, JsonNode jsonSchema) { @@ -112,7 +114,9 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { .responseFormat(responseFormat) .build(); - addCallback(sendChatRequest(ctx, chatRequest), new FutureCallback<>() { + ChatModel chatModel = ctx.getAiService().configureChatModel(ctx.getTenantId(), aiSettingsId); + + addCallback(sendChatRequest(ctx, chatModel, chatRequest), new FutureCallback<>() { @Override public void onSuccess(String response) { if (!isValidJsonObject(response)) { @@ -130,7 +134,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { }, directExecutor()); } - private ListenableFuture sendChatRequest(TbContext ctx, ChatRequest chatRequest) { + private ListenableFuture sendChatRequest(TbContext ctx, ChatModel chatModel, ChatRequest chatRequest) { return ctx.getExternalCallExecutor().submit(() -> chatModel.chat(chatRequest).aiMessage().text()); } @@ -153,7 +157,6 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { systemMessage = null; userPromptTemplate = null; responseFormat = null; - chatModel = null; } } From d5c6ed1f618e20c094296de98501a152aaff31f4 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 10 Jun 2025 12:18:21 +0300 Subject: [PATCH 034/249] AI rule node: JSON ignore validation method in rule node config --- .../org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java | 2 ++ 1 file changed, 2 insertions(+) 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 c3234f61bb..f9341ae982 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 @@ -15,6 +15,7 @@ */ package org.thingsboard.rule.engine.ai; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.JsonNode; import dev.langchain4j.model.chat.request.ResponseFormatType; import jakarta.validation.constraints.AssertTrue; @@ -45,6 +46,7 @@ public class TbAiNodeConfiguration implements NodeConfiguration Date: Wed, 11 Jun 2025 17:42:03 +0300 Subject: [PATCH 035/249] AI rule node: add timeout support --- .../server/service/ai/AiServiceImpl.java | 39 ++++++++++++++----- .../common/data/ai/model/AiModelConfig.java | 9 +++++ .../model/GoogleAiGeminiChatModelConfig.java | 12 ++++++ .../ai/model/MistralAiChatModelConfig.java | 12 ++++++ .../data/ai/model/OpenAiChatModelConfig.java | 12 ++++++ .../rule/engine/api/RuleEngineAiService.java | 4 ++ .../thingsboard/rule/engine/ai/TbAiNode.java | 28 +++++++++++-- .../rule/engine/ai/TbAiNodeConfiguration.java | 9 ++++- 8 files changed, 111 insertions(+), 14 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java index 60d7dca7a8..eeddb5e6d2 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java @@ -23,13 +23,16 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.thingsboard.rule.engine.api.RuleEngineAiService; import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; import org.thingsboard.server.common.data.ai.model.GoogleAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.MistralAiChatModelConfig; import org.thingsboard.server.common.data.ai.model.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; import org.thingsboard.server.common.data.id.AiSettingsId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.ai.AiSettingsService; +import java.time.Duration; import java.util.NoSuchElementException; import java.util.Optional; @@ -46,37 +49,53 @@ class AiServiceImpl implements RuleEngineAiService { throw new NoSuchElementException("AI settings with ID: " + aiSettingsId + " were not found"); } var aiSettings = aiSettingsOpt.get(); + return configureChatModel(aiSettings.getProviderConfig(), aiSettings.getModelConfig()); + } - return switch (aiSettings.getProvider()) { + @Override + public ChatModel configureChatModel(AiProviderConfig providerConfig, AiModelConfig modelConfig) { + return switch (providerConfig.getProvider()) { case OPENAI -> { var modelBuilder = OpenAiChatModel.builder() - .apiKey(aiSettings.getProviderConfig().getApiKey()) - .modelName(aiSettings.getModel()); + .apiKey(providerConfig.getApiKey()) + .modelName(modelConfig.getModel()); - if (aiSettings.getModelConfig() instanceof OpenAiChatModelConfig config) { + if (modelConfig instanceof OpenAiChatModelConfig config) { modelBuilder.temperature(config.getTemperature()); + if (config.getTimeoutSeconds() != null) { + modelBuilder.timeout(Duration.ofSeconds(config.getTimeoutSeconds())); + } + modelBuilder.maxRetries(config.getMaxRetries()); } yield modelBuilder.build(); } case MISTRAL_AI -> { var modelBuilder = MistralAiChatModel.builder() - .apiKey(aiSettings.getProviderConfig().getApiKey()) - .modelName(aiSettings.getModel()); + .apiKey(providerConfig.getApiKey()) + .modelName(modelConfig.getModel()); - if (aiSettings.getModelConfig() instanceof MistralAiChatModelConfig config) { + if (modelConfig instanceof MistralAiChatModelConfig config) { modelBuilder.temperature(config.getTemperature()); + if (config.getTimeoutSeconds() != null) { + modelBuilder.timeout(Duration.ofSeconds(config.getTimeoutSeconds())); + } + modelBuilder.maxRetries(config.getMaxRetries()); } yield modelBuilder.build(); } case GOOGLE_AI_GEMINI -> { var modelBuilder = GoogleAiGeminiChatModel.builder() - .apiKey(aiSettings.getProviderConfig().getApiKey()) - .modelName(aiSettings.getModel()); + .apiKey(providerConfig.getApiKey()) + .modelName(modelConfig.getModel()); - if (aiSettings.getModelConfig() instanceof GoogleAiGeminiChatModelConfig config) { + if (modelConfig instanceof GoogleAiGeminiChatModelConfig config) { modelBuilder.temperature(config.getTemperature()); + if (config.getTimeoutSeconds() != null) { + modelBuilder.timeout(Duration.ofSeconds(config.getTimeoutSeconds())); + } + modelBuilder.maxRetries(config.getMaxRetries()); } yield modelBuilder.build(); 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 5112df3213..779b75812f 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 @@ -44,4 +44,13 @@ public abstract class AiModelConfig { ) private String model; + public abstract Integer getTimeoutSeconds(); + + public abstract void setTimeoutSeconds(Integer timeoutSeconds); + + public abstract Integer getMaxRetries(); + + public abstract void setMaxRetries(Integer timeoutSeconds); + + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/GoogleAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/GoogleAiGeminiChatModelConfig.java index 761ec998cc..1d85115f31 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/GoogleAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/GoogleAiGeminiChatModelConfig.java @@ -47,4 +47,16 @@ public final class GoogleAiGeminiChatModelConfig extends AiModelConfig { ) private Double temperature; + @Schema( + accessMode = Schema.AccessMode.READ_WRITE, + description = "Timeout (in seconds) for establishing HTTP connection" + ) + private Integer timeoutSeconds; + + @Schema( + accessMode = Schema.AccessMode.READ_WRITE, + description = "Maximum number of times to retry an LLM call upon exception (except for non-retriable ones like authentication or invalid request errors)" + ) + private Integer maxRetries; + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/MistralAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/MistralAiChatModelConfig.java index 20a8cfea7c..d333eb6283 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/MistralAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/MistralAiChatModelConfig.java @@ -47,4 +47,16 @@ public final class MistralAiChatModelConfig extends AiModelConfig { ) private Double temperature; + @Schema( + accessMode = Schema.AccessMode.READ_WRITE, + description = "Timeout (in seconds) for the entire HTTP call: applied to connect, read, and write operations" + ) + private Integer timeoutSeconds; + + @Schema( + accessMode = Schema.AccessMode.READ_WRITE, + description = "Maximum number of times to retry an LLM call upon exception (except for non-retriable ones like authentication or invalid request errors)" + ) + private Integer maxRetries; + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/OpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/OpenAiChatModelConfig.java index a7bc3725ef..accf5c92ec 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/OpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/OpenAiChatModelConfig.java @@ -47,4 +47,16 @@ public final class OpenAiChatModelConfig extends AiModelConfig { ) private Double temperature; + @Schema( + accessMode = Schema.AccessMode.READ_WRITE, + description = "Timeout (in seconds) for both establishing HTTP connection and receiving a response" + ) + private Integer timeoutSeconds; + + @Schema( + accessMode = Schema.AccessMode.READ_WRITE, + description = "Maximum number of times to retry an LLM call upon exception (except for non-retriable ones like authentication or invalid request errors)" + ) + private Integer maxRetries; + } diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAiService.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAiService.java index ae455d3b51..d493c5599a 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAiService.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAiService.java @@ -16,6 +16,8 @@ package org.thingsboard.rule.engine.api; import dev.langchain4j.model.chat.ChatModel; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; import org.thingsboard.server.common.data.id.AiSettingsId; import org.thingsboard.server.common.data.id.TenantId; @@ -23,4 +25,6 @@ public interface RuleEngineAiService { ChatModel configureChatModel(TenantId tenantId, AiSettingsId aiSettingsId); + ChatModel configureChatModel(AiProviderConfig providerConfig, AiModelConfig modelConfig); + } 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 6b0bb3803b..7ee354d581 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 @@ -35,6 +35,9 @@ 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.ai.AiSettings; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; import org.thingsboard.server.common.data.id.AiSettingsId; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; @@ -42,6 +45,7 @@ import org.thingsboard.server.dao.exception.DataValidationException; import java.util.List; import java.util.Map; +import java.util.Optional; import static com.google.common.util.concurrent.Futures.addCallback; import static com.google.common.util.concurrent.MoreExecutors.directExecutor; @@ -60,6 +64,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { private SystemMessage systemMessage; private PromptTemplate userPromptTemplate; private ResponseFormat responseFormat; + private int timeoutSeconds; private AiSettingsId aiSettingsId; @Override @@ -88,6 +93,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { .formatted(config.getUserPrompt()) ); + timeoutSeconds = config.getTimeoutSeconds(); aiSettingsId = config.getAiSettingsId(); } @@ -95,11 +101,11 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { if (responseFormatType == ResponseFormatType.TEXT) { return null; } - return responseFormatType == ResponseFormatType.JSON && jsonSchema != null ? Langchain4jJsonSchemaAdapter.fromJsonNode(jsonSchema) : null; + return responseFormatType == ResponseFormatType.JSON && jsonSchema != null && !jsonSchema.isNull() ? Langchain4jJsonSchemaAdapter.fromJsonNode(jsonSchema) : null; } @Override - public void onMsg(TbContext ctx, TbMsg msg) { + public void onMsg(TbContext ctx, TbMsg msg) throws TbNodeException { var ackedMsg = ackIfNeeded(ctx, msg); Map variables = Map.of( @@ -114,7 +120,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { .responseFormat(responseFormat) .build(); - ChatModel chatModel = ctx.getAiService().configureChatModel(ctx.getTenantId(), aiSettingsId); + ChatModel chatModel = configureChatModel(ctx); addCallback(sendChatRequest(ctx, chatModel, chatRequest), new FutureCallback<>() { @Override @@ -134,6 +140,21 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { }, directExecutor()); } + private ChatModel configureChatModel(TbContext ctx) throws TbNodeException { + Optional aiSettingsOpt = ctx.getAiSettingsService().findAiSettingsByTenantIdAndId(ctx.getTenantId(), aiSettingsId); + if (aiSettingsOpt.isEmpty()) { + throw new TbNodeException("AI settings with ID: " + aiSettingsId + " were not found", true); + } + + AiProviderConfig providerConfig = aiSettingsOpt.get().getProviderConfig(); + AiModelConfig modelConfig = aiSettingsOpt.get().getModelConfig(); + + modelConfig.setTimeoutSeconds(timeoutSeconds); + modelConfig.setMaxRetries(0); // disable retries to respect timeout set in rule node config + + return ctx.getAiService().configureChatModel(providerConfig, modelConfig); + } + private ListenableFuture sendChatRequest(TbContext ctx, ChatModel chatModel, ChatRequest chatRequest) { return ctx.getExternalCallExecutor().submit(() -> chatModel.chat(chatRequest).aiMessage().text()); } @@ -157,6 +178,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { systemMessage = null; userPromptTemplate = null; responseFormat = null; + aiSettingsId = 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 f9341ae982..7d2b9745ab 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 @@ -19,6 +19,8 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.JsonNode; import dev.langchain4j.model.chat.request.ResponseFormatType; import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -46,10 +48,14 @@ public class TbAiNodeConfiguration implements NodeConfiguration Date: Wed, 11 Jun 2025 17:51:38 +0300 Subject: [PATCH 036/249] AI rule node: use `ObjectNode` instead of more general `JsonNode` for JSON Schema config field --- .../rule/engine/ai/Langchain4jJsonSchemaAdapter.java | 5 +++-- .../main/java/org/thingsboard/rule/engine/ai/TbAiNode.java | 5 +++-- .../thingsboard/rule/engine/ai/TbAiNodeConfiguration.java | 6 +++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java index 9ad745f3ed..12988c209d 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java @@ -16,6 +16,7 @@ package org.thingsboard.rule.engine.ai; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import dev.langchain4j.model.chat.request.json.JsonArraySchema; import dev.langchain4j.model.chat.request.json.JsonBooleanSchema; import dev.langchain4j.model.chat.request.json.JsonEnumSchema; @@ -42,10 +43,10 @@ final class Langchain4jJsonSchemaAdapter { /** * Creates a Langchain4j {@link JsonSchema} from the given root JSON Schema node. * - * @param rootSchemaNode a valid JSON Schema as a Jackson {@link JsonNode} + * @param rootSchemaNode a valid JSON Schema as a Jackson {@link ObjectNode} * @return the corresponding Langchain4j {@link JsonSchema} */ - public static JsonSchema fromJsonNode(JsonNode rootSchemaNode) { + public static JsonSchema fromJsonNode(ObjectNode rootSchemaNode) { return JsonSchema.builder() .name(rootSchemaNode.get("title").textValue()) .rootElement(parse(rootSchemaNode)) 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 7ee354d581..0acba685ac 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 @@ -16,6 +16,7 @@ package org.thingsboard.rule.engine.ai; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.ListenableFuture; import dev.langchain4j.data.message.SystemMessage; @@ -97,11 +98,11 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { aiSettingsId = config.getAiSettingsId(); } - private static JsonSchema getJsonSchema(ResponseFormatType responseFormatType, JsonNode jsonSchema) { + private static JsonSchema getJsonSchema(ResponseFormatType responseFormatType, ObjectNode jsonSchema) { if (responseFormatType == ResponseFormatType.TEXT) { return null; } - return responseFormatType == ResponseFormatType.JSON && jsonSchema != null && !jsonSchema.isNull() ? Langchain4jJsonSchemaAdapter.fromJsonNode(jsonSchema) : null; + return responseFormatType == ResponseFormatType.JSON && jsonSchema != null ? Langchain4jJsonSchemaAdapter.fromJsonNode(jsonSchema) : null; } @Override 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 7d2b9745ab..96ecde330a 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 @@ -16,7 +16,7 @@ package org.thingsboard.rule.engine.ai; import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import dev.langchain4j.model.chat.request.ResponseFormatType; import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.Max; @@ -46,7 +46,7 @@ public class TbAiNodeConfiguration implements NodeConfiguration Date: Wed, 11 Jun 2025 18:10:01 +0300 Subject: [PATCH 037/249] AI rule node: ensure AI settings exist on rule node init --- .../java/org/thingsboard/rule/engine/ai/TbAiNode.java | 8 ++++++++ 1 file changed, 8 insertions(+) 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 0acba685ac..1477f321a8 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 @@ -95,6 +95,10 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { ); timeoutSeconds = config.getTimeoutSeconds(); + + if (!aiSettingsExist(ctx, config.getAiSettingsId())) { + throw new TbNodeException("[" + ctx.getTenantId() + "] AI settings with ID: " + config.getAiSettingsId() + " were not found", true); + } aiSettingsId = config.getAiSettingsId(); } @@ -105,6 +109,10 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { return responseFormatType == ResponseFormatType.JSON && jsonSchema != null ? Langchain4jJsonSchemaAdapter.fromJsonNode(jsonSchema) : null; } + private static boolean aiSettingsExist(TbContext ctx, AiSettingsId aiSettingsId) { + return ctx.getAiSettingsService().findAiSettingsByTenantIdAndId(ctx.getTenantId(), aiSettingsId).isPresent(); + } + @Override public void onMsg(TbContext ctx, TbMsg msg) throws TbNodeException { var ackedMsg = ackIfNeeded(ctx, msg); From fe9caaedd4e08ce1ae5af203385a508c52008d3d Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Wed, 11 Jun 2025 18:20:26 +0300 Subject: [PATCH 038/249] AI rule node: disable rule node on edges --- .../main/java/org/thingsboard/rule/engine/ai/TbAiNode.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 1477f321a8..41c76f589b 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 @@ -41,6 +41,7 @@ import org.thingsboard.server.common.data.ai.model.AiModelConfig; import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; import org.thingsboard.server.common.data.id.AiSettingsId; 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; @@ -58,7 +59,8 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie name = "AI", nodeDescription = "Interact with AI", nodeDetails = "This node makes requests to AI based on a prompt and a input message and returns a response in a form of output message", - configClazz = TbAiNodeConfiguration.class + configClazz = TbAiNodeConfiguration.class, + ruleChainTypes = RuleChainType.CORE ) public final class TbAiNode extends TbAbstractExternalNode implements TbNode { From db5e4f8d91564efeb371fde76d14ad5822311374 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Wed, 11 Jun 2025 19:35:30 +0300 Subject: [PATCH 039/249] AI rule node: make message processing non-blocking --- .../server/dao/ai/AiSettingsService.java | 3 + .../server/dao/ai/AiSettingsServiceImpl.java | 8 +++ .../thingsboard/rule/engine/ai/TbAiNode.java | 67 +++++++++---------- 3 files changed, 44 insertions(+), 34 deletions(-) diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java index f06923b008..83a66de876 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.ai; +import com.google.common.util.concurrent.FluentFuture; import org.thingsboard.server.common.data.ai.AiSettings; import org.thingsboard.server.common.data.id.AiSettingsId; import org.thingsboard.server.common.data.id.TenantId; @@ -34,6 +35,8 @@ public interface AiSettingsService extends EntityDaoService { Optional findAiSettingsByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId); + FluentFuture> findAiSettingsByTenantIdAndIdAsync(TenantId tenantId, AiSettingsId aiSettingsId); + boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java index 15e1074b96..8950e3559f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.ai; import com.google.common.collect.Sets; +import com.google.common.util.concurrent.FluentFuture; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,6 +33,7 @@ import org.thingsboard.server.dao.entity.CachedVersionedEntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.dao.sql.JpaExecutorService; import java.util.List; import java.util.Optional; @@ -45,6 +47,7 @@ class AiSettingsServiceImpl extends CachedVersionedEntityService aiSettingsValidator; + private final JpaExecutorService jpaExecutor; private final AiSettingsDao aiSettingsDao; @Override @@ -97,6 +100,11 @@ class AiSettingsServiceImpl extends CachedVersionedEntityService aiSettingsDao.findByTenantIdAndId(tenantId, aiSettingsId).orElse(null))); } + @Override + public FluentFuture> findAiSettingsByTenantIdAndIdAsync(TenantId tenantId, AiSettingsId aiSettingsId) { + return FluentFuture.from(jpaExecutor.submit(() -> findAiSettingsByTenantIdAndId(tenantId, aiSettingsId))); + } + @Override @Transactional public boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) { 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 41c76f589b..def5d03477 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 @@ -17,8 +17,8 @@ package org.thingsboard.rule.engine.ai; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.ListenableFuture; import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.model.chat.ChatModel; @@ -36,7 +36,6 @@ 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.ai.AiSettings; import org.thingsboard.server.common.data.ai.model.AiModelConfig; import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; import org.thingsboard.server.common.data.id.AiSettingsId; @@ -47,9 +46,8 @@ import org.thingsboard.server.dao.exception.DataValidationException; import java.util.List; import java.util.Map; -import java.util.Optional; +import java.util.NoSuchElementException; -import static com.google.common.util.concurrent.Futures.addCallback; import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static java.util.Objects.requireNonNullElse; import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; @@ -131,43 +129,44 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { .responseFormat(responseFormat) .build(); - ChatModel chatModel = configureChatModel(ctx); - - addCallback(sendChatRequest(ctx, chatModel, chatRequest), new FutureCallback<>() { - @Override - public void onSuccess(String response) { - if (!isValidJsonObject(response)) { - response = wrapInJsonObject(response); - } - tellSuccess(ctx, ackedMsg.transform() - .data(response) - .build()); - } - - @Override - public void onFailure(@NonNull Throwable t) { - tellFailure(ctx, ackedMsg, t); - } - }, directExecutor()); + configureChatModelAsync(ctx) + .transform(chatModel -> sendChatRequest(chatModel, chatRequest), ctx.getExternalCallExecutor()) + .addCallback(new FutureCallback<>() { + @Override + public void onSuccess(String response) { + if (!isValidJsonObject(response)) { + response = wrapInJsonObject(response); + } + tellSuccess(ctx, ackedMsg.transform() + .data(response) + .build()); + } + + @Override + public void onFailure(@NonNull Throwable t) { + tellFailure(ctx, ackedMsg, t); + } + }, directExecutor()); } - private ChatModel configureChatModel(TbContext ctx) throws TbNodeException { - Optional aiSettingsOpt = ctx.getAiSettingsService().findAiSettingsByTenantIdAndId(ctx.getTenantId(), aiSettingsId); - if (aiSettingsOpt.isEmpty()) { - throw new TbNodeException("AI settings with ID: " + aiSettingsId + " were not found", true); - } + private FluentFuture configureChatModelAsync(TbContext ctx) { + return ctx.getAiSettingsService().findAiSettingsByTenantIdAndIdAsync(ctx.getTenantId(), aiSettingsId).transform(aiSettingsOpt -> { + if (aiSettingsOpt.isEmpty()) { + throw new NoSuchElementException("AI settings with ID: " + aiSettingsId + " were not found"); + } - AiProviderConfig providerConfig = aiSettingsOpt.get().getProviderConfig(); - AiModelConfig modelConfig = aiSettingsOpt.get().getModelConfig(); + AiProviderConfig providerConfig = aiSettingsOpt.get().getProviderConfig(); + AiModelConfig modelConfig = aiSettingsOpt.get().getModelConfig(); - modelConfig.setTimeoutSeconds(timeoutSeconds); - modelConfig.setMaxRetries(0); // disable retries to respect timeout set in rule node config + modelConfig.setTimeoutSeconds(timeoutSeconds); + modelConfig.setMaxRetries(0); // disable retries to respect timeout set in rule node config - return ctx.getAiService().configureChatModel(providerConfig, modelConfig); + return ctx.getAiService().configureChatModel(providerConfig, modelConfig); + }, ctx.getDbCallbackExecutor()); } - private ListenableFuture sendChatRequest(TbContext ctx, ChatModel chatModel, ChatRequest chatRequest) { - return ctx.getExternalCallExecutor().submit(() -> chatModel.chat(chatRequest).aiMessage().text()); + private String sendChatRequest(ChatModel chatModel, ChatRequest chatRequest) { + return chatModel.chat(chatRequest).aiMessage().text(); } private static boolean isValidJsonObject(String jsonString) { From ff23fa03c0e9c257a3219997d2662194a7aac532 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Wed, 11 Jun 2025 21:17:48 +0300 Subject: [PATCH 040/249] AI rule node: add dedicated thread pool for AI requests --- .../server/actors/ActorSystemContext.java | 5 ++ .../actors/ruleChain/DefaultTbContext.java | 6 ++ .../ai/AiRequestsExecutorProperties.java | 43 +++++++++++++ .../service/ai/DefaultAiRequestsExecutor.java | 63 +++++++++++++++++++ .../src/main/resources/thingsboard.yml | 10 +++ .../rule/engine/api/AiRequestsExecutor.java | 27 ++++++++ .../rule/engine/api/TbContext.java | 2 + .../thingsboard/rule/engine/ai/TbAiNode.java | 10 ++- 8 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/ai/AiRequestsExecutorProperties.java create mode 100644 application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AiRequestsExecutor.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 e420d6a6f6..5fc7247061 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -30,6 +30,7 @@ import org.springframework.context.annotation.Lazy; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.AiRequestsExecutor; import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; @@ -319,6 +320,10 @@ public class ActorSystemContext { @Getter private AiSettingsService aiSettingsService; + @Autowired + @Getter + private AiRequestsExecutor aiRequestsExecutor; + @Autowired @Getter private EntityViewService entityViewService; 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 3aba1f5379..9df08b7b87 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 @@ -23,6 +23,7 @@ import org.bouncycastle.util.Arrays; import org.thingsboard.common.util.DebugModeUtil; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ListeningExecutor; +import org.thingsboard.rule.engine.api.AiRequestsExecutor; import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; @@ -1024,6 +1025,11 @@ public class DefaultTbContext implements TbContext { return mainCtx.getAiSettingsService(); } + @Override + public AiRequestsExecutor getAiRequestsExecutor() { + return mainCtx.getAiRequestsExecutor(); + } + @Override public MqttClientSettings getMqttClientSettings() { return mainCtx.getMqttClientSettings(); diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiRequestsExecutorProperties.java b/application/src/main/java/org/thingsboard/server/service/ai/AiRequestsExecutorProperties.java new file mode 100644 index 0000000000..52a0d0d57e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiRequestsExecutorProperties.java @@ -0,0 +1,43 @@ +/** + * 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.ai; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.annotation.Validated; + +@Data +@Validated +@Configuration +@ConfigurationProperties(prefix = "actors.rule.ai-requests-thread-pool") +class AiRequestsExecutorProperties { + + @NotBlank(message = "Pool name must be not blank") + private String poolName = "ai-requests"; + + @Min(value = 1, message = "Pool size must be at least 1") + private int poolSize = 50; + + @Min(value = 1, message = "Max queue size must be at least 1") + private int maxQueueSize = 10000; + + @Min(value = 1, message = "Termination timeout must be at least 1 second") + private int terminationTimeoutSeconds = 60; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java b/application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java new file mode 100644 index 0000000000..40a5bdbb63 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java @@ -0,0 +1,63 @@ +/** + * 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.ai; + +import com.google.common.util.concurrent.FluentFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.rule.engine.api.AiRequestsExecutor; + +import java.time.Duration; + +@Lazy +@Component +@RequiredArgsConstructor +class DefaultAiRequestsExecutor implements AiRequestsExecutor { + + private final AiRequestsExecutorProperties properties; + + private ListeningExecutorService executorService; + + @PostConstruct + private void init() { + executorService = MoreExecutors.listeningDecorator( + ThingsBoardExecutors.newLimitedTasksExecutor(properties.getPoolSize(), properties.getMaxQueueSize(), properties.getPoolName()) + ); + } + + @Override + public FluentFuture sendChatRequestAsync(ChatModel chatModel, ChatRequest chatRequest) { + return FluentFuture.from(executorService.submit(() -> chatModel.chat(chatRequest))); + } + + @PreDestroy + private void destroy() { + if (executorService != null) { + MoreExecutors.shutdownAndAwaitTermination(executorService, Duration.ofSeconds(properties.getTerminationTimeoutSeconds())); + executorService = null; + } + } + +} diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 54c1159003..dd41b13de0 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -463,6 +463,16 @@ actors: allow_system_sms_service: "${ACTORS_RULE_ALLOW_SYSTEM_SMS_SERVICE:true}" # Specify thread pool size for external call service external_call_thread_pool_size: "${ACTORS_RULE_EXTERNAL_CALL_THREAD_POOL_SIZE:50}" + # Configuration for the thread pool that executes HTTP calls to AI provider APIs + ai-requests-thread-pool: + # The base name for threads + pool-name: "${ACTORS_RULE_AI_REQUESTS_THREAD_POOL_NAME:ai-requests}" + # The maximum number of concurrent HTTP requests + pool-size: "${ACTORS_RULE_AI_REQUESTS_THREAD_POOL_SIZE:50}" + # The maximum queue size for pending AI requests + max-queue-size: "${ACTORS_RULE_AI_REQUESTS_THREAD_POOL_QUEUE_SIZE:10000}" + # The maximum time in seconds to wait for active tasks to complete during graceful shutdown + termination-timeout-seconds: "${ACTORS_RULE_AI_REQUESTS_THREAD_POOL_TERMINATION_TIMEOUT_SECONDS:60}" chain: # Errors for particular actors are persisted once per specified amount of milliseconds error_persist_frequency: "${ACTORS_RULE_CHAIN_ERROR_FREQUENCY:3000}" diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AiRequestsExecutor.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AiRequestsExecutor.java new file mode 100644 index 0000000000..9fd2a000ce --- /dev/null +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AiRequestsExecutor.java @@ -0,0 +1,27 @@ +/** + * 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.rule.engine.api; + +import com.google.common.util.concurrent.FluentFuture; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; + +public interface AiRequestsExecutor { + + FluentFuture sendChatRequestAsync(ChatModel chatModel, ChatRequest chatRequest); + +} 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 4f9e786357..e5306c0193 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 @@ -422,6 +422,8 @@ public interface TbContext { AiSettingsService getAiSettingsService(); + AiRequestsExecutor getAiRequestsExecutor(); + // Configuration parameters for the MQTT client that is used in the MQTT node and Azure IoT hub node MqttClientSettings getMqttClientSettings(); 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 def5d03477..955442e79b 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 @@ -26,6 +26,7 @@ import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.request.ResponseFormat; import dev.langchain4j.model.chat.request.ResponseFormatType; import dev.langchain4j.model.chat.request.json.JsonSchema; +import dev.langchain4j.model.chat.response.ChatResponse; import dev.langchain4j.model.input.PromptTemplate; import org.checkerframework.checker.nullness.qual.NonNull; import org.thingsboard.common.util.JacksonUtil; @@ -130,10 +131,11 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { .build(); configureChatModelAsync(ctx) - .transform(chatModel -> sendChatRequest(chatModel, chatRequest), ctx.getExternalCallExecutor()) + .transformAsync(chatModel -> ctx.getAiRequestsExecutor().sendChatRequestAsync(chatModel, chatRequest), directExecutor()) .addCallback(new FutureCallback<>() { @Override - public void onSuccess(String response) { + public void onSuccess(ChatResponse chatResponse) { + String response = chatResponse.aiMessage().text(); if (!isValidJsonObject(response)) { response = wrapInJsonObject(response); } @@ -165,10 +167,6 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { }, ctx.getDbCallbackExecutor()); } - private String sendChatRequest(ChatModel chatModel, ChatRequest chatRequest) { - return chatModel.chat(chatRequest).aiMessage().text(); - } - private static boolean isValidJsonObject(String jsonString) { try { JsonNode result = JacksonUtil.toJsonNode(jsonString); From 9567dd3090c04eda484c86e69620e3fd26bc739e Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 12 Jun 2025 16:47:44 +0300 Subject: [PATCH 041/249] AI rule node: encapsulate AI requests thread pool properties --- .../ai/AiRequestsExecutorProperties.java | 43 ------------------- .../service/ai/DefaultAiRequestsExecutor.java | 26 +++++++++++ 2 files changed, 26 insertions(+), 43 deletions(-) delete mode 100644 application/src/main/java/org/thingsboard/server/service/ai/AiRequestsExecutorProperties.java diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiRequestsExecutorProperties.java b/application/src/main/java/org/thingsboard/server/service/ai/AiRequestsExecutorProperties.java deleted file mode 100644 index 52a0d0d57e..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/ai/AiRequestsExecutorProperties.java +++ /dev/null @@ -1,43 +0,0 @@ -/** - * 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.ai; - -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Configuration; -import org.springframework.validation.annotation.Validated; - -@Data -@Validated -@Configuration -@ConfigurationProperties(prefix = "actors.rule.ai-requests-thread-pool") -class AiRequestsExecutorProperties { - - @NotBlank(message = "Pool name must be not blank") - private String poolName = "ai-requests"; - - @Min(value = 1, message = "Pool size must be at least 1") - private int poolSize = 50; - - @Min(value = 1, message = "Max queue size must be at least 1") - private int maxQueueSize = 10000; - - @Min(value = 1, message = "Termination timeout must be at least 1 second") - private int terminationTimeoutSeconds = 60; - -} diff --git a/application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java b/application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java index 40a5bdbb63..ae0663eefc 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java @@ -23,9 +23,15 @@ import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.response.ChatResponse; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.rule.engine.api.AiRequestsExecutor; @@ -38,6 +44,26 @@ class DefaultAiRequestsExecutor implements AiRequestsExecutor { private final AiRequestsExecutorProperties properties; + @Data + @Validated + @Configuration + @ConfigurationProperties(prefix = "actors.rule.ai-requests-thread-pool") + private static class AiRequestsExecutorProperties { + + @NotBlank(message = "Pool name must be not blank") + private String poolName = "ai-requests"; + + @Min(value = 1, message = "Pool size must be at least 1") + private int poolSize = 50; + + @Min(value = 1, message = "Max queue size must be at least 1") + private int maxQueueSize = 10000; + + @Min(value = 1, message = "Termination timeout must be at least 1 second") + private int terminationTimeoutSeconds = 60; + + } + private ListeningExecutorService executorService; @PostConstruct From d2d22a44c21b504cc681e6e88710c8664485e50f Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 17 Jun 2025 18:17:38 +0300 Subject: [PATCH 042/249] AI rule node: add support for all data/metadata patterns in rule nodes --- .../server/common/msg/TbMsgMetaData.java | 20 ++- .../rule/engine/api/util/TbNodeUtils.java | 43 ++--- .../rule/engine/api/util/TbNodeUtilsTest.java | 158 ++++++++++++++++++ 3 files changed, 191 insertions(+), 30 deletions(-) diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java index e703183883..ef083ebccb 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java @@ -23,9 +23,6 @@ import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -/** - * Created by ashvayka on 13.01.18. - */ @Data public final class TbMsgMetaData implements Serializable { @@ -34,7 +31,7 @@ public final class TbMsgMetaData implements Serializable { private final Map data; public TbMsgMetaData() { - this.data = new ConcurrentHashMap<>(); + data = new ConcurrentHashMap<>(); } public TbMsgMetaData(Map data) { @@ -46,24 +43,29 @@ public final class TbMsgMetaData implements Serializable { * Internal constructor to create immutable TbMsgMetaData.EMPTY * */ private TbMsgMetaData(int ignored) { - this.data = Collections.emptyMap(); + data = Collections.emptyMap(); } public String getValue(String key) { - return this.data.get(key); + return data.get(key); } public void putValue(String key, String value) { if (key != null && value != null) { - this.data.put(key, value); + data.put(key, value); } } public Map values() { - return new HashMap<>(this.data); + return new HashMap<>(data); } public TbMsgMetaData copy() { - return new TbMsgMetaData(this.data); + return new TbMsgMetaData(data); } + + public boolean isEmpty() { + return data == null || data.isEmpty(); + } + } diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java index 78ddba15d1..ae8faecb0b 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java @@ -16,11 +16,11 @@ package org.thingsboard.rule.engine.api.util; import com.fasterxml.jackson.databind.JsonNode; -import org.springframework.util.CollectionUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.util.CollectionsUtil; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; @@ -29,15 +29,18 @@ import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Collectors; -/** - * Created by ashvayka on 19.01.18. - */ -public class TbNodeUtils { +public final class TbNodeUtils { + + private TbNodeUtils() { + throw new IllegalStateException("Utility class"); + } private static final Pattern DATA_PATTERN = Pattern.compile("(\\$\\[)(.*?)(])"); + private static final String ALL_DATA_TEMPLATE = "$[*]"; + private static final String ALL_METADATA_TEMPLATE = "${*}"; + public static T convert(TbNodeConfiguration configuration, Class clazz) throws TbNodeException { try { return JacksonUtil.treeToValue(configuration.getData(), clazz); @@ -47,16 +50,19 @@ public class TbNodeUtils { } public static List processPatterns(List patterns, TbMsg tbMsg) { - if (!CollectionUtils.isEmpty(patterns)) { - return patterns.stream().map(p -> processPattern(p, tbMsg)).collect(Collectors.toList()); + if (CollectionsUtil.isEmpty(patterns)) { + return Collections.emptyList(); } - return Collections.emptyList(); + return patterns.stream().map(p -> processPattern(p, tbMsg)).toList(); } public static String processPattern(String pattern, TbMsg tbMsg) { try { String result = processPattern(pattern, tbMsg.getMetaData()); JsonNode json = JacksonUtil.toJsonNode(tbMsg.getData()); + + result = result.replace(ALL_DATA_TEMPLATE, JacksonUtil.toString(json)); + if (json.isObject()) { Matcher matcher = DATA_PATTERN.matcher(result); while (matcher.find()) { @@ -64,7 +70,7 @@ public class TbNodeUtils { String[] keys = group.split("\\."); JsonNode jsonNode = json; for (String key : keys) { - if (!StringUtils.isEmpty(key) && jsonNode != null) { + if (StringUtils.isNotEmpty(key) && jsonNode != null) { jsonNode = jsonNode.get(key); } else { jsonNode = null; @@ -83,15 +89,9 @@ public class TbNodeUtils { } } - @Deprecated(since = "3.6.1", forRemoval = true) - public static List processPatterns(List patterns, TbMsgMetaData metaData) { - if (!CollectionUtils.isEmpty(patterns)) { - return patterns.stream().map(p -> processPattern(p, metaData)).collect(Collectors.toList()); - } - return Collections.emptyList(); - } - - public static String processPattern(String pattern, TbMsgMetaData metaData) { + private static String processPattern(String pattern, TbMsgMetaData metaData) { + String replacement = metaData.isEmpty() ? "{}" : JacksonUtil.toString(metaData.getData()); + pattern = pattern.replace(ALL_METADATA_TEMPLATE, replacement); return processTemplate(pattern, metaData.values()); } @@ -108,10 +108,11 @@ public class TbNodeUtils { } static String formatDataVarTemplate(String key) { - return "$[" + key + ']'; + return "$[" + key + "]"; } static String formatMetadataVarTemplate(String key) { - return "${" + key + '}'; + return "${" + key + "}"; } + } diff --git a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java index 7651a46b62..89d775f305 100644 --- a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java +++ b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java @@ -26,6 +26,8 @@ import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import java.util.Map; + import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; @@ -167,4 +169,160 @@ public class TbNodeUtilsTest { assertThat(TbNodeUtils.formatMetadataVarTemplate(null), is("${null}")); assertThat(TbNodeUtils.formatMetadataVarTemplate(null), is(String.format(METADATA_VARIABLE_TEMPLATE, (String) null))); } + + @Test + public void testAllMetadataTemplateReplacement() { + // GIVEN + String pattern = "META ${*}"; + var metadata = new TbMsgMetaData(); + metadata.putValue("meta_key", "meta_value"); + + var msg = TbMsg.newMsg() + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(metadata) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "META {\"meta_key\":\"meta_value\"}"; + assertThat(actual, is(expected)); + } + + @Test + public void testMultipleAllMetadataTemplatesReplacement() { + // GIVEN + String pattern = "${*} then again ${*}"; + var metadata = new TbMsgMetaData(); + metadata.putValue("meta_key", "meta_value"); + + var msg = TbMsg.newMsg() + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(metadata) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "{\"meta_key\":\"meta_value\"} then again {\"meta_key\":\"meta_value\"}"; + assertThat(actual, is(expected)); + } + + @Test + public void testAllDataTemplateReplacement() { + // GIVEN + String pattern = "DATA $[*]"; + var dataJson = JacksonUtil.newObjectNode().put("data_key", "data_value"); + + var msg = TbMsg.newMsg() + .data(JacksonUtil.toString(dataJson)) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "DATA {\"data_key\":\"data_value\"}"; + assertThat(actual, is(expected)); + } + + @Test + public void testMultipleAllDataTemplatesReplacement() { + // GIVEN + String pattern = "$[*] then again $[*]"; + var dataJson = JacksonUtil.newObjectNode().put("data_key", "data_value"); + + var msg = TbMsg.newMsg() + .data(JacksonUtil.toString(dataJson)) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "{\"data_key\":\"data_value\"} then again {\"data_key\":\"data_value\"}"; + assertThat(actual, is(expected)); + } + + @Test + public void testAllDataAndAllMetadataTemplatesSimultaneously() { + // GIVEN + String pattern = "META ${*} DATA $[*]"; + + var metadata = new TbMsgMetaData(Map.of("meta_key", "meta_value")); + var dataJson = JacksonUtil.newObjectNode().put("data_key", "data_value"); + + var msg = TbMsg.newMsg() + .data(JacksonUtil.toString(dataJson)) + .metaData(metadata) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "META {\"meta_key\":\"meta_value\"} DATA {\"data_key\":\"data_value\"}"; + assertThat(actual, is(expected)); + } + + @Test + public void testAllDataAndAllMetadataTemplatesSimultaneouslyEmpty() { + // GIVEN + String pattern = "META ${*} DATA $[*]"; + + var msg = TbMsg.newMsg() + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "META {} DATA {}"; + assertThat(actual, is(expected)); + } + + @Test + public void testAllDataTemplateArray() { + // GIVEN + String pattern = "DATA $[*]"; + + var msg = TbMsg.newMsg() + .data("[1, \"two\", true]") + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "DATA [1,\"two\",true]"; + assertThat(actual, is(expected)); + } + + @Test + public void testMixedAllDataMetadataAndNormalTemplates() { + // GIVEN + String pattern = "fullMeta=${*}, singleMeta=${meta_key}, fullData=$[*], singleData=$[data_key]"; + var metadata = new TbMsgMetaData(Map.of("meta_key", "meta_value")); + var dataJson = JacksonUtil.newObjectNode().put("data_key", "data_value"); + + var msg = TbMsg.newMsg() + .data(JacksonUtil.toString(dataJson)) + .metaData(metadata) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "fullMeta={\"meta_key\":\"meta_value\"}, singleMeta=meta_value, fullData={\"data_key\":\"data_value\"}, singleData=data_value"; + assertThat(actual, is(expected)); + } + } From c09639701084382ffae044402bcc8d4282ce1184 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 17 Jun 2025 18:39:16 +0300 Subject: [PATCH 043/249] AI rule node: support node patterns in prompts --- .../thingsboard/rule/engine/ai/TbAiNode.java | 30 +++++-------------- 1 file changed, 8 insertions(+), 22 deletions(-) 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 955442e79b..4d56b43c8e 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 @@ -27,7 +27,6 @@ import dev.langchain4j.model.chat.request.ResponseFormat; import dev.langchain4j.model.chat.request.ResponseFormatType; import dev.langchain4j.model.chat.request.json.JsonSchema; import dev.langchain4j.model.chat.response.ChatResponse; -import dev.langchain4j.model.input.PromptTemplate; import org.checkerframework.checker.nullness.qual.NonNull; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; @@ -46,11 +45,9 @@ import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.dao.exception.DataValidationException; import java.util.List; -import java.util.Map; import java.util.NoSuchElementException; import static com.google.common.util.concurrent.MoreExecutors.directExecutor; -import static java.util.Objects.requireNonNullElse; import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; @RuleNode( @@ -63,8 +60,8 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie ) public final class TbAiNode extends TbAbstractExternalNode implements TbNode { - private SystemMessage systemMessage; - private PromptTemplate userPromptTemplate; + private String systemPrompt; + private String userPrompt; private ResponseFormat responseFormat; private int timeoutSeconds; private AiSettingsId aiSettingsId; @@ -86,15 +83,8 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { .jsonSchema(getJsonSchema(config.getResponseFormatType(), config.getJsonSchema())) .build(); - systemMessage = SystemMessage.from(config.getSystemPrompt()); - userPromptTemplate = PromptTemplate.from(""" - User-provided task or question: %s - Rule engine message payload: {{msgPayload}} - Rule engine message metadata: {{msgMetadata}} - Rule engine message type: {{msgType}}""" - .formatted(config.getUserPrompt()) - ); - + systemPrompt = config.getSystemPrompt(); + userPrompt = config.getUserPrompt(); timeoutSeconds = config.getTimeoutSeconds(); if (!aiSettingsExist(ctx, config.getAiSettingsId())) { @@ -118,12 +108,8 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { public void onMsg(TbContext ctx, TbMsg msg) throws TbNodeException { var ackedMsg = ackIfNeeded(ctx, msg); - Map variables = Map.of( - "msgPayload", msg.getData(), - "msgMetadata", requireNonNullElse(JacksonUtil.toString(msg.getMetaData().getData()), "{}"), - "msgType", msg.getType() - ); - UserMessage userMessage = userPromptTemplate.apply(variables).toUserMessage(); + var systemMessage = SystemMessage.from(TbNodeUtils.processPattern(systemPrompt, ackedMsg)); + var userMessage = UserMessage.from(TbNodeUtils.processPattern(userPrompt, ackedMsg)); var chatRequest = ChatRequest.builder() .messages(List.of(systemMessage, userMessage)) @@ -183,8 +169,8 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { @Override public void destroy() { super.destroy(); - systemMessage = null; - userPromptTemplate = null; + systemPrompt = null; + userPrompt = null; responseFormat = null; aiSettingsId = null; } From 9bffff2d75dd5263a1ecc7fe4272fb841a299a76 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 17 Jun 2025 18:39:33 +0300 Subject: [PATCH 044/249] AI rule node: fix typo --- .../rule/engine/ai/Langchain4jJsonSchemaAdapter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java index 12988c209d..e136bfaea8 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java @@ -32,7 +32,7 @@ import java.util.ArrayList; import java.util.List; /** - * Converts a Jackson {@link JsonNode} JSON Schema into a Langchain4j {@link JsonSchema} model. + * Converts a Jackson {@link ObjectNode} JSON Schema into a Langchain4j {@link JsonSchema} model. */ final class Langchain4jJsonSchemaAdapter { From 459cc6d27e0c808f14b54d2bdb268937344ca5d1 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 20 Jun 2025 12:21:29 +0300 Subject: [PATCH 045/249] AI rule node: bump Langchain4j version --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c68cc9e1e1..c9303e9715 100755 --- a/pom.xml +++ b/pom.xml @@ -134,7 +134,7 @@ 1.7.5 3.8.0 2.9.0 - 1.0.1 + 1.1.0 4.2.1 2.7.3 From 1343c4af3bc108e0a68b32cc0171ea0559d88202 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 23 Jun 2025 16:16:42 +0300 Subject: [PATCH 046/249] AI rule node: refactor model config data structure; rename from AI settings to AI model settings --- .../main/data/upgrade/basic/schema_update.sql | 9 +- .../server/actors/ActorSystemContext.java | 8 +- .../actors/ruleChain/DefaultTbContext.java | 12 +- ...er.java => AiModelSettingsController.java} | 74 ++++++------ .../server/controller/BaseController.java | 20 ++-- .../server/service/ai/AiModelServiceImpl.java | 37 ++++++ .../server/service/ai/AiServiceImpl.java | 106 ----------------- .../Langchain4jChatModelConfigurerImpl.java | 70 +++++++++++ .../edge/EdgeEventSourcingListener.java | 4 +- .../edge/RelatedEdgesSourcingListener.java | 2 +- .../entitiy/EntityStateSourcingListener.java | 4 +- ...a => DefaultTbAiModelSettingsService.java} | 30 ++--- ...ice.java => TbAiModelSettingsService.java} | 8 +- .../queue/DefaultTbClusterService.java | 2 +- .../service/security/permission/Resource.java | 2 +- .../permission/TenantAdminPermissions.java | 10 +- .../src/main/resources/thingsboard.yml | 8 +- ...rvice.java => AiModelSettingsService.java} | 18 +-- common/data/pom.xml | 4 + .../server/common/data/CacheConstants.java | 2 +- .../server/common/data/EntityType.java | 4 +- .../{AiSettings.java => AiModelSettings.java} | 50 ++------ .../server/common/data/ai/model/AiModel.java | 48 ++++++++ .../common/data/ai/model/AiModelConfig.java | 40 +------ .../common/data/ai/model/AiModelType.java | 22 ++++ .../model/GoogleAiGeminiChatModelConfig.java | 62 ---------- .../ai/model/MistralAiChatModelConfig.java | 62 ---------- .../data/ai/model/OpenAiChatModelConfig.java | 62 ---------- .../data/ai/model/chat/AiChatModel.java | 38 ++++++ .../data/ai/model/chat/AiChatModelConfig.java | 35 ++++++ .../model/chat/GoogleAiGeminiChatModel.java | 60 ++++++++++ .../chat/Langchain4jChatModelConfigurer.java | 28 +++++ .../ai/model/chat/MistralAiChatModel.java | 60 ++++++++++ .../data/ai/model/chat/OpenAiChatModel.java | 60 ++++++++++ .../data/ai/provider/AiProviderConfig.java | 22 +--- .../GoogleAiGeminiProviderConfig.java | 28 ++--- .../ai/provider/MistralAiProviderConfig.java | 28 ++--- .../ai/provider/OpenAiProviderConfig.java | 28 ++--- ...SettingsId.java => AiModelSettingsId.java} | 16 +-- .../common/data/id/EntityIdFactory.java | 2 +- common/proto/src/main/proto/queue.proto | 2 +- ...va => AiModelSettingsCacheEvictEvent.java} | 8 +- ...eKey.java => AiModelSettingsCacheKey.java} | 14 +-- ...java => AiModelSettingsCaffeineCache.java} | 10 +- ...ttingsDao.java => AiModelSettingsDao.java} | 12 +- ...he.java => AiModelSettingsRedisCache.java} | 8 +- ...l.java => AiModelSettingsServiceImpl.java} | 84 +++++++------- .../dao/housekeeper/CleanUpService.java | 2 +- .../server/dao/model/ModelConstants.java | 15 +-- ...Entity.java => AiModelSettingsEntity.java} | 64 ++++------ .../AiModelSettingsDataValidator.java | 86 ++++++++++++++ .../validator/AiSettingsDataValidator.java | 109 ------------------ ...ry.java => AiModelSettingsRepository.java} | 28 ++--- .../server/dao/sql/ai/JpaAiSettingsDao.java | 44 +++---- .../server/dao/tenant/TenantServiceImpl.java | 2 +- .../main/resources/sql/schema-entities.sql | 9 +- ...ice.java => RuleEngineAiModelService.java} | 12 +- .../rule/engine/api/TbContext.java | 6 +- .../thingsboard/rule/engine/ai/TbAiNode.java | 54 +++++---- .../rule/engine/ai/TbAiNodeConfiguration.java | 4 +- .../rule/engine/util/TenantIdLoader.java | 6 +- .../rule/engine/util/TenantIdLoaderTest.java | 16 +-- 62 files changed, 901 insertions(+), 879 deletions(-) rename application/src/main/java/org/thingsboard/server/controller/{AiSettingsController.java => AiModelSettingsController.java} (65%) create mode 100644 application/src/main/java/org/thingsboard/server/service/ai/AiModelServiceImpl.java delete mode 100644 application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java create mode 100644 application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java rename application/src/main/java/org/thingsboard/server/service/entitiy/ai/{DefaultTbAiSettingsService.java => DefaultTbAiModelSettingsService.java} (60%) rename application/src/main/java/org/thingsboard/server/service/entitiy/ai/{TbAiSettingsService.java => TbAiModelSettingsService.java} (76%) rename common/dao-api/src/main/java/org/thingsboard/server/dao/ai/{AiSettingsService.java => AiModelSettingsService.java} (55%) rename common/data/src/main/java/org/thingsboard/server/common/data/ai/{AiSettings.java => AiModelSettings.java} (52%) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelType.java delete mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/GoogleAiGeminiChatModelConfig.java delete mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/MistralAiChatModelConfig.java delete mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/OpenAiChatModelConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java rename common/data/src/main/java/org/thingsboard/server/common/data/id/{AiSettingsId.java => AiModelSettingsId.java} (72%) rename dao/src/main/java/org/thingsboard/server/dao/ai/{AiSettingsCacheEvictEvent.java => AiModelSettingsCacheEvictEvent.java} (68%) rename dao/src/main/java/org/thingsboard/server/dao/ai/{AiSettingsCacheKey.java => AiModelSettingsCacheKey.java} (70%) rename dao/src/main/java/org/thingsboard/server/dao/ai/{AiSettingsCaffeineCache.java => AiModelSettingsCaffeineCache.java} (75%) rename dao/src/main/java/org/thingsboard/server/dao/ai/{AiSettingsDao.java => AiModelSettingsDao.java} (64%) rename dao/src/main/java/org/thingsboard/server/dao/ai/{AiSettingsRedisCache.java => AiModelSettingsRedisCache.java} (73%) rename dao/src/main/java/org/thingsboard/server/dao/ai/{AiSettingsServiceImpl.java => AiModelSettingsServiceImpl.java} (50%) rename dao/src/main/java/org/thingsboard/server/dao/model/sql/{AiSettingsEntity.java => AiModelSettingsEntity.java} (53%) create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelSettingsDataValidator.java delete mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/AiSettingsDataValidator.java rename dao/src/main/java/org/thingsboard/server/dao/sql/ai/{AiSettingsRepository.java => AiModelSettingsRepository.java} (57%) rename rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/{RuleEngineAiService.java => RuleEngineAiModelService.java} (60%) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 9685847278..f77c8dce67 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -14,15 +14,12 @@ -- limitations under the License. -- -CREATE TABLE ai_settings ( +CREATE TABLE ai_model_settings ( id UUID NOT NULL PRIMARY KEY, created_time BIGINT NOT NULL, tenant_id UUID NOT NULL, version BIGINT NOT NULL DEFAULT 1, name VARCHAR(255) NOT NULL, - provider VARCHAR(255) NOT NULL, - provider_config JSONB NOT NULL, - model VARCHAR(255) NOT NULL, - model_config JSONB, - CONSTRAINT ai_settings_name_unq_key UNIQUE (tenant_id, name) + configuration JSONB NOT NULL, + CONSTRAINT ai_model_settings_name_unq_key UNIQUE (tenant_id, name) ); 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 5fc7247061..a81e312bf6 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -35,7 +35,7 @@ import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; -import org.thingsboard.rule.engine.api.RuleEngineAiService; +import org.thingsboard.rule.engine.api.RuleEngineAiModelService; import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.rule.engine.api.notification.SlackService; import org.thingsboard.rule.engine.api.sms.SmsSenderFactory; @@ -63,7 +63,7 @@ import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.common.msg.tools.TbRateLimits; import org.thingsboard.server.common.stats.TbApiUsageReportClient; -import org.thingsboard.server.dao.ai.AiSettingsService; +import org.thingsboard.server.dao.ai.AiModelSettingsService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -314,11 +314,11 @@ public class ActorSystemContext { @Autowired @Getter - private RuleEngineAiService aiService; + private RuleEngineAiModelService aiModelService; @Autowired @Getter - private AiSettingsService aiSettingsService; + private AiModelSettingsService aiModelSettingsService; @Autowired @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 9df08b7b87..5778aba4ef 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 @@ -28,7 +28,7 @@ import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; -import org.thingsboard.rule.engine.api.RuleEngineAiService; +import org.thingsboard.rule.engine.api.RuleEngineAiModelService; import org.thingsboard.rule.engine.api.RuleEngineAlarmService; import org.thingsboard.rule.engine.api.RuleEngineApiUsageStateService; import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache; @@ -77,7 +77,7 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.TbMsgProcessingStackItem; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; -import org.thingsboard.server.dao.ai.AiSettingsService; +import org.thingsboard.server.dao.ai.AiModelSettingsService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -1016,13 +1016,13 @@ public class DefaultTbContext implements TbContext { } @Override - public RuleEngineAiService getAiService() { - return mainCtx.getAiService(); + public RuleEngineAiModelService getAiModelService() { + return mainCtx.getAiModelService(); } @Override - public AiSettingsService getAiSettingsService() { - return mainCtx.getAiSettingsService(); + public AiModelSettingsService getAiModelSettingsService() { + return mainCtx.getAiModelSettingsService(); } @Override diff --git a/application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java b/application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java similarity index 65% rename from application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java rename to application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java index 530fc1e56a..891f6b176c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java @@ -26,9 +26,9 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.ai.AiModelSettings; import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.id.AiModelSettingsId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.service.security.permission.Operation; @@ -47,70 +47,70 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERT import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; @RestController -@RequestMapping("/api/ai-settings") -public class AiSettingsController extends BaseController { +@RequestMapping("/api/ai-model-settings") +public class AiModelSettingsController extends BaseController { - private static final Set ALLOWED_SORT_PROPERTIES = Set.of("createdTime", "name", "provider", "model"); + private static final Set ALLOWED_SORT_PROPERTIES = Set.of("createdTime", "name"); @ApiOperation( - value = "Create or update AI settings (saveAiSettings)", - notes = "Creates or updates an AI settings record.\n\n" + + value = "Create or update AI model settings (saveAiModelSettings)", + notes = "Creates or updates an AI model settings record.\n\n" + "• **Create:** Omit the `id` to create a new record. The platform assigns a UUID to the new settings and returns it in the `id` field of the response.\n\n" + "• **Update:** Include an existing `id` to modify that record. If no matching record exists, the API responds with **404 Not Found**.\n\n" + - "Tenant ID for the AI settings will be taken from the authenticated user making the request, regardless of any value provided in the request body." + + "Tenant ID for the AI model settings will be taken from the authenticated user making the request, regardless of any value provided in the request body." + TENANT_AUTHORITY_PARAGRAPH ) @PreAuthorize("hasAuthority('TENANT_ADMIN')") @PostMapping - public AiSettings saveAiSettings(@RequestBody AiSettings aiSettings) throws ThingsboardException { + public AiModelSettings saveAiModelSettings(@RequestBody AiModelSettings settings) throws ThingsboardException { var user = getCurrentUser(); - aiSettings.setTenantId(user.getTenantId()); - checkEntity(aiSettings.getId(), aiSettings, Resource.AI_SETTINGS); - return tbAiSettingsService.save(aiSettings, user); + settings.setTenantId(user.getTenantId()); + checkEntity(settings.getId(), settings, Resource.AI_MODEL_SETTINGS); + return tbAiModelSettingsService.save(settings, user); } @ApiOperation( - value = "Get AI settings by ID (getAiSettingsById)", - notes = "Fetches an AI settings record by its `id`." + + value = "Get AI model settings by ID (getAiModelSettingsById)", + notes = "Fetches an AI model settings record by its `id`." + TENANT_AUTHORITY_PARAGRAPH ) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping("/{aiSettingsId}") - public AiSettings getAiSettingsById( + @GetMapping("/{aiModelSettingsId}") + public AiModelSettings getAiModelSettingsById( @Parameter( - description = "ID of the AI settings record", + description = "ID of the AI model settings record", required = true, example = "de7900d4-30e2-11f0-9cd2-0242ac120002" ) - @PathVariable("aiSettingsId") UUID aiSettingsUuid + @PathVariable("aiModelSettingsId") UUID aiModelSettingsUuid ) throws ThingsboardException { - return checkAiSettingsId(new AiSettingsId(aiSettingsUuid), Operation.READ); + return checkAiModelSettingsId(new AiModelSettingsId(aiModelSettingsUuid), Operation.READ); } @ApiOperation( - value = "Get AI settings (getAiSettings)", - notes = "Returns a page of AI settings. " + + value = "Get AI model settings (getAiModelSettings)", + notes = "Returns a page of AI model settings. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH ) @PreAuthorize("hasAuthority('TENANT_ADMIN')") @GetMapping - public PageData getAiSettings( + public PageData getAiModelSettings( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) @RequestParam int page, @Parameter(description = AI_SETTINGS_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch, - @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name", "provider", "model"})) + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"})) @RequestParam(required = false) String sortProperty, @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @RequestParam(required = false) String sortOrder ) throws ThingsboardException { var user = getCurrentUser(); - accessControlService.checkPermission(user, Resource.AI_SETTINGS, Operation.READ); + accessControlService.checkPermission(user, Resource.AI_MODEL_SETTINGS, Operation.READ); validateSortProperty(sortProperty); var pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); - return aiSettingsService.findAiSettingsByTenantId(user.getTenantId(), pageLink); + return aiModelSettingsService.findAiModelSettingsByTenantId(user.getTenantId(), pageLink); } private static void validateSortProperty(String sortProperty) { @@ -120,31 +120,31 @@ public class AiSettingsController extends BaseController { } @ApiOperation( - value = "Delete AI settings by ID (deleteAiSettingsById)", - notes = "Deletes the AI settings record by its `id`. " + + value = "Delete AI model settings by ID (deleteAiModelSettingsById)", + notes = "Deletes the AI model settings record by its `id`. " + "If a record with the specified `id` exists, the record is deleted and the endpoint returns `true`. " + "If no such record exists, the endpoint returns `false`." + TENANT_AUTHORITY_PARAGRAPH ) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @DeleteMapping("/{aiSettingsId}") - public boolean deleteAiSettingsById( + @DeleteMapping("/{aiModelSettingsId}") + public boolean deleteAiModelSettingsById( @Parameter( - description = "ID of the AI settings record", + description = "ID of the AI model settings record", required = true, example = "de7900d4-30e2-11f0-9cd2-0242ac120002" ) - @PathVariable("aiSettingsId") UUID aiSettingsUuid + @PathVariable("aiModelSettingsId") UUID aiModelSettingsUuid ) throws ThingsboardException { var user = getCurrentUser(); - var aiSettingsId = new AiSettingsId(aiSettingsUuid); - accessControlService.checkPermission(user, Resource.AI_SETTINGS, Operation.DELETE); - Optional aiSettingsOpt = aiSettingsService.findAiSettingsByTenantIdAndId(user.getTenantId(), aiSettingsId); - if (aiSettingsOpt.isEmpty()) { + var settingsId = new AiModelSettingsId(aiModelSettingsUuid); + accessControlService.checkPermission(user, Resource.AI_MODEL_SETTINGS, Operation.DELETE); + Optional toDelete = aiModelSettingsService.findAiModelSettingsByTenantIdAndId(user.getTenantId(), settingsId); + if (toDelete.isEmpty()) { return false; } - accessControlService.checkPermission(user, Resource.AI_SETTINGS, Operation.DELETE, aiSettingsId, aiSettingsOpt.get()); - return tbAiSettingsService.delete(aiSettingsOpt.get(), user); + accessControlService.checkPermission(user, Resource.AI_MODEL_SETTINGS, Operation.DELETE, settingsId, toDelete.get()); + return tbAiModelSettingsService.delete(toDelete.get(), user); } } diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 26ff7c1a94..179327f73b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -63,7 +63,7 @@ import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantInfo; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.ai.AiModelSettings; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmInfo; @@ -78,7 +78,7 @@ import org.thingsboard.server.common.data.edge.EdgeInfo; import org.thingsboard.server.common.data.exception.EntityVersionMismatchException; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.id.AiModelSettingsId; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.AssetId; @@ -131,7 +131,7 @@ import org.thingsboard.server.common.data.util.ThrowingBiFunction; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.common.data.widget.WidgetsBundle; -import org.thingsboard.server.dao.ai.AiSettingsService; +import org.thingsboard.server.dao.ai.AiModelSettingsService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -177,7 +177,7 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.action.EntityActionService; import org.thingsboard.server.service.component.ComponentDiscoveryService; import org.thingsboard.server.service.entitiy.TbLogEntityActionService; -import org.thingsboard.server.service.entitiy.ai.TbAiSettingsService; +import org.thingsboard.server.service.entitiy.ai.TbAiModelSettingsService; import org.thingsboard.server.service.entitiy.user.TbUserSettingsService; import org.thingsboard.server.service.ota.OtaPackageStateService; import org.thingsboard.server.service.profile.TbAssetProfileCache; @@ -378,10 +378,10 @@ public abstract class BaseController { protected CalculatedFieldService calculatedFieldService; @Autowired - protected AiSettingsService aiSettingsService; + protected AiModelSettingsService aiModelSettingsService; @Autowired - protected TbAiSettingsService tbAiSettingsService; + protected TbAiModelSettingsService tbAiModelSettingsService; @Value("${server.log_controller_error_stack_trace}") @Getter @@ -691,8 +691,8 @@ public abstract class BaseController { case CALCULATED_FIELD: checkCalculatedFieldId(new CalculatedFieldId(entityId.getId()), operation); return; - case AI_SETTINGS: - checkAiSettingsId(new AiSettingsId(entityId.getId()), operation); + case AI_MODEL_SETTINGS: + checkAiModelSettingsId(new AiModelSettingsId(entityId.getId()), operation); return; default: checkEntityId(entityId, entitiesService::findEntityByTenantIdAndId, operation); @@ -894,8 +894,8 @@ public abstract class BaseController { return checkEntityId(notificationTargetId, notificationTargetService::findNotificationTargetById, operation); } - AiSettings checkAiSettingsId(AiSettingsId aiSettingsId, Operation operation) throws ThingsboardException { - return checkEntityId(aiSettingsId, (tenantId, id) -> aiSettingsService.findAiSettingsByTenantIdAndId(tenantId, id).orElse(null), operation); + AiModelSettings checkAiModelSettingsId(AiModelSettingsId settingsId, Operation operation) throws ThingsboardException { + return checkEntityId(settingsId, (tenantId, id) -> aiModelSettingsService.findAiModelSettingsByTenantIdAndId(tenantId, id).orElse(null), operation); } protected I emptyId(EntityType entityType) { diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiModelServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiModelServiceImpl.java new file mode 100644 index 0000000000..728e46e922 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiModelServiceImpl.java @@ -0,0 +1,37 @@ +/** + * 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.ai; + +import dev.langchain4j.model.chat.ChatModel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.rule.engine.api.RuleEngineAiModelService; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; + +@Service +@RequiredArgsConstructor +class AiModelServiceImpl implements RuleEngineAiModelService { + + private final Langchain4jChatModelConfigurer chatModelConfigurer; + + @Override + public > ChatModel configureChatModel(AiChatModel chatModel) { + return chatModel.configure(chatModelConfigurer); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java deleted file mode 100644 index eeddb5e6d2..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/ai/AiServiceImpl.java +++ /dev/null @@ -1,106 +0,0 @@ -/** - * 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.ai; - -import dev.langchain4j.model.chat.ChatModel; -import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel; -import dev.langchain4j.model.mistralai.MistralAiChatModel; -import dev.langchain4j.model.openai.OpenAiChatModel; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.thingsboard.rule.engine.api.RuleEngineAiService; -import org.thingsboard.server.common.data.ai.AiSettings; -import org.thingsboard.server.common.data.ai.model.AiModelConfig; -import org.thingsboard.server.common.data.ai.model.GoogleAiGeminiChatModelConfig; -import org.thingsboard.server.common.data.ai.model.MistralAiChatModelConfig; -import org.thingsboard.server.common.data.ai.model.OpenAiChatModelConfig; -import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; -import org.thingsboard.server.common.data.id.AiSettingsId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.ai.AiSettingsService; - -import java.time.Duration; -import java.util.NoSuchElementException; -import java.util.Optional; - -@Service -@RequiredArgsConstructor -class AiServiceImpl implements RuleEngineAiService { - - private final AiSettingsService aiSettingsService; - - @Override - public ChatModel configureChatModel(TenantId tenantId, AiSettingsId aiSettingsId) { - Optional aiSettingsOpt = aiSettingsService.findAiSettingsById(tenantId, aiSettingsId); - if (aiSettingsOpt.isEmpty()) { - throw new NoSuchElementException("AI settings with ID: " + aiSettingsId + " were not found"); - } - var aiSettings = aiSettingsOpt.get(); - return configureChatModel(aiSettings.getProviderConfig(), aiSettings.getModelConfig()); - } - - @Override - public ChatModel configureChatModel(AiProviderConfig providerConfig, AiModelConfig modelConfig) { - return switch (providerConfig.getProvider()) { - case OPENAI -> { - var modelBuilder = OpenAiChatModel.builder() - .apiKey(providerConfig.getApiKey()) - .modelName(modelConfig.getModel()); - - if (modelConfig instanceof OpenAiChatModelConfig config) { - modelBuilder.temperature(config.getTemperature()); - if (config.getTimeoutSeconds() != null) { - modelBuilder.timeout(Duration.ofSeconds(config.getTimeoutSeconds())); - } - modelBuilder.maxRetries(config.getMaxRetries()); - } - - yield modelBuilder.build(); - } - case MISTRAL_AI -> { - var modelBuilder = MistralAiChatModel.builder() - .apiKey(providerConfig.getApiKey()) - .modelName(modelConfig.getModel()); - - if (modelConfig instanceof MistralAiChatModelConfig config) { - modelBuilder.temperature(config.getTemperature()); - if (config.getTimeoutSeconds() != null) { - modelBuilder.timeout(Duration.ofSeconds(config.getTimeoutSeconds())); - } - modelBuilder.maxRetries(config.getMaxRetries()); - } - - yield modelBuilder.build(); - } - case GOOGLE_AI_GEMINI -> { - var modelBuilder = GoogleAiGeminiChatModel.builder() - .apiKey(providerConfig.getApiKey()) - .modelName(modelConfig.getModel()); - - if (modelConfig instanceof GoogleAiGeminiChatModelConfig config) { - modelBuilder.temperature(config.getTemperature()); - if (config.getTimeoutSeconds() != null) { - modelBuilder.timeout(Duration.ofSeconds(config.getTimeoutSeconds())); - } - modelBuilder.maxRetries(config.getMaxRetries()); - } - - yield modelBuilder.build(); - } - }; - } - -} 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 new file mode 100644 index 0000000000..f66f31400b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -0,0 +1,70 @@ +/** + * 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.ai; + +import dev.langchain4j.model.chat.ChatModel; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; +import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModel; + +import java.time.Duration; + +@Component +public class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigurer { + + @Override + public ChatModel configureChatModel(OpenAiChatModel chatModel) { + OpenAiChatModel.Config modelConfig = chatModel.modelConfig(); + return dev.langchain4j.model.openai.OpenAiChatModel.builder() + .apiKey(chatModel.providerConfig().apiKey()) + .modelName(chatModel.modelId()) + .temperature(modelConfig.temperature()) + .timeout(toDuration(modelConfig.timeoutSeconds())) + .maxRetries(modelConfig.maxRetries()) + .build(); + } + + @Override + public ChatModel configureChatModel(GoogleAiGeminiChatModel chatModel) { + GoogleAiGeminiChatModel.Config modelConfig = chatModel.modelConfig(); + return dev.langchain4j.model.googleai.GoogleAiGeminiChatModel.builder() + .apiKey(chatModel.providerConfig().apiKey()) + .modelName(chatModel.modelId()) + .temperature(modelConfig.temperature()) + .timeout(toDuration(modelConfig.timeoutSeconds())) + .maxRetries(modelConfig.maxRetries()) + .build(); + } + + @Override + public ChatModel configureChatModel(MistralAiChatModel chatModel) { + MistralAiChatModel.Config modelConfig = chatModel.modelConfig(); + return dev.langchain4j.model.mistralai.MistralAiChatModel.builder() + .apiKey(chatModel.providerConfig().apiKey()) + .modelName(chatModel.modelId()) + .temperature(modelConfig.temperature()) + .timeout(toDuration(modelConfig.timeoutSeconds())) + .maxRetries(modelConfig.maxRetries()) + .build(); + } + + private static Duration toDuration(Integer timeoutSeconds) { + return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java index dc3d1a74f7..8bbed875fe 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java @@ -112,7 +112,7 @@ public class EdgeEventSourcingListener { return; } try { - if (EntityType.TENANT == entityType || EntityType.EDGE == entityType || EntityType.AI_SETTINGS == entityType) { + if (EntityType.TENANT == entityType || EntityType.EDGE == entityType || EntityType.AI_MODEL_SETTINGS == entityType) { return; } log.trace("[{}] DeleteEntityEvent called: {}", tenantId, event); @@ -226,7 +226,7 @@ public class EdgeEventSourcingListener { break; case TENANT: return !event.getCreated(); - case API_USAGE_STATE, EDGE, AI_SETTINGS: + case API_USAGE_STATE, EDGE, AI_MODEL_SETTINGS: return false; case DOMAIN: if (entity instanceof Domain domain) { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java index e0c24a13bc..5fca13380c 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java @@ -68,7 +68,7 @@ public class RelatedEdgesSourcingListener { @TransactionalEventListener( fallbackExecution = true, - condition = "#event.entityId.getEntityType() != T(org.thingsboard.server.common.data.EntityType).AI_SETTINGS" + condition = "#event.entityId.getEntityType() != T(org.thingsboard.server.common.data.EntityType).AI_MODEL_SETTINGS" ) public void handleEvent(DeleteEntityEvent event) { executorService.submit(() -> { diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java index d0d9376f12..5823632b63 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java @@ -96,7 +96,7 @@ public class EntityStateSourcingListener { case ASSET -> { onAssetUpdate(event.getEntity(), event.getOldEntity()); } - case ASSET_PROFILE, ENTITY_VIEW, NOTIFICATION_RULE, AI_SETTINGS -> { + case ASSET_PROFILE, ENTITY_VIEW, NOTIFICATION_RULE, AI_MODEL_SETTINGS -> { tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, lifecycleEvent); } case RULE_CHAIN -> { @@ -158,7 +158,7 @@ public class EntityStateSourcingListener { Asset asset = (Asset) event.getEntity(); tbClusterService.onAssetDeleted(tenantId, asset, null); } - case ASSET_PROFILE, ENTITY_VIEW, CUSTOMER, EDGE, NOTIFICATION_RULE, AI_SETTINGS -> { + case ASSET_PROFILE, ENTITY_VIEW, CUSTOMER, EDGE, NOTIFICATION_RULE, AI_MODEL_SETTINGS -> { tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, ComponentLifecycleEvent.DELETED); } case NOTIFICATION_REQUEST -> { diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiSettingsService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelSettingsService.java similarity index 60% rename from application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiSettingsService.java rename to application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelSettingsService.java index 08e663633d..5a41d83eed 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiSettingsService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelSettingsService.java @@ -19,9 +19,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.ai.AiModelSettings; import org.thingsboard.server.common.data.audit.ActionType; -import org.thingsboard.server.dao.ai.AiSettingsService; +import org.thingsboard.server.dao.ai.AiModelSettingsService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; @@ -30,22 +30,22 @@ import static java.util.Objects.requireNonNullElseGet; @Service @TbCoreComponent @RequiredArgsConstructor -class DefaultTbAiSettingsService extends AbstractTbEntityService implements TbAiSettingsService { +class DefaultTbAiModelSettingsService extends AbstractTbEntityService implements TbAiModelSettingsService { - private final AiSettingsService aiSettingsService; + private final AiModelSettingsService aiModelSettingsService; @Override - public AiSettings save(AiSettings aiSettings, User user) { - var actionType = aiSettings.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + public AiModelSettings save(AiModelSettings settings, User user) { + var actionType = settings.getId() == null ? ActionType.ADDED : ActionType.UPDATED; var tenantId = user.getTenantId(); - aiSettings.setTenantId(tenantId); + settings.setTenantId(tenantId); - AiSettings savedSettings; + AiModelSettings savedSettings; try { - savedSettings = aiSettingsService.save(aiSettings); + savedSettings = aiModelSettingsService.save(settings); } catch (Exception e) { - logEntityActionService.logEntityAction(tenantId, requireNonNullElseGet(aiSettings.getId(), () -> emptyId(EntityType.AI_SETTINGS)), aiSettings, actionType, user, e); + logEntityActionService.logEntityAction(tenantId, requireNonNullElseGet(settings.getId(), () -> emptyId(EntityType.AI_MODEL_SETTINGS)), settings, actionType, user, e); throw e; } @@ -55,22 +55,22 @@ class DefaultTbAiSettingsService extends AbstractTbEntityService implements TbAi } @Override - public boolean delete(AiSettings aiSettings, User user) { + public boolean delete(AiModelSettings settings, User user) { var actionType = ActionType.DELETED; var tenantId = user.getTenantId(); - var aiSettingsId = aiSettings.getId(); + var settingsId = settings.getId(); boolean deleted; try { - deleted = aiSettingsService.deleteByTenantIdAndId(tenantId, aiSettingsId); + deleted = aiModelSettingsService.deleteByTenantIdAndId(tenantId, settingsId); } catch (Exception e) { - logEntityActionService.logEntityAction(tenantId, aiSettingsId, aiSettings, actionType, user, e, aiSettingsId.toString()); + logEntityActionService.logEntityAction(tenantId, settingsId, settings, actionType, user, e, settingsId.toString()); throw e; } if (deleted) { - logEntityActionService.logEntityAction(tenantId, aiSettingsId, aiSettings, actionType, user, aiSettingsId.toString()); + logEntityActionService.logEntityAction(tenantId, settingsId, settings, actionType, user, settingsId.toString()); } return deleted; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiSettingsService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiModelSettingsService.java similarity index 76% rename from application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiSettingsService.java rename to application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiModelSettingsService.java index 399a3b2bdd..0d66c171a7 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiSettingsService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiModelSettingsService.java @@ -16,12 +16,12 @@ package org.thingsboard.server.service.entitiy.ai; import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.ai.AiModelSettings; -public interface TbAiSettingsService { +public interface TbAiModelSettingsService { - AiSettings save(AiSettings aiSettings, User user); + AiModelSettings save(AiModelSettings settings, User user); - boolean delete(AiSettings aiSettings, User user); + boolean delete(AiModelSettings settings, User user); } 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 aa27b4592b..2a2a39c4d2 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 @@ -590,7 +590,7 @@ public class DefaultTbClusterService implements TbClusterService { EntityType.ENTITY_VIEW, EntityType.NOTIFICATION_RULE, EntityType.CALCULATED_FIELD, - EntityType.AI_SETTINGS, + EntityType.AI_MODEL_SETTINGS, EntityType.TENANT_PROFILE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE) diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java index 55b7577ebe..cdbd2bccec 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java @@ -52,7 +52,7 @@ public enum Resource { EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE), MOBILE_APP_SETTINGS, CALCULATED_FIELD(EntityType.CALCULATED_FIELD), - AI_SETTINGS(EntityType.AI_SETTINGS); + AI_MODEL_SETTINGS(EntityType.AI_MODEL_SETTINGS); private final Set entityTypes; diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java index 990f29798c..49c4350761 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java @@ -18,8 +18,8 @@ package org.thingsboard.server.service.security.permission; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.ai.AiSettings; -import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.id.AiModelSettingsId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; @@ -58,7 +58,7 @@ public class TenantAdminPermissions extends AbstractPermissions { put(Resource.MOBILE_APP, tenantEntityPermissionChecker); put(Resource.MOBILE_APP_BUNDLE, tenantEntityPermissionChecker); put(Resource.CALCULATED_FIELD, tenantEntityPermissionChecker); - put(Resource.AI_SETTINGS, aiSettingsPermissionChecker); + put(Resource.AI_MODEL_SETTINGS, aiModelSettingsPermissionChecker); } public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() { @@ -149,7 +149,7 @@ public class TenantAdminPermissions extends AbstractPermissions { }; - private static final PermissionChecker aiSettingsPermissionChecker = new PermissionChecker<>() { + private static final PermissionChecker aiModelSettingsPermissionChecker = new PermissionChecker<>() { @Override public boolean hasPermission(SecurityUser user, Operation operation) { @@ -157,7 +157,7 @@ public class TenantAdminPermissions extends AbstractPermissions { } @Override - public boolean hasPermission(SecurityUser user, Operation operation, AiSettingsId entityId, AiSettings entity) { + public boolean hasPermission(SecurityUser user, Operation operation, AiModelSettingsId entityId, AiModelSettings entity) { return user.getTenantId().equals(entity.getTenantId()); } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index dd41b13de0..f79a387b58 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -657,9 +657,9 @@ cache: trendzSettings: timeToLiveInMinutes: "${CACHE_SPECS_TRENDZ_SETTINGS_TTL:1440}" # Trendz settings cache TTL maxSize: "${CACHE_SPECS_TRENDZ_SETTINGS_MAX_SIZE:10000}" # 0 means the cache is disabled - aiSettings: - timeToLiveInMinutes: "${CACHE_SPECS_AI_SETTINGS_TTL:1440}" # AI settings cache TTL - maxSize: "${CACHE_SPECS_AI_SETTINGS_MAX_SIZE:10000}" # 0 means the cache is disabled + aiModelSettings: + timeToLiveInMinutes: "${CACHE_SPECS_AI_MODEL_SETTINGS_TTL:1440}" # AI model settings cache TTL + maxSize: "${CACHE_SPECS_AI_MODEL_SETTINGS_MAX_SIZE:10000}" # 0 means the cache is disabled # Deliberately placed outside the 'specs' group above notificationRules: @@ -875,7 +875,7 @@ audit-log: "tb_resource": "${AUDIT_LOG_MASK_RESOURCE:W}" # TB resource logging levels. "ota_package": "${AUDIT_LOG_MASK_OTA_PACKAGE:W}" # Ota package logging levels. "calculated_field": "${AUDIT_LOG_MASK_CALCULATED_FIELD:W}" # Calculated field logging levels. - "ai_settings": "${AUDIT_LOG_MASK_AI_SETTINGS:W}" # AI settings logging levels. + "ai_model_settings": "${AUDIT_LOG_MASK_AI_MODEL_SETTINGS:W}" # AI model settings logging levels. sink: # Type of external sink. possible options: none, elasticsearch type: "${AUDIT_LOG_SINK_TYPE:none}" diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsService.java similarity index 55% rename from common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java rename to common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsService.java index 83a66de876..09219f238e 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsService.java @@ -16,8 +16,8 @@ package org.thingsboard.server.dao.ai; import com.google.common.util.concurrent.FluentFuture; -import org.thingsboard.server.common.data.ai.AiSettings; -import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.id.AiModelSettingsId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -25,18 +25,18 @@ import org.thingsboard.server.dao.entity.EntityDaoService; import java.util.Optional; -public interface AiSettingsService extends EntityDaoService { +public interface AiModelSettingsService extends EntityDaoService { - AiSettings save(AiSettings aiSettings); + AiModelSettings save(AiModelSettings settings); - Optional findAiSettingsById(TenantId tenantId, AiSettingsId aiSettingsId); + Optional findAiModelSettingsById(TenantId tenantId, AiModelSettingsId settingsId); - PageData findAiSettingsByTenantId(TenantId tenantId, PageLink pageLink); + PageData findAiModelSettingsByTenantId(TenantId tenantId, PageLink pageLink); - Optional findAiSettingsByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId); + Optional findAiModelSettingsByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId); - FluentFuture> findAiSettingsByTenantIdAndIdAsync(TenantId tenantId, AiSettingsId aiSettingsId); + FluentFuture> findAiModelSettingsByTenantIdAndIdAsync(TenantId tenantId, AiModelSettingsId settingsId); - boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId); + boolean deleteByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId); } diff --git a/common/data/pom.xml b/common/data/pom.xml index 648f3d1086..beb67c6b19 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -112,6 +112,10 @@ leshan-core compile + + dev.langchain4j + langchain4j + diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index 4b103d769a..c5df7c10c0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -37,7 +37,7 @@ public final class CacheConstants { public static final String NOTIFICATION_SETTINGS_CACHE = "notificationSettings"; public static final String SENT_NOTIFICATIONS_CACHE = "sentNotifications"; public static final String TRENDZ_SETTINGS_CACHE = "trendzSettings"; - public static final String AI_SETTINGS_CACHE = "aiSettings"; + public static final String AI_MODEL_SETTINGS_CACHE = "aiModelSettings"; public static final String ASSET_PROFILE_CACHE = "assetProfiles"; public static final String ATTRIBUTES_CACHE = "attributes"; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java index 89bc456fd9..868b36f73b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java @@ -64,10 +64,10 @@ public enum EntityType { MOBILE_APP_BUNDLE(38), CALCULATED_FIELD(39), CALCULATED_FIELD_LINK(40), - AI_SETTINGS(41, "ai_settings") { + AI_MODEL_SETTINGS(41, "ai_model_settings") { @Override public String getNormalName() { - return "AI settings"; + return "AI model settings"; } }; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModelSettings.java similarity index 52% rename from common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java rename to common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModelSettings.java index d38ac45992..df1dbe4d7e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModelSettings.java @@ -24,10 +24,8 @@ import org.thingsboard.server.common.data.BaseData; import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.HasVersion; -import org.thingsboard.server.common.data.ai.model.AiModelConfig; -import org.thingsboard.server.common.data.ai.provider.AiProvider; -import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; -import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.ai.model.AiModel; +import org.thingsboard.server.common.data.id.AiModelSettingsId; import org.thingsboard.server.common.data.id.TenantId; import java.io.Serial; @@ -36,7 +34,7 @@ import java.io.Serial; @Builder @AllArgsConstructor @EqualsAndHashCode(callSuper = true) -public final class AiSettings extends BaseData implements HasTenantId, HasVersion, HasName { +public final class AiModelSettings extends BaseData implements HasTenantId, HasVersion, HasName { @Serial private static final long serialVersionUID = 9017108678716011604L; @@ -44,7 +42,7 @@ public final class AiSettings extends BaseData implements HasTenan @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, - description = "JSON object representing the ID of the tenant associated with these AI settings", + description = "JSON object representing the ID of the tenant associated with these AI model settings", example = "e3c4b7d2-5678-4a9b-0c1d-2e3f4a5b6c7d" ) TenantId tenantId; @@ -52,7 +50,7 @@ public final class AiSettings extends BaseData implements HasTenan @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, - description = "Version of the AI settings; increments automatically whenever the settings are changed", + description = "Version of the AI model settings; increments automatically whenever the settings are changed", example = "7", defaultValue = "1" ) @@ -61,49 +59,21 @@ public final class AiSettings extends BaseData implements HasTenan @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, - description = "Human-readable name of the AI settings; must be unique within the scope of the tenant", + description = "Human-readable name of the AI model settings; must be unique within the scope of the tenant", example = "Default AI Settings" ) String name; - @Schema( - requiredMode = Schema.RequiredMode.REQUIRED, - accessMode = Schema.AccessMode.READ_WRITE, - description = "Name of the AI provider", - example = "OPENAI", - allowableValues = {"OPENAI", "GOOGLE_AI_GEMINI", "MISTRAL_AI"}, - type = "string" - ) - AiProvider provider; - - @Schema( - requiredMode = Schema.RequiredMode.REQUIRED, - accessMode = Schema.AccessMode.READ_WRITE, - description = "Configuration specific to the AI provider" - ) - AiProviderConfig providerConfig; - - @Schema( - requiredMode = Schema.RequiredMode.REQUIRED, - accessMode = Schema.AccessMode.READ_WRITE, - description = "Identifier of the AI model", - example = "gpt-4o-mini" - ) - String model; - @Schema( requiredMode = Schema.RequiredMode.NOT_REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, - description = """ - Optional configuration specific to the AI model. - If provided, it must be one of the known `AiModelConfig` subtypes and any settings - you specify will override the model’s defaults; if omitted, the model will run with its built-in defaults.""" + description = "Configuration of the AI model" ) - AiModelConfig modelConfig; + AiModel configuration; - public AiSettings() {} + public AiModelSettings() {} - public AiSettings(AiSettingsId id) { + public AiModelSettings(AiModelSettingsId id) { super(id); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java new file mode 100644 index 0000000000..686c1f4fcf --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java @@ -0,0 +1,48 @@ +/** + * 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; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModel; +import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "modelId", + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4o"), + @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-2.5-flash"), + @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "mistral-medium-latest") +}) +public interface AiModel> { + + AiProviderConfig providerConfig(); + + AiModelType modelType(); + + String modelId(); + + C modelConfig(); + + AiModel withModelConfig(C config); + +} 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 779b75812f..b07007c301 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 @@ -15,42 +15,4 @@ */ package org.thingsboard.server.common.data.ai.model; -import com.fasterxml.jackson.annotation.JsonSubTypes; -import com.fasterxml.jackson.annotation.JsonTypeInfo; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.EXISTING_PROPERTY, - property = "model", - visible = true -) -@JsonSubTypes({ - @JsonSubTypes.Type(value = OpenAiChatModelConfig.class, name = "gpt-4o"), - @JsonSubTypes.Type(value = OpenAiChatModelConfig.class, name = "gpt-4o-mini"), - @JsonSubTypes.Type(value = GoogleAiGeminiChatModelConfig.class, name = "gemini-2.0-flash"), - @JsonSubTypes.Type(value = MistralAiChatModelConfig.class, name = "mistral-medium-latest") -}) -public abstract class AiModelConfig { - - @Schema( - requiredMode = Schema.RequiredMode.REQUIRED, - accessMode = Schema.AccessMode.READ_WRITE, - description = "Identifier of the AI model" - ) - private String model; - - public abstract Integer getTimeoutSeconds(); - - public abstract void setTimeoutSeconds(Integer timeoutSeconds); - - public abstract Integer getMaxRetries(); - - public abstract void setMaxRetries(Integer timeoutSeconds); - - -} +public interface AiModelConfig> {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelType.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelType.java new file mode 100644 index 0000000000..d6299cf5e6 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelType.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.model; + +public enum AiModelType { + + CHAT + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/GoogleAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/GoogleAiGeminiChatModelConfig.java deleted file mode 100644 index 1d85115f31..0000000000 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/GoogleAiGeminiChatModelConfig.java +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 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; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.NoArgsConstructor; - -@Data -@EqualsAndHashCode(callSuper = true) -@NoArgsConstructor -@Schema( - name = "GoogleAiGeminiChatModelConfig", - description = "Configuration for Google AI Gemini chat models" -) -public final class GoogleAiGeminiChatModelConfig extends AiModelConfig { - - @Schema( - requiredMode = Schema.RequiredMode.REQUIRED, - accessMode = Schema.AccessMode.READ_WRITE, - description = "Identifier of the AI model", - allowableValues = "gemini-2.0-flash", - example = "gemini-2.0-flash" - ) - public String getModel() { - return super.getModel(); - } - - @Schema( - accessMode = Schema.AccessMode.READ_WRITE, - description = "Sampling temperature to control randomness: 0.0 (most deterministic) to 1.0 (most creative)", - example = "0.7" - ) - private Double temperature; - - @Schema( - accessMode = Schema.AccessMode.READ_WRITE, - description = "Timeout (in seconds) for establishing HTTP connection" - ) - private Integer timeoutSeconds; - - @Schema( - accessMode = Schema.AccessMode.READ_WRITE, - description = "Maximum number of times to retry an LLM call upon exception (except for non-retriable ones like authentication or invalid request errors)" - ) - private Integer maxRetries; - -} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/MistralAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/MistralAiChatModelConfig.java deleted file mode 100644 index d333eb6283..0000000000 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/MistralAiChatModelConfig.java +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 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; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.NoArgsConstructor; - -@Data -@EqualsAndHashCode(callSuper = true) -@NoArgsConstructor -@Schema( - name = "MistralAiChatModelConfig", - description = "Configuration for Mistral AI chat models" -) -public final class MistralAiChatModelConfig extends AiModelConfig { - - @Schema( - requiredMode = Schema.RequiredMode.REQUIRED, - accessMode = Schema.AccessMode.READ_WRITE, - description = "Identifier of the AI model", - allowableValues = "mistral-medium-latest", - example = "mistral-medium-latest" - ) - public String getModel() { - return super.getModel(); - } - - @Schema( - accessMode = Schema.AccessMode.READ_WRITE, - description = "Sampling temperature to control randomness: 0.0 (most deterministic) to 1.0 (most creative)", - example = "0.7" - ) - private Double temperature; - - @Schema( - accessMode = Schema.AccessMode.READ_WRITE, - description = "Timeout (in seconds) for the entire HTTP call: applied to connect, read, and write operations" - ) - private Integer timeoutSeconds; - - @Schema( - accessMode = Schema.AccessMode.READ_WRITE, - description = "Maximum number of times to retry an LLM call upon exception (except for non-retriable ones like authentication or invalid request errors)" - ) - private Integer maxRetries; - -} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/OpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/OpenAiChatModelConfig.java deleted file mode 100644 index accf5c92ec..0000000000 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/OpenAiChatModelConfig.java +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 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; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.NoArgsConstructor; - -@Data -@EqualsAndHashCode(callSuper = true) -@NoArgsConstructor -@Schema( - name = "OpenAiChatModelConfig", - description = "Configuration for OpenAI chat models" -) -public final class OpenAiChatModelConfig extends AiModelConfig { - - @Schema( - requiredMode = Schema.RequiredMode.REQUIRED, - accessMode = Schema.AccessMode.READ_WRITE, - description = "Identifier of the AI model", - allowableValues = {"gpt-4o", "gpt-4o-mini"}, - example = "gpt-4o" - ) - public String getModel() { - return super.getModel(); - } - - @Schema( - accessMode = Schema.AccessMode.READ_WRITE, - description = "Sampling temperature to control randomness: 0.0 (most deterministic) to 1.0 (most creative)", - example = "0.7" - ) - private Double temperature; - - @Schema( - accessMode = Schema.AccessMode.READ_WRITE, - description = "Timeout (in seconds) for both establishing HTTP connection and receiving a response" - ) - private Integer timeoutSeconds; - - @Schema( - accessMode = Schema.AccessMode.READ_WRITE, - description = "Maximum number of times to retry an LLM call upon exception (except for non-retriable ones like authentication or invalid request errors)" - ) - private Integer maxRetries; - -} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java new file mode 100644 index 0000000000..17f79d93e2 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java @@ -0,0 +1,38 @@ +/** + * 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 org.thingsboard.server.common.data.ai.model.AiModel; +import org.thingsboard.server.common.data.ai.model.AiModelType; + +public sealed interface AiChatModel> extends AiModel + permits OpenAiChatModel, GoogleAiGeminiChatModel, MistralAiChatModel { + + ChatModel configure(Langchain4jChatModelConfigurer configurer); + + @Override + default AiModelType modelType() { + return AiModelType.CHAT; + } + + @Override + C modelConfig(); + + @Override + AiChatModel withModelConfig(C config); + +} 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 new file mode 100644 index 0000000000..565923cf6c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java @@ -0,0 +1,35 @@ +/** + * 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 org.thingsboard.server.common.data.ai.model.AiModelConfig; + +public sealed interface AiChatModelConfig> extends AiModelConfig + permits OpenAiChatModel.Config, GoogleAiGeminiChatModel.Config, MistralAiChatModel.Config { + + Double temperature(); + + Integer timeoutSeconds(); + + Integer maxRetries(); + + C withTemperature(Double temperature); + + C withTimeoutSeconds(Integer timeoutSeconds); + + C withMaxRetries(Integer maxRetries); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java new file mode 100644 index 0000000000..875262abb6 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java @@ -0,0 +1,60 @@ +/** + * 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 org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; + +public record GoogleAiGeminiChatModel( + GoogleAiGeminiProviderConfig providerConfig, + String modelId, + Config modelConfig +) implements AiChatModel { + + public record Config( + Double temperature, + Integer timeoutSeconds, + Integer maxRetries + ) implements AiChatModelConfig { + + @Override + public Config withTemperature(Double temperature) { + return new Config(temperature, timeoutSeconds, maxRetries); + } + + @Override + public Config withTimeoutSeconds(Integer timeoutSeconds) { + return new Config(temperature, timeoutSeconds, maxRetries); + } + + @Override + public Config withMaxRetries(Integer maxRetries) { + return new Config(temperature, timeoutSeconds, maxRetries); + } + + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public GoogleAiGeminiChatModel withModelConfig(GoogleAiGeminiChatModel.Config config) { + return new GoogleAiGeminiChatModel(providerConfig, modelId, config); + } + +} 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 new file mode 100644 index 0000000000..87d317eb0b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.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.common.data.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; + +public interface Langchain4jChatModelConfigurer { + + ChatModel configureChatModel(OpenAiChatModel chatModel); + + ChatModel configureChatModel(GoogleAiGeminiChatModel chatModel); + + ChatModel configureChatModel(MistralAiChatModel chatModel); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java new file mode 100644 index 0000000000..413d2b93a8 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java @@ -0,0 +1,60 @@ +/** + * 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 org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; + +public record MistralAiChatModel( + MistralAiProviderConfig providerConfig, + String modelId, + Config modelConfig +) implements AiChatModel { + + public record Config( + Double temperature, + Integer timeoutSeconds, + Integer maxRetries + ) implements AiChatModelConfig { + + @Override + public Config withTemperature(Double temperature) { + return new Config(temperature, timeoutSeconds, maxRetries); + } + + @Override + public Config withTimeoutSeconds(Integer timeoutSeconds) { + return new Config(temperature, timeoutSeconds, maxRetries); + } + + @Override + public Config withMaxRetries(Integer maxRetries) { + return new Config(temperature, timeoutSeconds, maxRetries); + } + + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public MistralAiChatModel withModelConfig(Config config) { + return new MistralAiChatModel(providerConfig, modelId, config); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java new file mode 100644 index 0000000000..0d5031d512 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java @@ -0,0 +1,60 @@ +/** + * 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 org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; + +public record OpenAiChatModel( + OpenAiProviderConfig providerConfig, + String modelId, + Config modelConfig +) implements AiChatModel { + + public record Config( + Double temperature, + Integer timeoutSeconds, + Integer maxRetries + ) implements AiChatModelConfig { + + @Override + public OpenAiChatModel.Config withTemperature(Double temperature) { + return new Config(temperature, timeoutSeconds, maxRetries); + } + + @Override + public OpenAiChatModel.Config withTimeoutSeconds(Integer timeoutSeconds) { + return new Config(temperature, timeoutSeconds, maxRetries); + } + + @Override + public OpenAiChatModel.Config withMaxRetries(Integer maxRetries) { + return new Config(temperature, timeoutSeconds, maxRetries); + } + + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public OpenAiChatModel withModelConfig(OpenAiChatModel.Config config) { + return new OpenAiChatModel(providerConfig, modelId, config); + } + +} 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 f350ed75ec..b7c594ba60 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 @@ -17,32 +17,22 @@ package org.thingsboard.server.common.data.ai.provider; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; -import lombok.NoArgsConstructor; -@Data -@NoArgsConstructor @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.EXISTING_PROPERTY, - property = "provider", - visible = true + include = JsonTypeInfo.As.PROPERTY, + property = "provider" ) @JsonSubTypes({ @JsonSubTypes.Type(value = OpenAiProviderConfig.class, name = "OPENAI"), @JsonSubTypes.Type(value = GoogleAiGeminiProviderConfig.class, name = "GOOGLE_AI_GEMINI"), @JsonSubTypes.Type(value = MistralAiProviderConfig.class, name = "MISTRAL_AI") }) -public abstract class AiProviderConfig { +public sealed interface AiProviderConfig + permits OpenAiProviderConfig, GoogleAiGeminiProviderConfig, MistralAiProviderConfig { - public abstract AiProvider getProvider(); + AiProvider provider(); - @Schema( - requiredMode = Schema.RequiredMode.REQUIRED, - accessMode = Schema.AccessMode.READ_WRITE, - description = "API key for authenticating with the AI provider" - ) - private String apiKey; + String apiKey(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java index 9ca8643173..0bb9d21b52 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java @@ -15,26 +15,16 @@ */ package org.thingsboard.server.common.data.ai.provider; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; -import lombok.EqualsAndHashCode; +public record GoogleAiGeminiProviderConfig(String apiKey) implements AiProviderConfig { -@Data -@EqualsAndHashCode(callSuper = true) -@Schema( - name = "GoogleAiGeminiProviderConfig", - description = "Configuration for the Google AI Gemini provider" -) -public final class GoogleAiGeminiProviderConfig extends AiProviderConfig { + @Override + public AiProvider provider() { + return AiProvider.GOOGLE_AI_GEMINI; + } - @Schema( - requiredMode = Schema.RequiredMode.REQUIRED, - accessMode = Schema.AccessMode.READ_WRITE, - description = "Name of the AI provider", - example = "GOOGLE_AI_GEMINI", - allowableValues = "GOOGLE_AI_GEMINI", - type = "string" - ) - private AiProvider provider = AiProvider.GOOGLE_AI_GEMINI; + @Override + public String apiKey() { + return apiKey; + } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java index bc17f34220..45e3b68800 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java @@ -15,26 +15,16 @@ */ package org.thingsboard.server.common.data.ai.provider; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; -import lombok.EqualsAndHashCode; +public record MistralAiProviderConfig(String apiKey) implements AiProviderConfig { -@Data -@EqualsAndHashCode(callSuper = true) -@Schema( - name = "MistralAiProviderConfig", - description = "Configuration for the Mistral AI provider" -) -public final class MistralAiProviderConfig extends AiProviderConfig { + @Override + public AiProvider provider() { + return AiProvider.MISTRAL_AI; + } - @Schema( - requiredMode = Schema.RequiredMode.REQUIRED, - accessMode = Schema.AccessMode.READ_WRITE, - description = "Name of the AI provider", - example = "MISTRAL_AI", - allowableValues = "MISTRAL_AI", - type = "string" - ) - private AiProvider provider = AiProvider.MISTRAL_AI; + @Override + public String apiKey() { + return apiKey; + } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java index 36ac8b4b35..0536d1176e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java @@ -15,26 +15,16 @@ */ package org.thingsboard.server.common.data.ai.provider; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; -import lombok.EqualsAndHashCode; +public record OpenAiProviderConfig(String apiKey) implements AiProviderConfig { -@Data -@EqualsAndHashCode(callSuper = true) -@Schema( - name = "OpenAiProviderConfig", - description = "Configuration for the OpenAI provider" -) -public final class OpenAiProviderConfig extends AiProviderConfig { + @Override + public AiProvider provider() { + return AiProvider.OPENAI; + } - @Schema( - requiredMode = Schema.RequiredMode.REQUIRED, - accessMode = Schema.AccessMode.READ_WRITE, - description = "Name of the AI provider", - example = "OPENAI", - allowableValues = "OPENAI", - type = "string" - ) - private AiProvider provider = AiProvider.OPENAI; + @Override + public String apiKey() { + return apiKey; + } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AiSettingsId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelSettingsId.java similarity index 72% rename from common/data/src/main/java/org/thingsboard/server/common/data/id/AiSettingsId.java rename to common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelSettingsId.java index 8aac27051a..83b4fefab2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AiSettingsId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelSettingsId.java @@ -23,29 +23,29 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; -public final class AiSettingsId extends UUIDBased implements EntityId { +public final class AiModelSettingsId extends UUIDBased implements EntityId { @Serial private static final long serialVersionUID = 3021036138554389754L; @JsonCreator - public AiSettingsId(@JsonProperty("id") UUID id) { + public AiModelSettingsId(@JsonProperty("id") UUID id) { super(id); } @Override @Schema( requiredMode = Schema.RequiredMode.REQUIRED, - description = "Entity type of the AI settings", - example = "AI_SETTINGS", - allowableValues = "AI_SETTINGS" + description = "Entity type of the AI model settings", + example = "AI_MODEL_SETTINGS", + allowableValues = "AI_MODEL_SETTINGS" ) public EntityType getEntityType() { - return EntityType.AI_SETTINGS; + return EntityType.AI_MODEL_SETTINGS; } - public static AiSettingsId fromString(String uuid) { - return new AiSettingsId(UUID.fromString(uuid)); + public static AiModelSettingsId fromString(String uuid) { + return new AiModelSettingsId(UUID.fromString(uuid)); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java index fcdf1e0a1b..0738ce45ee 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java @@ -84,7 +84,7 @@ public class EntityIdFactory { case MOBILE_APP_BUNDLE -> new MobileAppBundleId(uuid); case CALCULATED_FIELD -> new CalculatedFieldId(uuid); case CALCULATED_FIELD_LINK -> new CalculatedFieldLinkId(uuid); - case AI_SETTINGS -> new AiSettingsId(uuid); + case AI_MODEL_SETTINGS -> new AiModelSettingsId(uuid); default -> throw new IllegalArgumentException("EntityType " + type + " is not supported!"); }; } diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index e5a94c21fc..40f154d276 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -63,7 +63,7 @@ enum EntityTypeProto { MOBILE_APP_BUNDLE = 38; CALCULATED_FIELD = 39; CALCULATED_FIELD_LINK = 40; - AI_SETTINGS = 41; + AI_MODEL_SETTINGS = 41; } enum ApiUsageRecordKeyProto { diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheEvictEvent.java similarity index 68% rename from dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheEvictEvent.java rename to dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheEvictEvent.java index d6cad8bf16..16ce64256b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheEvictEvent.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheEvictEvent.java @@ -15,15 +15,15 @@ */ package org.thingsboard.server.dao.ai; -import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.id.AiModelSettingsId; import org.thingsboard.server.common.data.id.TenantId; import java.util.Set; -record AiSettingsCacheEvictEvent(Set keys) { +record AiModelSettingsCacheEvictEvent(Set keys) { - static AiSettingsCacheEvictEvent of(TenantId tenantId, AiSettingsId aiSettingsId) { - return new AiSettingsCacheEvictEvent(Set.of(AiSettingsCacheKey.of(tenantId, aiSettingsId))); + static AiModelSettingsCacheEvictEvent of(TenantId tenantId, AiModelSettingsId settingsId) { + return new AiModelSettingsCacheEvictEvent(Set.of(AiModelSettingsCacheKey.of(tenantId, settingsId))); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheKey.java similarity index 70% rename from dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheKey.java rename to dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheKey.java index d732c08fc8..9d73289be7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCacheKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheKey.java @@ -17,22 +17,22 @@ package org.thingsboard.server.dao.ai; import org.checkerframework.checker.nullness.qual.NonNull; import org.thingsboard.server.cache.VersionedCacheKey; -import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.id.AiModelSettingsId; import org.thingsboard.server.common.data.id.TenantId; import java.util.UUID; import static java.util.Objects.requireNonNull; -record AiSettingsCacheKey(UUID tenantId, UUID aiSettingsId) implements VersionedCacheKey { +record AiModelSettingsCacheKey(UUID tenantId, UUID settingsId) implements VersionedCacheKey { - AiSettingsCacheKey { + AiModelSettingsCacheKey { requireNonNull(tenantId); - requireNonNull(aiSettingsId); + requireNonNull(settingsId); } - static AiSettingsCacheKey of(TenantId tenantId, AiSettingsId aiSettingsId) { - return new AiSettingsCacheKey(tenantId.getId(), aiSettingsId.getId()); + static AiModelSettingsCacheKey of(TenantId tenantId, AiModelSettingsId settingsId) { + return new AiModelSettingsCacheKey(tenantId.getId(), settingsId.getId()); } @Override @@ -43,7 +43,7 @@ record AiSettingsCacheKey(UUID tenantId, UUID aiSettingsId) implements Versioned @NonNull @Override public String toString() { - return /* cache name */ "_" + tenantId + "_" + aiSettingsId; + return /* cache name */ "_" + tenantId + "_" + settingsId; } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCaffeineCache.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCaffeineCache.java similarity index 75% rename from dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCaffeineCache.java rename to dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCaffeineCache.java index f006c0a757..d758cb21c5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsCaffeineCache.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCaffeineCache.java @@ -20,14 +20,14 @@ import org.springframework.cache.CacheManager; import org.springframework.stereotype.Component; import org.thingsboard.server.cache.VersionedCaffeineTbCache; import org.thingsboard.server.common.data.CacheConstants; -import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.ai.AiModelSettings; -@Component("AiSettingsCache") +@Component("AiModelSettingsCache") @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) -class AiSettingsCaffeineCache extends VersionedCaffeineTbCache { +class AiModelSettingsCaffeineCache extends VersionedCaffeineTbCache { - AiSettingsCaffeineCache(CacheManager cacheManager) { - super(cacheManager, CacheConstants.AI_SETTINGS_CACHE); + AiModelSettingsCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.AI_MODEL_SETTINGS_CACHE); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsDao.java similarity index 64% rename from dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsDao.java rename to dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsDao.java index 94a096d679..ae47af0db2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsDao.java @@ -15,22 +15,22 @@ */ package org.thingsboard.server.dao.ai; -import org.thingsboard.server.common.data.ai.AiSettings; -import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.id.AiModelSettingsId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.Dao; import org.thingsboard.server.dao.TenantEntityDao; import java.util.Optional; -public interface AiSettingsDao extends Dao, TenantEntityDao { +public interface AiModelSettingsDao extends Dao, TenantEntityDao { - Optional findByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId); + Optional findByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId); - boolean deleteById(TenantId tenantId, AiSettingsId aiSettingsId); + boolean deleteById(TenantId tenantId, AiModelSettingsId settingsId); int deleteByTenantId(TenantId tenantId); - boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId); + boolean deleteByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsRedisCache.java similarity index 73% rename from dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsRedisCache.java rename to dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsRedisCache.java index 0c1da26769..8674c522ff 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsRedisCache.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsRedisCache.java @@ -23,14 +23,14 @@ import org.thingsboard.server.cache.TBRedisCacheConfiguration; import org.thingsboard.server.cache.TbJsonRedisSerializer; import org.thingsboard.server.cache.VersionedRedisTbCache; import org.thingsboard.server.common.data.CacheConstants; -import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.ai.AiModelSettings; @Component("AiSettingsCache") @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") -class AiSettingsRedisCache extends VersionedRedisTbCache { +class AiModelSettingsRedisCache extends VersionedRedisTbCache { - AiSettingsRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { - super(CacheConstants.AI_SETTINGS_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(AiSettings.class)); + AiModelSettingsRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.AI_MODEL_SETTINGS_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(AiModelSettings.class)); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java similarity index 50% rename from dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java rename to dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java index 8950e3559f..2fa47bf60f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java @@ -22,8 +22,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.ai.AiSettings; -import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.id.AiModelSettingsId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; @@ -43,29 +43,29 @@ import static org.thingsboard.server.dao.service.Validator.validatePageLink; @Service @RequiredArgsConstructor -class AiSettingsServiceImpl extends CachedVersionedEntityService implements AiSettingsService { +class AiModelSettingsServiceImpl extends CachedVersionedEntityService implements AiModelSettingsService { - private final DataValidator aiSettingsValidator; + private final DataValidator aiModelSettingsValidator; private final JpaExecutorService jpaExecutor; - private final AiSettingsDao aiSettingsDao; + private final AiModelSettingsDao aiModelSettingsDao; @Override @TransactionalEventListener - public void handleEvictEvent(AiSettingsCacheEvictEvent event) { + public void handleEvictEvent(AiModelSettingsCacheEvictEvent event) { cache.evict(event.keys()); } @Override @Transactional - public AiSettings save(AiSettings aiSettings) { - AiSettings oldSettings = aiSettingsValidator.validate(aiSettings, AiSettings::getTenantId); + public AiModelSettings save(AiModelSettings settings) { + AiModelSettings oldSettings = aiModelSettingsValidator.validate(settings, AiModelSettings::getTenantId); - AiSettings savedSettings; + AiModelSettings savedSettings; try { - savedSettings = aiSettingsDao.saveAndFlush(aiSettings.getTenantId(), aiSettings); + savedSettings = aiModelSettingsDao.saveAndFlush(settings.getTenantId(), settings); } catch (Exception e) { - checkConstraintViolation(e, "ai_settings_name_unq_key", "AI settings record with such name already exists!"); + checkConstraintViolation(e, "ai_model_settings_name_unq_key", "AI model settings with such name already exist!"); throw e; } @@ -78,65 +78,65 @@ class AiSettingsServiceImpl extends CachedVersionedEntityService findAiSettingsById(TenantId tenantId, AiSettingsId aiSettingsId) { - return Optional.ofNullable(aiSettingsDao.findById(tenantId, aiSettingsId.getId())); + public Optional findAiModelSettingsById(TenantId tenantId, AiModelSettingsId settingsId) { + return Optional.ofNullable(aiModelSettingsDao.findById(tenantId, settingsId.getId())); } @Override - public PageData findAiSettingsByTenantId(TenantId tenantId, PageLink pageLink) { + public PageData findAiModelSettingsByTenantId(TenantId tenantId, PageLink pageLink) { validatePageLink(pageLink); - return aiSettingsDao.findAllByTenantId(tenantId, pageLink); + return aiModelSettingsDao.findAllByTenantId(tenantId, pageLink); } @Override - public Optional findAiSettingsByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) { - var cacheKey = AiSettingsCacheKey.of(tenantId, aiSettingsId); - return Optional.ofNullable(cache.get(cacheKey, () -> aiSettingsDao.findByTenantIdAndId(tenantId, aiSettingsId).orElse(null))); + public Optional findAiModelSettingsByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId) { + var cacheKey = AiModelSettingsCacheKey.of(tenantId, settingsId); + return Optional.ofNullable(cache.get(cacheKey, () -> aiModelSettingsDao.findByTenantIdAndId(tenantId, settingsId).orElse(null))); } @Override - public FluentFuture> findAiSettingsByTenantIdAndIdAsync(TenantId tenantId, AiSettingsId aiSettingsId) { - return FluentFuture.from(jpaExecutor.submit(() -> findAiSettingsByTenantIdAndId(tenantId, aiSettingsId))); + public FluentFuture> findAiModelSettingsByTenantIdAndIdAsync(TenantId tenantId, AiModelSettingsId settingsId) { + return FluentFuture.from(jpaExecutor.submit(() -> findAiModelSettingsByTenantIdAndId(tenantId, settingsId))); } @Override @Transactional - public boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) { - return deleteByTenantIdAndIdInternal(tenantId, aiSettingsId); + public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId) { + return deleteByTenantIdAndIdInternal(tenantId, settingsId); } @Override public Optional> findEntity(TenantId tenantId, EntityId entityId) { - return findAiSettingsByTenantIdAndId(tenantId, (AiSettingsId) entityId) - .map(aiSettings -> aiSettings); // necessary to cast to HasId + return findAiModelSettingsByTenantIdAndId(tenantId, (AiModelSettingsId) entityId) + .map(settings -> settings); // necessary to cast to HasId } @Override public long countByTenantId(TenantId tenantId) { - return aiSettingsDao.countByTenantId(tenantId); + return aiModelSettingsDao.countByTenantId(tenantId); } @Override @Transactional public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { - deleteByTenantIdAndIdInternal(tenantId, new AiSettingsId(id.getId())); + deleteByTenantIdAndIdInternal(tenantId, new AiModelSettingsId(id.getId())); } - private boolean deleteByTenantIdAndIdInternal(TenantId tenantId, AiSettingsId aiSettingsId) { - Optional aiSettingsOpt = aiSettingsDao.findByTenantIdAndId(tenantId, aiSettingsId); - if (aiSettingsOpt.isEmpty()) { + private boolean deleteByTenantIdAndIdInternal(TenantId tenantId, AiModelSettingsId settingsId) { + Optional toDeleteOpt = aiModelSettingsDao.findByTenantIdAndId(tenantId, settingsId); + if (toDeleteOpt.isEmpty()) { return false; } - boolean deleted = aiSettingsDao.deleteByTenantIdAndId(tenantId, aiSettingsId); + boolean deleted = aiModelSettingsDao.deleteByTenantIdAndId(tenantId, settingsId); if (deleted) { - publishDeleteEvent(aiSettingsOpt.get()); - publishEvictEvent(AiSettingsCacheEvictEvent.of(tenantId, aiSettingsId)); + publishDeleteEvent(toDeleteOpt.get()); + publishEvictEvent(AiModelSettingsCacheEvictEvent.of(tenantId, settingsId)); } return deleted; } @@ -144,23 +144,23 @@ class AiSettingsServiceImpl extends CachedVersionedEntityService deletedSettings = aiSettingsDao.findAllByTenantId(tenantId, new PageLink(Integer.MAX_VALUE)).getData(); - if (deletedSettings.isEmpty()) { + List toDelete = aiModelSettingsDao.findAllByTenantId(tenantId, new PageLink(Integer.MAX_VALUE)).getData(); + if (toDelete.isEmpty()) { return; } - aiSettingsDao.deleteByTenantId(tenantId); + aiModelSettingsDao.deleteByTenantId(tenantId); - Set cacheKeys = Sets.newHashSetWithExpectedSize(deletedSettings.size()); - deletedSettings.forEach(settings -> { + Set cacheKeys = Sets.newHashSetWithExpectedSize(toDelete.size()); + toDelete.forEach(settings -> { publishDeleteEvent(settings); - cacheKeys.add(AiSettingsCacheKey.of(settings.getTenantId(), settings.getId())); + cacheKeys.add(AiModelSettingsCacheKey.of(settings.getTenantId(), settings.getId())); }); - publishEvictEvent(new AiSettingsCacheEvictEvent(cacheKeys)); + publishEvictEvent(new AiModelSettingsCacheEvictEvent(cacheKeys)); } - private void publishDeleteEvent(AiSettings settings) { + private void publishDeleteEvent(AiModelSettings settings) { eventPublisher.publishEvent(DeleteEntityEvent.builder() .tenantId(settings.getTenantId()) .entityId(settings.getId()) @@ -170,7 +170,7 @@ class AiSettingsServiceImpl extends CachedVersionedEntityService skippedEntities = EnumSet.of( EntityType.ALARM, EntityType.QUEUE, EntityType.TB_RESOURCE, EntityType.OTA_PACKAGE, EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_TEMPLATE, - EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, EntityType.AI_SETTINGS + EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, EntityType.AI_MODEL_SETTINGS ); @TransactionalEventListener(fallbackExecution = true) // after transaction commit diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index ecd93df6fd..87c432268a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -740,15 +740,12 @@ public class ModelConstants { public static final String CALCULATED_FIELD_LINK_CALCULATED_FIELD_ID = "calculated_field_id"; /** - * AI settings constants. - */ - public static final String AI_SETTINGS_TABLE_NAME = "ai_settings"; - public static final String AI_SETTINGS_TENANT_ID_COLUMN_NAME = TENANT_ID_COLUMN; - public static final String AI_SETTINGS_NAME_COLUMN_NAME = NAME_PROPERTY; - public static final String AI_SETTINGS_PROVIDER_COLUMN_NAME = "provider"; - public static final String AI_SETTINGS_PROVIDER_CONFIG_COLUMN_NAME = "provider_config"; - public static final String AI_SETTINGS_MODEL_COLUMN_NAME = "model"; - public static final String AI_SETTINGS_MODEL_CONFIG_COLUMN_NAME = "model_config"; + * AI model settings constants. + */ + public static final String AI_MODEL_SETTINGS_TABLE_NAME = "ai_model_settings"; + public static final String AI_MODEL_SETTINGS_TENANT_ID_COLUMN_NAME = TENANT_ID_COLUMN; + public static final String AI_MODEL_SETTINGS_NAME_COLUMN_NAME = NAME_PROPERTY; + public static final String AI_MODEL_SETTINGS_CONFIGURATION_COLUMN_NAME = "configuration"; protected static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, JSON_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN}; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelSettingsEntity.java similarity index 53% rename from dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java rename to dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelSettingsEntity.java index e2775e3cbb..06774f5a7a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiSettingsEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelSettingsEntity.java @@ -18,23 +18,20 @@ package org.thingsboard.server.dao.model.sql; import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.Table; import lombok.Getter; import lombok.Setter; import lombok.ToString; import org.hibernate.annotations.Type; import org.hibernate.proxy.HibernateProxy; -import org.thingsboard.server.common.data.ai.AiSettings; -import org.thingsboard.server.common.data.ai.model.AiModelConfig; -import org.thingsboard.server.common.data.ai.provider.AiProvider; -import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; -import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.ai.model.AiModel; +import org.thingsboard.server.common.data.id.AiModelSettingsId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.model.BaseVersionedEntity; import org.thingsboard.server.dao.model.ModelConstants; +import java.util.Map; import java.util.Objects; import java.util.UUID; @@ -42,53 +39,40 @@ import java.util.UUID; @Setter @ToString @Entity -@Table(name = ModelConstants.AI_SETTINGS_TABLE_NAME) -public class AiSettingsEntity extends BaseVersionedEntity { +@Table(name = ModelConstants.AI_MODEL_SETTINGS_TABLE_NAME) +public class AiModelSettingsEntity extends BaseVersionedEntity { - @Column(name = ModelConstants.AI_SETTINGS_TENANT_ID_COLUMN_NAME, nullable = false, columnDefinition = "UUID") + public static final Map COLUMN_MAP = Map.of( + "createdTime", "created_time" + ); + + @Column(name = ModelConstants.AI_MODEL_SETTINGS_TENANT_ID_COLUMN_NAME, nullable = false, columnDefinition = "UUID") private UUID tenantId; - @Column(name = ModelConstants.AI_SETTINGS_NAME_COLUMN_NAME, nullable = false) + @Column(name = ModelConstants.AI_MODEL_SETTINGS_NAME_COLUMN_NAME, nullable = false) private String name; - @Enumerated(EnumType.STRING) - @Column(name = ModelConstants.AI_SETTINGS_PROVIDER_COLUMN_NAME, nullable = false) - private AiProvider provider; - - @Type(JsonBinaryType.class) - @Column(name = ModelConstants.AI_SETTINGS_PROVIDER_CONFIG_COLUMN_NAME, nullable = false, columnDefinition = "JSONB") - private AiProviderConfig providerConfig; - - @Column(name = ModelConstants.AI_SETTINGS_MODEL_COLUMN_NAME, nullable = false) - private String model; - @Type(JsonBinaryType.class) - @Column(name = ModelConstants.AI_SETTINGS_MODEL_CONFIG_COLUMN_NAME, columnDefinition = "JSONB") - private AiModelConfig modelConfig; + @Column(name = ModelConstants.AI_MODEL_SETTINGS_CONFIGURATION_COLUMN_NAME, nullable = false, columnDefinition = "JSONB") + private AiModel configuration; - public AiSettingsEntity() {} + public AiModelSettingsEntity() {} - public AiSettingsEntity(AiSettings aiSettings) { - super(aiSettings); - tenantId = getTenantUuid(aiSettings.getTenantId()); - name = aiSettings.getName(); - provider = aiSettings.getProvider(); - providerConfig = aiSettings.getProviderConfig(); - model = aiSettings.getModel(); - modelConfig = aiSettings.getModelConfig(); + public AiModelSettingsEntity(AiModelSettings aiModelSettings) { + super(aiModelSettings); + tenantId = getTenantUuid(aiModelSettings.getTenantId()); + name = aiModelSettings.getName(); + configuration = aiModelSettings.getConfiguration(); } @Override - public AiSettings toData() { - var settings = new AiSettings(new AiSettingsId(id)); + public AiModelSettings toData() { + var settings = new AiModelSettings(new AiModelSettingsId(id)); settings.setCreatedTime(createdTime); settings.setVersion(version); settings.setTenantId(TenantId.fromUUID(tenantId)); settings.setName(name); - settings.setProvider(provider); - settings.setProviderConfig(providerConfig); - settings.setModel(model); - settings.setModelConfig(modelConfig); + settings.setConfiguration(configuration); return settings; } @@ -99,7 +83,7 @@ public class AiSettingsEntity extends BaseVersionedEntity { Class oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass(); Class thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); if (thisEffectiveClass != oEffectiveClass) return false; - AiSettingsEntity that = (AiSettingsEntity) o; + AiModelSettingsEntity that = (AiModelSettingsEntity) o; return getId() != null && Objects.equals(getId(), that.getId()); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelSettingsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelSettingsDataValidator.java new file mode 100644 index 0000000000..6ed84e3c3e --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelSettingsDataValidator.java @@ -0,0 +1,86 @@ +/** + * 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.service.validator; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.ai.AiModelSettingsDao; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.dao.tenant.TenantService; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +class AiModelSettingsDataValidator extends DataValidator { + + private final TenantService tenantService; + private final AiModelSettingsDao aiModelSettingsDao; + + @Override + protected void validateCreate(TenantId tenantId, AiModelSettings settings) { + validateNumberOfEntitiesPerTenant(tenantId, EntityType.AI_MODEL_SETTINGS); + } + + @Override + protected AiModelSettings validateUpdate(TenantId tenantId, AiModelSettings settings) { + Optional existing = aiModelSettingsDao.findByTenantIdAndId(tenantId, settings.getId()); + if (existing.isEmpty()) { + throw new DataValidationException("Cannot update non-existent AI model settings!"); + } + return existing.get(); + } + + @Override + protected void validateDataImpl(TenantId tenantId, AiModelSettings settings) { + // ID validation + if (settings.getId() != null) { + if (settings.getUuidId() == null) { + throw new DataValidationException("AI model settings UUID should be specified!"); + } + if (settings.getId().isNullUid()) { + throw new DataValidationException("AI model settings UUID must not be the reserved null value!"); + } + } + + // tenant ID validation + if (settings.getTenantId() == null || settings.getTenantId().getId() == null) { + throw new DataValidationException("AI model settings should be assigned to tenant!"); + } + if (settings.getTenantId().isSysTenantId()) { + throw new DataValidationException("AI model settings cannot be assigned to the system tenant!"); + } + if (!tenantService.tenantExists(tenantId)) { + throw new DataValidationException("AI model settings reference a non-existent tenant!"); + } + + // name validation + validateString("AI model settings name", settings.getName()); + if (settings.getName().length() > 255) { + throw new DataValidationException("AI model settings name should be between 1 and 255 symbols!"); + } + + // model config validation + if (settings.getConfiguration() == null) { + throw new DataValidationException("AI model settings configuration should be specified!"); + } + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiSettingsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiSettingsDataValidator.java deleted file mode 100644 index 24d4df23ee..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiSettingsDataValidator.java +++ /dev/null @@ -1,109 +0,0 @@ -/** - * 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.service.validator; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.ai.AiSettings; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.ai.AiSettingsDao; -import org.thingsboard.server.dao.exception.DataValidationException; -import org.thingsboard.server.dao.service.DataValidator; -import org.thingsboard.server.dao.tenant.TenantService; - -import java.util.Objects; -import java.util.Optional; - -@Component -@RequiredArgsConstructor -class AiSettingsDataValidator extends DataValidator { - - private final TenantService tenantService; - private final AiSettingsDao aiSettingsDao; - - @Override - protected void validateCreate(TenantId tenantId, AiSettings aiSettings) { - validateNumberOfEntitiesPerTenant(tenantId, EntityType.AI_SETTINGS); - } - - @Override - protected AiSettings validateUpdate(TenantId tenantId, AiSettings aiSettings) { - Optional old = aiSettingsDao.findByTenantIdAndId(tenantId, aiSettings.getId()); - if (old.isEmpty()) { - throw new DataValidationException("Cannot update non-existent AI settings!"); - } - return old.get(); - } - - @Override - protected void validateDataImpl(TenantId tenantId, AiSettings aiSettings) { - // ID validation - if (aiSettings.getId() != null) { - if (aiSettings.getUuidId() == null) { - throw new DataValidationException("AI settings UUID should be specified!"); - } - if (aiSettings.getId().isNullUid()) { - throw new DataValidationException("AI settings UUID must not be the reserved null value!"); - } - } - - // tenant ID validation - if (aiSettings.getTenantId() == null || aiSettings.getTenantId().getId() == null) { - throw new DataValidationException("AI settings should be assigned to tenant!"); - } - if (aiSettings.getTenantId().isSysTenantId()) { - throw new DataValidationException("AI settings cannot be assigned to the system tenant!"); - } - if (!tenantService.tenantExists(tenantId)) { - throw new DataValidationException("AI settings reference a non-existent tenant!"); - } - - // name validation - validateString("AI settings name", aiSettings.getName()); - if (aiSettings.getName().length() > 255) { - throw new DataValidationException("AI settings name should be between 1 and 255 symbols!"); - } - - // provider validation - if (aiSettings.getProvider() == null) { - throw new DataValidationException("AI provider should be specified!"); - } - - // provider config validation - if (aiSettings.getProviderConfig() == null) { - throw new DataValidationException("AI provider config should be specified!"); - } - if (aiSettings.getProviderConfig().getProvider() != aiSettings.getProvider()) { - throw new DataValidationException("AI provider configuration should match the selected AI provider!"); - } - validateString("AI provider API key", aiSettings.getProviderConfig().getApiKey()); - - // model identifier validation - validateString("AI model identifier", aiSettings.getModel()); - if (aiSettings.getModel().length() > 255) { - throw new DataValidationException("AI model identifier should be between 1 and 255 symbols!"); - } - - // model config validation - if (aiSettings.getModelConfig() != null) { - if (!Objects.equals(aiSettings.getModelConfig().getModel(), aiSettings.getModel())) { - throw new DataValidationException("AI model configuration should match the selected AI model!"); - } - } - } - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java similarity index 57% rename from dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java rename to dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java index a73569eac2..b645db8d66 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java @@ -22,30 +22,32 @@ import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; -import org.thingsboard.server.dao.model.sql.AiSettingsEntity; +import org.thingsboard.server.dao.model.sql.AiModelSettingsEntity; import java.util.Optional; import java.util.Set; import java.util.UUID; -public interface AiSettingsRepository extends JpaRepository { +interface AiModelSettingsRepository extends JpaRepository { - @Query("SELECT ai " + - "FROM AiSettingsEntity ai " + - "WHERE ai.tenantId = :tenantId " + - "AND (:textSearch IS NULL " + - "OR ilike(ai.name, CONCAT('%', :textSearch, '%')) = true " + - "OR ilike(ai.provider, CONCAT('%', :textSearch, '%')) = true " + - "OR ilike(ai.model, CONCAT('%', :textSearch, '%')) = true)") - Page findByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable); + @Query(nativeQuery = true, value = """ + SELECT * + FROM ai_model_settings ai_model + WHERE ai_model.tenant_id = :tenantId + AND (:textSearch IS NULL + OR ai_model.name ILIKE '%' || :textSearch || '%' + OR (ai_model.configuration -> 'providerConfig' ->> 'provider') ILIKE '%' || :textSearch || '%' + OR (ai_model.configuration ->> 'modelId') ILIKE '%' || :textSearch || '%') + """) + Page findByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable); - Optional findByTenantIdAndId(UUID tenantId, UUID id); + Optional findByTenantIdAndId(UUID tenantId, UUID id); long countByTenantId(UUID tenantId); @Transactional @Modifying - @Query("DELETE FROM AiSettingsEntity ai WHERE ai.id IN (:ids)") + @Query("DELETE FROM AiModelSettingsEntity ai_model WHERE ai_model.id IN (:ids)") int deleteByIdIn(@Param("ids") Set ids); @Transactional @@ -53,7 +55,7 @@ public interface AiSettingsRepository extends JpaRepository ids); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiSettingsDao.java index c56c29b835..e5d7df2ee9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiSettingsDao.java @@ -20,14 +20,14 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.ai.AiSettings; -import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.id.AiModelSettingsId; 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.dao.DaoUtil; -import org.thingsboard.server.dao.ai.AiSettingsDao; -import org.thingsboard.server.dao.model.sql.AiSettingsEntity; +import org.thingsboard.server.dao.ai.AiModelSettingsDao; +import org.thingsboard.server.dao.model.sql.AiModelSettingsEntity; import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.util.SqlDao; @@ -38,55 +38,55 @@ import java.util.UUID; @SqlDao @Component @RequiredArgsConstructor -class JpaAiSettingsDao extends JpaAbstractDao implements AiSettingsDao { +class JpaAiSettingsDao extends JpaAbstractDao implements AiModelSettingsDao { - private final AiSettingsRepository aiSettingsRepository; + private final AiModelSettingsRepository aiModelSettingsRepository; @Override - public Optional findByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) { - return aiSettingsRepository.findByTenantIdAndId(tenantId.getId(), aiSettingsId.getId()).map(DaoUtil::getData); + public Optional findByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId) { + return aiModelSettingsRepository.findByTenantIdAndId(tenantId.getId(), settingsId.getId()).map(DaoUtil::getData); } @Override - public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { - return DaoUtil.toPageData(aiSettingsRepository.findByTenantId( - tenantId.getId(), StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), DaoUtil.toPageable(pageLink)) + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return DaoUtil.toPageData(aiModelSettingsRepository.findByTenantId( + tenantId.getId(), StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), DaoUtil.toPageable(pageLink, AiModelSettingsEntity.COLUMN_MAP)) ); } @Override public Long countByTenantId(TenantId tenantId) { - return aiSettingsRepository.countByTenantId(tenantId.getId()); + return aiModelSettingsRepository.countByTenantId(tenantId.getId()); } @Override - public boolean deleteById(TenantId tenantId, AiSettingsId aiSettingsId) { - return aiSettingsRepository.deleteByIdIn(Set.of(aiSettingsId.getId())) > 0; + public boolean deleteById(TenantId tenantId, AiModelSettingsId settingsId) { + return aiModelSettingsRepository.deleteByIdIn(Set.of(settingsId.getId())) > 0; } @Override public int deleteByTenantId(TenantId tenantId) { - return aiSettingsRepository.deleteByTenantId(tenantId.getId()); + return aiModelSettingsRepository.deleteByTenantId(tenantId.getId()); } @Override - public boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) { - return aiSettingsRepository.deleteByTenantIdAndIdIn(tenantId.getId(), Set.of(aiSettingsId.getId())) > 0; + public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId) { + return aiModelSettingsRepository.deleteByTenantIdAndIdIn(tenantId.getId(), Set.of(settingsId.getId())) > 0; } @Override public EntityType getEntityType() { - return EntityType.AI_SETTINGS; + return EntityType.AI_MODEL_SETTINGS; } @Override - protected Class getEntityClass() { - return AiSettingsEntity.class; + protected Class getEntityClass() { + return AiModelSettingsEntity.class; } @Override - protected JpaRepository getRepository() { - return aiSettingsRepository; + protected JpaRepository getRepository() { + return aiModelSettingsRepository; } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index 505fe2a50a..ef85ba30c3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -183,7 +183,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService> ChatModel configureChatModel(AiChatModel chatModel); } 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 e5306c0193..ab0f3bd050 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 @@ -44,7 +44,7 @@ import org.thingsboard.server.common.data.rule.RuleNodeState; import org.thingsboard.server.common.data.script.ScriptLanguage; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; -import org.thingsboard.server.dao.ai.AiSettingsService; +import org.thingsboard.server.dao.ai.AiModelSettingsService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -418,9 +418,9 @@ public interface TbContext { AuditLogService getAuditLogService(); - RuleEngineAiService getAiService(); + RuleEngineAiModelService getAiModelService(); - AiSettingsService getAiSettingsService(); + AiModelSettingsService getAiModelSettingsService(); AiRequestsExecutor getAiRequestsExecutor(); 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 4d56b43c8e..2ac3ea3e6c 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 @@ -36,9 +36,11 @@ 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.ai.model.AiModelConfig; -import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; -import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.ai.model.AiModelType; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; +import org.thingsboard.server.common.data.id.AiModelSettingsId; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.msg.TbMsg; @@ -46,6 +48,7 @@ import org.thingsboard.server.dao.exception.DataValidationException; import java.util.List; import java.util.NoSuchElementException; +import java.util.Optional; import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; @@ -64,7 +67,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { private String userPrompt; private ResponseFormat responseFormat; private int timeoutSeconds; - private AiSettingsId aiSettingsId; + private AiModelSettingsId modelSettingsId; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { @@ -86,11 +89,16 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { systemPrompt = config.getSystemPrompt(); userPrompt = config.getUserPrompt(); timeoutSeconds = config.getTimeoutSeconds(); + modelSettingsId = config.getAiModelSettingsId(); - if (!aiSettingsExist(ctx, config.getAiSettingsId())) { - throw new TbNodeException("[" + ctx.getTenantId() + "] AI settings with ID: " + config.getAiSettingsId() + " were not found", true); + Optional modelSettings = ctx.getAiModelSettingsService().findAiModelSettingsByTenantIdAndId(ctx.getTenantId(), modelSettingsId); + if (modelSettings.isEmpty()) { + throw new TbNodeException("[" + ctx.getTenantId() + "] AI model settings with ID: [" + modelSettingsId + "] were not found", true); + } + AiModelType modelType = modelSettings.get().getConfiguration().modelType(); + if (modelType != AiModelType.CHAT) { + throw new TbNodeException("[" + ctx.getTenantId() + "] AI model settings with ID: [" + modelSettingsId + "] must be of type CHAT, but was " + modelType, true); } - aiSettingsId = config.getAiSettingsId(); } private static JsonSchema getJsonSchema(ResponseFormatType responseFormatType, ObjectNode jsonSchema) { @@ -100,12 +108,8 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { return responseFormatType == ResponseFormatType.JSON && jsonSchema != null ? Langchain4jJsonSchemaAdapter.fromJsonNode(jsonSchema) : null; } - private static boolean aiSettingsExist(TbContext ctx, AiSettingsId aiSettingsId) { - return ctx.getAiSettingsService().findAiSettingsByTenantIdAndId(ctx.getTenantId(), aiSettingsId).isPresent(); - } - @Override - public void onMsg(TbContext ctx, TbMsg msg) throws TbNodeException { + public void onMsg(TbContext ctx, TbMsg msg) { var ackedMsg = ackIfNeeded(ctx, msg); var systemMessage = SystemMessage.from(TbNodeUtils.processPattern(systemPrompt, ackedMsg)); @@ -137,19 +141,25 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { }, directExecutor()); } - private FluentFuture configureChatModelAsync(TbContext ctx) { - return ctx.getAiSettingsService().findAiSettingsByTenantIdAndIdAsync(ctx.getTenantId(), aiSettingsId).transform(aiSettingsOpt -> { - if (aiSettingsOpt.isEmpty()) { - throw new NoSuchElementException("AI settings with ID: " + aiSettingsId + " were not found"); + private > FluentFuture configureChatModelAsync(TbContext ctx) { + return ctx.getAiModelSettingsService().findAiModelSettingsByTenantIdAndIdAsync(ctx.getTenantId(), modelSettingsId).transform(settingsOpt -> { + if (settingsOpt.isEmpty()) { + throw new NoSuchElementException("[" + ctx.getTenantId() + "] AI model settings with ID: [" + modelSettingsId + "] were not found"); + } + AiModelSettings settings = settingsOpt.get(); + AiModelType modelType = settings.getConfiguration().modelType(); + if (modelType != AiModelType.CHAT) { + throw new IllegalStateException("[" + ctx.getTenantId() + "] AI model settings with ID: [" + modelSettingsId + "] must be of type CHAT, but was " + modelType); } - AiProviderConfig providerConfig = aiSettingsOpt.get().getProviderConfig(); - AiModelConfig modelConfig = aiSettingsOpt.get().getModelConfig(); + @SuppressWarnings("unchecked") + AiChatModel chatModel = (AiChatModel) settingsOpt.get().getConfiguration(); - modelConfig.setTimeoutSeconds(timeoutSeconds); - modelConfig.setMaxRetries(0); // disable retries to respect timeout set in rule node config + chatModel = chatModel.withModelConfig(chatModel.modelConfig() + .withTimeoutSeconds(timeoutSeconds) + .withMaxRetries(0)); // disable retries to respect timeout set in rule node config - return ctx.getAiService().configureChatModel(providerConfig, modelConfig); + return ctx.getAiModelService().configureChatModel(chatModel); }, ctx.getDbCallbackExecutor()); } @@ -172,7 +182,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { systemPrompt = null; userPrompt = null; responseFormat = null; - aiSettingsId = null; + modelSettingsId = 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 96ecde330a..4c99b6f613 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 @@ -26,14 +26,14 @@ import jakarta.validation.constraints.NotNull; import lombok.Data; import org.thingsboard.common.util.JsonSchemaUtils; import org.thingsboard.rule.engine.api.NodeConfiguration; -import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.id.AiModelSettingsId; import org.thingsboard.server.common.data.validation.Length; @Data public class TbAiNodeConfiguration implements NodeConfiguration { @NotNull - private AiSettingsId aiSettingsId; + private AiModelSettingsId aiModelSettingsId; @NotBlank @Length(min = 1, max = 1000) diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java index 62ca65db1b..0df2652ccb 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java @@ -19,7 +19,7 @@ import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; -import org.thingsboard.server.common.data.id.AiSettingsId; +import org.thingsboard.server.common.data.id.AiModelSettingsId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.ApiUsageStateId; import org.thingsboard.server.common.data.id.AssetId; @@ -176,8 +176,8 @@ public class TenantIdLoader { tenantEntity = null; } break; - case AI_SETTINGS: - tenantEntity = ctx.getAiSettingsService().findAiSettingsById(ctxTenantId, new AiSettingsId(id)).orElse(null); + case AI_MODEL_SETTINGS: + tenantEntity = ctx.getAiModelSettingsService().findAiModelSettingsById(ctxTenantId, new AiModelSettingsId(id)).orElse(null); break; default: throw new RuntimeException("Unexpected entity type: " + entityId.getEntityType()); diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java index c763a1b069..873393ef2b 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java @@ -40,7 +40,7 @@ import org.thingsboard.server.common.data.OtaPackage; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.ai.AiModelSettings; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; @@ -69,7 +69,7 @@ import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetsBundle; -import org.thingsboard.server.dao.ai.AiSettingsService; +import org.thingsboard.server.dao.ai.AiModelSettingsService; import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.customer.CustomerService; @@ -164,7 +164,7 @@ public class TenantIdLoaderTest { @Mock private CalculatedFieldService calculatedFieldService; @Mock - private AiSettingsService aiSettingsService; + private AiModelSettingsService aiModelSettingsService; private TenantId tenantId; private TenantProfileId tenantProfileId; @@ -424,11 +424,11 @@ public class TenantIdLoaderTest { when(ctx.getCalculatedFieldService()).thenReturn(calculatedFieldService); doReturn(calculatedFieldLink).when(calculatedFieldService).findCalculatedFieldLinkById(eq(tenantId), any()); break; - case AI_SETTINGS: - AiSettings aiSettings = new AiSettings(); - aiSettings.setTenantId(tenantId); - when(ctx.getAiSettingsService()).thenReturn(aiSettingsService); - doReturn(Optional.of(aiSettings)).when(aiSettingsService).findAiSettingsById(eq(tenantId), any()); + case AI_MODEL_SETTINGS: + AiModelSettings aiModelSettings = new AiModelSettings(); + aiModelSettings.setTenantId(tenantId); + when(ctx.getAiModelSettingsService()).thenReturn(aiModelSettingsService); + doReturn(Optional.of(aiModelSettings)).when(aiModelSettingsService).findAiModelSettingsById(eq(tenantId), any()); break; default: throw new RuntimeException("Unexpected originator EntityType " + entityType); From bfee0c9b4699840b65d6f51a27e406c47f183f97 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 23 Jun 2025 16:17:17 +0300 Subject: [PATCH 047/249] AI rule node: correct texts to reflect renaming --- .../thingsboard/server/controller/ControllerConstants.java | 2 +- ui-ngx/src/app/shared/models/entity-type.models.ts | 4 ++-- ui-ngx/src/assets/locale/locale.constant-en_US.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index b27c08b52c..a46455a47b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -90,7 +90,7 @@ public class ControllerConstants { protected static final String TENANT_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the tenant profile name."; protected static final String RULE_CHAIN_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the rule chain name."; protected static final String DEVICE_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the device profile name."; - protected static final String AI_SETTINGS_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the AI settings name, provider and model."; + protected static final String AI_SETTINGS_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the AI model settings name, provider and model ID."; protected static final String ASSET_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the asset profile name."; protected static final String CUSTOMER_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the customer title."; diff --git a/ui-ngx/src/app/shared/models/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts index 0c7b6ca152..3f70ad4b7c 100644 --- a/ui-ngx/src/app/shared/models/entity-type.models.ts +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -496,8 +496,8 @@ export const entityTypeTranslations = new Map Date: Mon, 23 Jun 2025 20:06:39 +0300 Subject: [PATCH 048/249] AI rule node: add version control support --- .../main/data/upgrade/basic/schema_update.sql | 4 +- .../controller/AiModelSettingsController.java | 4 +- .../controller/ControllerConstants.java | 2 +- .../entitiy/AbstractTbEntityService.java | 4 +- .../ai/DefaultTbAiModelSettingsService.java | 1 + .../DefaultEntitiesExportImportService.java | 3 +- .../impl/AiModelSettingsExportService.java | 36 +++++++++ .../impl/AiModelSettingsImportService.java | 77 +++++++++++++++++++ .../DefaultEntitiesVersionControlService.java | 7 +- .../vc/EntitiesVersionControlService.java | 4 +- .../common/data/ai/AiModelSettings.java | 26 +++++-- .../server/common/data/sync/JsonTbEntity.java | 7 +- .../server/dao/ai/AiModelSettingsDao.java | 4 +- .../dao/ai/AiModelSettingsServiceImpl.java | 4 +- .../dao/model/sql/AiModelSettingsEntity.java | 5 ++ .../dao/sql/ai/AiModelSettingsRepository.java | 13 +++- ...ngsDao.java => JpaAiModelSettingsDao.java} | 31 +++++++- .../main/resources/sql/schema-entities.sql | 4 +- .../app/shared/models/entity-type.models.ts | 4 +- 19 files changed, 207 insertions(+), 33 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelSettingsExportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelSettingsImportService.java rename dao/src/main/java/org/thingsboard/server/dao/sql/ai/{JpaAiSettingsDao.java => JpaAiModelSettingsDao.java} (69%) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index f77c8dce67..3aa5a03b11 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -16,10 +16,12 @@ CREATE TABLE ai_model_settings ( id UUID NOT NULL PRIMARY KEY, + external_id UUID, created_time BIGINT NOT NULL, tenant_id UUID NOT NULL, version BIGINT NOT NULL DEFAULT 1, name VARCHAR(255) NOT NULL, configuration JSONB NOT NULL, - CONSTRAINT ai_model_settings_name_unq_key UNIQUE (tenant_id, name) + CONSTRAINT ai_model_settings_name_unq_key UNIQUE (tenant_id, name), + CONSTRAINT ai_model_settings_external_id_unq_key UNIQUE (tenant_id, external_id) ); diff --git a/application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java b/application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java index 891f6b176c..69c3181e26 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java @@ -38,7 +38,7 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; -import static org.thingsboard.server.controller.ControllerConstants.AI_SETTINGS_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.AI_MODEL_SETTINGS_TEXT_SEARCH_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -99,7 +99,7 @@ public class AiModelSettingsController extends BaseController { @RequestParam int pageSize, @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) @RequestParam int page, - @Parameter(description = AI_SETTINGS_TEXT_SEARCH_DESCRIPTION) + @Parameter(description = AI_MODEL_SETTINGS_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch, @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"})) @RequestParam(required = false) String sortProperty, diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index a46455a47b..20e350f0b8 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -90,7 +90,7 @@ public class ControllerConstants { protected static final String TENANT_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the tenant profile name."; protected static final String RULE_CHAIN_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the rule chain name."; protected static final String DEVICE_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the device profile name."; - protected static final String AI_SETTINGS_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the AI model settings name, provider and model ID."; + protected static final String AI_MODEL_SETTINGS_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the AI model settings name, provider and model ID."; protected static final String ASSET_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the asset profile name."; protected static final String CUSTOMER_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the customer title."; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java index 476a4ef5ca..81fd1478b5 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java @@ -97,7 +97,7 @@ public abstract class AbstractTbEntityService { return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID); } - protected ListenableFuture autoCommit(User user, EntityId entityId) throws Exception { + protected ListenableFuture autoCommit(User user, EntityId entityId) { if (vcService != null) { return vcService.autoCommit(user, entityId); } else { @@ -106,7 +106,7 @@ public abstract class AbstractTbEntityService { } } - protected ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) throws Exception { + protected ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) { if (vcService != null) { return vcService.autoCommit(user, entityType, entityIds); } else { diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelSettingsService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelSettingsService.java index 5a41d83eed..69f130461c 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelSettingsService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelSettingsService.java @@ -44,6 +44,7 @@ class DefaultTbAiModelSettingsService extends AbstractTbEntityService implements AiModelSettings savedSettings; try { savedSettings = aiModelSettingsService.save(settings); + autoCommit(user, savedSettings.getId()); } catch (Exception e) { logEntityActionService.logEntityAction(tenantId, requireNonNullElseGet(settings.getId(), () -> emptyId(EntityType.AI_MODEL_SETTINGS)), settings, actionType, user, e); throw e; diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java index db7e37b368..82f855b7c2 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java @@ -69,7 +69,8 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS EntityType.DASHBOARD, EntityType.ASSET_PROFILE, EntityType.ASSET, EntityType.DEVICE_PROFILE, EntityType.DEVICE, EntityType.ENTITY_VIEW, EntityType.WIDGET_TYPE, EntityType.WIDGETS_BUNDLE, - EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE + EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, + EntityType.AI_MODEL_SETTINGS ); @Override diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelSettingsExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelSettingsExportService.java new file mode 100644 index 0000000000..a5ba16e9b1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelSettingsExportService.java @@ -0,0 +1,36 @@ +/** + * 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.sync.ie.exporting.impl; + +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.id.AiModelSettingsId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.Set; + +@Service +@TbCoreComponent +class AiModelSettingsExportService extends BaseEntityExportService> { + + @Override + public Set getSupportedEntityTypes() { + return Set.of(EntityType.AI_MODEL_SETTINGS); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelSettingsImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelSettingsImportService.java new file mode 100644 index 0000000000..e5666e5003 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelSettingsImportService.java @@ -0,0 +1,77 @@ +/** + * 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.sync.ie.importing.impl; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.id.AiModelSettingsId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.dao.ai.AiModelSettingsService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +class AiModelSettingsImportService extends BaseEntityImportService> { + + private final AiModelSettingsService aiModelSettingsService; + + @Override + protected void setOwner( + TenantId tenantId, + AiModelSettings settings, + BaseEntityImportService>.IdProvider idProvider + ) { + settings.setTenantId(tenantId); + } + + @Override + protected AiModelSettings prepare( + EntitiesImportCtx ctx, + AiModelSettings settings, + AiModelSettings oldEntity, + EntityExportData exportData, + BaseEntityImportService>.IdProvider idProvider + ) { + return settings; + } + + @Override + protected AiModelSettings deepCopy(AiModelSettings settings) { + return new AiModelSettings(settings); + } + + @Override + protected AiModelSettings saveOrUpdate( + EntitiesImportCtx ctx, + AiModelSettings settings, + EntityExportData exportData, + BaseEntityImportService>.IdProvider idProvider, + CompareResult compareResult + ) { + return aiModelSettingsService.save(settings); + } + + @Override + public EntityType getEntityType() { + return EntityType.AI_MODEL_SETTINGS; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java index 420e85dc6c..5fe891c847 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java @@ -114,9 +114,8 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont private final TbTransactionalCache taskCache; private final VersionControlExecutor executor; - @SuppressWarnings("UnstableApiUsage") @Override - public ListenableFuture saveEntitiesVersion(User user, VersionCreateRequest request) throws Exception { + public ListenableFuture saveEntitiesVersion(User user, VersionCreateRequest request) { checkBranchName(request.getBranch()); var pendingCommit = gitServiceQueue.prepareCommit(user, request); DonAsynchron.withCallback(pendingCommit, commit -> { @@ -546,7 +545,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont } @Override - public ListenableFuture autoCommit(User user, EntityId entityId) throws Exception { + public ListenableFuture autoCommit(User user, EntityId entityId) { var repositorySettings = repositorySettingsService.get(user.getTenantId()); if (repositorySettings == null || repositorySettings.isReadOnly()) { return Futures.immediateFuture(null); @@ -573,7 +572,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont } @Override - public ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) throws Exception { + public ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) { var repositorySettings = repositorySettingsService.get(user.getTenantId()); if (repositorySettings == null || repositorySettings.isReadOnly()) { return Futures.immediateFuture(null); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java index 3f22120d9c..e5ff89ebbd 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java @@ -69,9 +69,9 @@ public interface EntitiesVersionControlService { ListenableFuture checkVersionControlAccess(TenantId tenantId, RepositorySettings settings) throws Exception; - ListenableFuture autoCommit(User user, EntityId entityId) throws Exception; + ListenableFuture autoCommit(User user, EntityId entityId); - ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) throws Exception; + ListenableFuture autoCommit(User user, EntityType entityType, List entityIds); ListenableFuture getEntityDataInfo(User user, EntityId entityId, String versionId); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModelSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModelSettings.java index df1dbe4d7e..750f779e68 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModelSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModelSettings.java @@ -21,7 +21,7 @@ import lombok.Builder; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.BaseData; -import org.thingsboard.server.common.data.HasName; +import org.thingsboard.server.common.data.ExportableEntity; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.ai.model.AiModel; @@ -34,7 +34,7 @@ import java.io.Serial; @Builder @AllArgsConstructor @EqualsAndHashCode(callSuper = true) -public final class AiModelSettings extends BaseData implements HasTenantId, HasVersion, HasName { +public final class AiModelSettings extends BaseData implements HasTenantId, HasVersion, ExportableEntity { @Serial private static final long serialVersionUID = 9017108678716011604L; @@ -45,7 +45,7 @@ public final class AiModelSettings extends BaseData implement description = "JSON object representing the ID of the tenant associated with these AI model settings", example = "e3c4b7d2-5678-4a9b-0c1d-2e3f4a5b6c7d" ) - TenantId tenantId; + private TenantId tenantId; @Schema( requiredMode = Schema.RequiredMode.REQUIRED, @@ -54,22 +54,24 @@ public final class AiModelSettings extends BaseData implement example = "7", defaultValue = "1" ) - Long version; + private Long version; @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, description = "Human-readable name of the AI model settings; must be unique within the scope of the tenant", - example = "Default AI Settings" + example = "Rule node assistant" ) - String name; + private String name; @Schema( requiredMode = Schema.RequiredMode.NOT_REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, description = "Configuration of the AI model" ) - AiModel configuration; + private AiModel configuration; + + private AiModelSettingsId externalId; public AiModelSettings() {} @@ -77,4 +79,14 @@ public final class AiModelSettings extends BaseData implement super(id); } + public AiModelSettings(AiModelSettings settings) { + super(settings.getId()); + createdTime = settings.getCreatedTime(); + tenantId = settings.getTenantId(); + version = settings.getVersion(); + name = settings.getName(); + configuration = settings.getConfiguration(); + externalId = settings.getExternalId() == null ? null : new AiModelSettingsId(settings.getExternalId().getId()); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java index c763daeb7f..6914b75636 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.ai.AiModelSettings; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.notification.rule.NotificationRule; @@ -58,8 +59,8 @@ import java.lang.annotation.Target; @Type(name = "NOTIFICATION_TEMPLATE", value = NotificationTemplate.class), @Type(name = "NOTIFICATION_TARGET", value = NotificationTarget.class), @Type(name = "NOTIFICATION_RULE", value = NotificationRule.class), - @Type(name = "TB_RESOURCE", value = TbResource.class) + @Type(name = "TB_RESOURCE", value = TbResource.class), + @Type(name = "AI_MODEL_SETTINGS", value = AiModelSettings.class) }) @JsonIgnoreProperties(value = {"tenantId", "createdTime", "version"}, ignoreUnknown = true) -public @interface JsonTbEntity { -} +public @interface JsonTbEntity {} diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsDao.java index ae47af0db2..5ee0d49121 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsDao.java @@ -18,12 +18,12 @@ package org.thingsboard.server.dao.ai; import org.thingsboard.server.common.data.ai.AiModelSettings; import org.thingsboard.server.common.data.id.AiModelSettingsId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.Dao; +import org.thingsboard.server.dao.ExportableEntityDao; import org.thingsboard.server.dao.TenantEntityDao; import java.util.Optional; -public interface AiModelSettingsDao extends Dao, TenantEntityDao { +public interface AiModelSettingsDao extends TenantEntityDao, ExportableEntityDao { Optional findByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java index 2fa47bf60f..3875169305 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java @@ -65,7 +65,9 @@ class AiModelSettingsServiceImpl extends CachedVersionedEntityService @Column(name = ModelConstants.AI_MODEL_SETTINGS_CONFIGURATION_COLUMN_NAME, nullable = false, columnDefinition = "JSONB") private AiModel configuration; + @Column(name = ModelConstants.EXTERNAL_ID_PROPERTY, columnDefinition = "UUID") + private UUID externalId; + public AiModelSettingsEntity() {} public AiModelSettingsEntity(AiModelSettings aiModelSettings) { @@ -63,6 +66,7 @@ public class AiModelSettingsEntity extends BaseVersionedEntity tenantId = getTenantUuid(aiModelSettings.getTenantId()); name = aiModelSettings.getName(); configuration = aiModelSettings.getConfiguration(); + externalId = getUuid(aiModelSettings.getExternalId()); } @Override @@ -73,6 +77,7 @@ public class AiModelSettingsEntity extends BaseVersionedEntity settings.setTenantId(TenantId.fromUUID(tenantId)); settings.setName(name); settings.setConfiguration(configuration); + settings.setExternalId(getEntityId(externalId, AiModelSettingsId::new)); return settings; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java index b645db8d66..e5909c5818 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java @@ -22,13 +22,18 @@ import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.AiModelSettingsEntity; import java.util.Optional; import java.util.Set; import java.util.UUID; -interface AiModelSettingsRepository extends JpaRepository { +interface AiModelSettingsRepository extends JpaRepository, ExportableEntityRepository { + + Optional findByTenantIdAndId(UUID tenantId, UUID id); + + Optional findByTenantIdAndName(UUID tenantId, String name); @Query(nativeQuery = true, value = """ SELECT * @@ -41,7 +46,11 @@ interface AiModelSettingsRepository extends JpaRepository findByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable); - Optional findByTenantIdAndId(UUID tenantId, UUID id); + @Query("SELECT ai_model.id FROM AiModelSettingsEntity ai_model WHERE ai_model.tenantId = :tenantId") + Page findIdsByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable); + + @Query("SELECT externalId FROM AiModelSettingsEntity WHERE id = :id") + Optional getExternalIdById(@Param("id") UUID id); long countByTenantId(UUID tenantId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelSettingsDao.java similarity index 69% rename from dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiSettingsDao.java rename to dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelSettingsDao.java index e5d7df2ee9..e7b87b0a1a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelSettingsDao.java @@ -38,7 +38,7 @@ import java.util.UUID; @SqlDao @Component @RequiredArgsConstructor -class JpaAiSettingsDao extends JpaAbstractDao implements AiModelSettingsDao { +class JpaAiModelSettingsDao extends JpaAbstractDao implements AiModelSettingsDao { private final AiModelSettingsRepository aiModelSettingsRepository; @@ -47,13 +47,40 @@ class JpaAiSettingsDao extends JpaAbstractDao findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findByTenantId(tenantId.getId(), pageLink); + } + + @Override + public PageData findByTenantId(UUID tenantId, PageLink pageLink) { return DaoUtil.toPageData(aiModelSettingsRepository.findByTenantId( - tenantId.getId(), StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), DaoUtil.toPageable(pageLink, AiModelSettingsEntity.COLUMN_MAP)) + tenantId, StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), DaoUtil.toPageable(pageLink, AiModelSettingsEntity.COLUMN_MAP)) + ); + } + + @Override + public PageData findIdsByTenantId(UUID tenantId, PageLink pageLink) { + return DaoUtil.pageToPageData( + aiModelSettingsRepository.findIdsByTenantId(tenantId, DaoUtil.toPageable(pageLink, AiModelSettingsEntity.COLUMN_MAP)).map(AiModelSettingsId::new) ); } + @Override + public AiModelSettingsId getExternalIdByInternal(AiModelSettingsId internalId) { + return aiModelSettingsRepository.getExternalIdById(internalId.getId()).map(AiModelSettingsId::new).orElse(null); + } + @Override public Long countByTenantId(TenantId tenantId) { return aiModelSettingsRepository.countByTenantId(tenantId.getId()); diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index cf2b64175e..82d3aea66b 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -951,10 +951,12 @@ CREATE TABLE IF NOT EXISTS cf_debug_event ( CREATE TABLE IF NOT EXISTS ai_model_settings ( id UUID NOT NULL PRIMARY KEY, + external_id UUID, created_time BIGINT NOT NULL, tenant_id UUID NOT NULL, version BIGINT NOT NULL DEFAULT 1, name VARCHAR(255) NOT NULL, configuration JSONB NOT NULL, - CONSTRAINT ai_model_settings_name_unq_key UNIQUE (tenant_id, name) + CONSTRAINT ai_model_settings_name_unq_key UNIQUE (tenant_id, name), + CONSTRAINT ai_model_settings_external_id_unq_key UNIQUE (tenant_id, external_id) ); diff --git a/ui-ngx/src/app/shared/models/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts index 3f70ad4b7c..02bae1576f 100644 --- a/ui-ngx/src/app/shared/models/entity-type.models.ts +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -51,7 +51,7 @@ export enum EntityType { MOBILE_APP_BUNDLE = 'MOBILE_APP_BUNDLE', MOBILE_APP = 'MOBILE_APP', CALCULATED_FIELD = 'CALCULATED_FIELD', - AI_SETTINGS = 'AI_SETTINGS', + AI_MODEL_SETTINGS = 'AI_MODEL_SETTINGS', } export enum AliasEntityType { @@ -494,7 +494,7 @@ export const entityTypeTranslations = new Map Date: Mon, 23 Jun 2025 21:09:40 +0300 Subject: [PATCH 049/249] AI rule node: add basic support for Azure OpenAI --- application/pom.xml | 4 ++ .../Langchain4jChatModelConfigurerImpl.java | 13 ++++ .../data/ai/model/chat/AiChatModel.java | 2 +- .../data/ai/model/chat/AiChatModelConfig.java | 2 +- .../ai/model/chat/AzureOpenAiChatModel.java | 60 +++++++++++++++++++ .../chat/Langchain4jChatModelConfigurer.java | 2 + .../common/data/ai/provider/AiProvider.java | 1 + .../data/ai/provider/AiProviderConfig.java | 3 +- .../provider/AzureOpenAiProviderConfig.java | 30 ++++++++++ 9 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java diff --git a/application/pom.xml b/application/pom.xml index 47459950c7..2c11c50d3d 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -381,6 +381,10 @@ org.rocksdb rocksdbjni + + dev.langchain4j + langchain4j-azure-open-ai + dev.langchain4j langchain4j-open-ai 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 f66f31400b..d3b23b65b5 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 @@ -17,6 +17,7 @@ package org.thingsboard.server.service.ai; import dev.langchain4j.model.chat.ChatModel; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModel; import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModel; import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModel; @@ -39,6 +40,18 @@ public class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelC .build(); } + @Override + public ChatModel configureChatModel(AzureOpenAiChatModel chatModel) { + AzureOpenAiChatModel.Config modelConfig = chatModel.modelConfig(); + return dev.langchain4j.model.azure.AzureOpenAiChatModel.builder() + .apiKey(chatModel.providerConfig().apiKey()) + .deploymentName(chatModel.modelId()) + .temperature(modelConfig.temperature()) + .timeout(toDuration(modelConfig.timeoutSeconds())) + .maxRetries(modelConfig.maxRetries()) + .build(); + } + @Override public ChatModel configureChatModel(GoogleAiGeminiChatModel chatModel) { GoogleAiGeminiChatModel.Config modelConfig = chatModel.modelConfig(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java index 17f79d93e2..4e944ed6ec 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java @@ -20,7 +20,7 @@ import org.thingsboard.server.common.data.ai.model.AiModel; import org.thingsboard.server.common.data.ai.model.AiModelType; public sealed interface AiChatModel> extends AiModel - permits OpenAiChatModel, GoogleAiGeminiChatModel, MistralAiChatModel { + permits OpenAiChatModel, AzureOpenAiChatModel, GoogleAiGeminiChatModel, MistralAiChatModel { ChatModel configure(Langchain4jChatModelConfigurer configurer); 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 565923cf6c..e44c9c5fa1 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 @@ -18,7 +18,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import org.thingsboard.server.common.data.ai.model.AiModelConfig; public sealed interface AiChatModelConfig> extends AiModelConfig - permits OpenAiChatModel.Config, GoogleAiGeminiChatModel.Config, MistralAiChatModel.Config { + permits OpenAiChatModel.Config, AzureOpenAiChatModel.Config, GoogleAiGeminiChatModel.Config, MistralAiChatModel.Config { Double temperature(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java new file mode 100644 index 0000000000..37a1d84095 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java @@ -0,0 +1,60 @@ +/** + * 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 org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; + +public record AzureOpenAiChatModel( + AzureOpenAiProviderConfig providerConfig, + String modelId, + Config modelConfig +) implements AiChatModel { + + public record Config( + Double temperature, + Integer timeoutSeconds, + Integer maxRetries + ) implements AiChatModelConfig { + + @Override + public AzureOpenAiChatModel.Config withTemperature(Double temperature) { + return new Config(temperature, timeoutSeconds, maxRetries); + } + + @Override + public AzureOpenAiChatModel.Config withTimeoutSeconds(Integer timeoutSeconds) { + return new Config(temperature, timeoutSeconds, maxRetries); + } + + @Override + public AzureOpenAiChatModel.Config withMaxRetries(Integer maxRetries) { + return new Config(temperature, timeoutSeconds, maxRetries); + } + + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public AzureOpenAiChatModel withModelConfig(AzureOpenAiChatModel.Config config) { + return new AzureOpenAiChatModel(providerConfig, modelId, config); + } + +} 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 87d317eb0b..41dd1fe4ad 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 @@ -21,6 +21,8 @@ public interface Langchain4jChatModelConfigurer { ChatModel configureChatModel(OpenAiChatModel chatModel); + ChatModel configureChatModel(AzureOpenAiChatModel chatModel); + ChatModel configureChatModel(GoogleAiGeminiChatModel chatModel); ChatModel configureChatModel(MistralAiChatModel chatModel); 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 068d75873b..20fb379f7d 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 @@ -18,6 +18,7 @@ package org.thingsboard.server.common.data.ai.provider; public enum AiProvider { OPENAI, + AZURE_OPENAI, GOOGLE_AI_GEMINI, MISTRAL_AI 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 b7c594ba60..84cda054d0 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 @@ -25,11 +25,12 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; ) @JsonSubTypes({ @JsonSubTypes.Type(value = OpenAiProviderConfig.class, name = "OPENAI"), + @JsonSubTypes.Type(value = AzureOpenAiProviderConfig.class, name = "AZURE_OPENAI"), @JsonSubTypes.Type(value = GoogleAiGeminiProviderConfig.class, name = "GOOGLE_AI_GEMINI"), @JsonSubTypes.Type(value = MistralAiProviderConfig.class, name = "MISTRAL_AI") }) public sealed interface AiProviderConfig - permits OpenAiProviderConfig, GoogleAiGeminiProviderConfig, MistralAiProviderConfig { + permits OpenAiProviderConfig, AzureOpenAiProviderConfig, GoogleAiGeminiProviderConfig, MistralAiProviderConfig { AiProvider provider(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java new file mode 100644 index 0000000000..f9a2a98a21 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java @@ -0,0 +1,30 @@ +/** + * 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; + +public record AzureOpenAiProviderConfig(String apiKey) implements AiProviderConfig { + + @Override + public AiProvider provider() { + return AiProvider.AZURE_OPENAI; + } + + @Override + public String apiKey() { + return apiKey; + } + +} From 7f0cd6dccbae3ac095c3b485a1938551ed76de1e Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 24 Jun 2025 15:08:17 +0300 Subject: [PATCH 050/249] AI rule node: support more OpenAI models --- .../server/common/data/ai/model/AiModel.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java index 686c1f4fcf..5955ac4355 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java @@ -30,6 +30,16 @@ import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; ) @JsonSubTypes({ @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4o"), + @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4o-mini"), + @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4.1"), + @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4.1-mini"), + @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4.1-nano"), + @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o4-mini"), + // @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o3-pro"), needs verification with Gov ID :) + // @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o3"), needs verification with Gov ID :) + @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o3-mini"), + // @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o1-pro"), LC4j sends requests to v1/chat/completions, but o1-pro is only supported in v1/responses + @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o1"), @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-2.5-flash"), @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "mistral-medium-latest") }) From aca9e3186e0d6e171160b8fc1188524d278eb8f5 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 24 Jun 2025 16:42:49 +0300 Subject: [PATCH 051/249] AI rule node: support more Gemini models --- .../server/common/data/ai/model/AiModel.java | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java index 5955ac4355..9fc4d50985 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java @@ -29,18 +29,24 @@ import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; visible = true ) @JsonSubTypes({ - @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4o"), - @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4o-mini"), - @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4.1"), - @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4.1-mini"), - @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4.1-nano"), @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o4-mini"), // @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o3-pro"), needs verification with Gov ID :) // @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o3"), needs verification with Gov ID :) @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o3-mini"), // @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o1-pro"), LC4j sends requests to v1/chat/completions, but o1-pro is only supported in v1/responses @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o1"), + @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4.1"), + @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4.1-mini"), + @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4.1-nano"), + @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4o"), + @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4o-mini"), + @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-2.5-pro"), @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-2.5-flash"), + @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-2.0-flash"), + @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-2.0-flash-lite"), + @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-1.5-pro"), + @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-1.5-flash"), + @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-1.5-flash-8b"), @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "mistral-medium-latest") }) public interface AiModel> { From ec64201dfc778fbb821a03b377d792aad8c94c3c Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Tue, 24 Jun 2025 16:56:02 +0300 Subject: [PATCH 052/249] UI: Fixed vc result popover with long error message --- ui-ngx/src/app/modules/home/components/vc/version-control.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui-ngx/src/app/modules/home/components/vc/version-control.scss b/ui-ngx/src/app/modules/home/components/vc/version-control.scss index 35a0932f19..f221fc66ad 100644 --- a/ui-ngx/src/app/modules/home/components/vc/version-control.scss +++ b/ui-ngx/src/app/modules/home/components/vc/version-control.scss @@ -16,9 +16,11 @@ :host { .vc-result-message { max-width: 65vw; + max-height: 65vh; padding: 0 8px; text-align: center; word-wrap: break-word; + overflow: auto; &:first-child { padding-top: 48px; } From fe63059a776ef943925218acd46cf83978f3c1b0 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 24 Jun 2025 17:52:40 +0300 Subject: [PATCH 053/249] AI rule node: support more Mistral AI models --- .../server/common/data/ai/model/AiModel.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java index 9fc4d50985..8f88b7aa01 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; visible = true ) @JsonSubTypes({ + // OpenAI models @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o4-mini"), // @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o3-pro"), needs verification with Gov ID :) // @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o3"), needs verification with Gov ID :) @@ -40,6 +41,8 @@ import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4.1-nano"), @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4o"), @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4o-mini"), + + // Google AI Gemini models @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-2.5-pro"), @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-2.5-flash"), @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-2.0-flash"), @@ -47,7 +50,17 @@ import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-1.5-pro"), @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-1.5-flash"), @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-1.5-flash-8b"), - @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "mistral-medium-latest") + + // Mistral AI models + @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "magistral-medium-latest"), + @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "magistral-small-latest"), + @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "mistral-large-latest"), + @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "mistral-medium-latest"), + @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "mistral-small-latest"), + @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "pixtral-large-latest"), + @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "ministral-8b-latest"), + @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "ministral-3b-latest"), + @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "open-mistral-nemo") }) public interface AiModel> { From 536f252b1636a5dfa8631f8d9efc83b48d6e813e Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 24 Jun 2025 18:47:03 +0300 Subject: [PATCH 054/249] AI rule node: add index on tenant ID --- application/src/main/data/upgrade/basic/schema_update.sql | 6 ++++-- dao/src/main/resources/sql/schema-entities-idx.sql | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 7a33c481c2..4897a35b41 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -60,7 +60,7 @@ WHERE trigger_type = 'RATE_LIMITS' -- UPDATE NOTIFICATION RULE CASSANDRA RATE LIMITS END --- CREATE AI MODEL SETTINGS TABLE START +-- CREATE AI MODEL SETTINGS TABLE AND INDEX START CREATE TABLE ai_model_settings ( id UUID NOT NULL PRIMARY KEY, @@ -74,4 +74,6 @@ CREATE TABLE ai_model_settings ( CONSTRAINT ai_model_settings_external_id_unq_key UNIQUE (tenant_id, external_id) ); --- CREATE AI MODEL SETTINGS TABLE END +CREATE INDEX idx_ai_model_settings_tenant_id ON ai_model_settings(tenant_id); + +-- CREATE AI MODEL SETTINGS TABLE AND INDEX END diff --git a/dao/src/main/resources/sql/schema-entities-idx.sql b/dao/src/main/resources/sql/schema-entities-idx.sql index ad311f00df..5611be2121 100644 --- a/dao/src/main/resources/sql/schema-entities-idx.sql +++ b/dao/src/main/resources/sql/schema-entities-idx.sql @@ -131,3 +131,5 @@ CREATE INDEX IF NOT EXISTS idx_resource_type_public_resource_key ON resource(res CREATE INDEX IF NOT EXISTS mobile_app_bundle_tenant_id ON mobile_app_bundle(tenant_id); CREATE INDEX IF NOT EXISTS idx_job_tenant_id ON job(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_ai_model_settings_tenant_id ON ai_model_settings(tenant_id); From d5c885dcde0749d7ed2a64ecf95aa6ad91efc9bf Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Wed, 25 Jun 2025 14:31:41 +0300 Subject: [PATCH 055/249] AI rule node: add support for Google Vertex AI Gemini --- application/pom.xml | 4 + .../Langchain4jChatModelConfigurerImpl.java | 59 ++++++++- .../server/common/data/ai/model/AiModel.java | 47 +------ .../common/data/ai/model/AiModelConfig.java | 6 +- .../data/ai/model/AiModelTypeIdResolver.java | 120 ++++++++++++++++++ .../data/ai/model/chat/AiChatModel.java | 2 +- .../data/ai/model/chat/AiChatModelConfig.java | 2 +- .../ai/model/chat/AzureOpenAiChatModel.java | 10 +- .../model/chat/GoogleAiGeminiChatModel.java | 10 +- .../chat/GoogleVertexAiGeminiChatModel.java | 60 +++++++++ .../chat/Langchain4jChatModelConfigurer.java | 2 + .../ai/model/chat/MistralAiChatModel.java | 10 +- .../data/ai/model/chat/OpenAiChatModel.java | 10 +- .../common/data/ai/provider/AiProvider.java | 1 + .../data/ai/provider/AiProviderConfig.java | 5 +- .../provider/AzureOpenAiProviderConfig.java | 5 - .../GoogleAiGeminiProviderConfig.java | 5 - .../GoogleVertexAiGeminiProviderConfig.java | 31 +++++ .../ai/provider/MistralAiProviderConfig.java | 5 - .../ai/provider/OpenAiProviderConfig.java | 5 - .../dao/sql/ai/AiModelSettingsRepository.java | 30 +++-- 21 files changed, 326 insertions(+), 103 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java diff --git a/application/pom.xml b/application/pom.xml index 2c11c50d3d..7ec326758b 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -393,6 +393,10 @@ dev.langchain4j langchain4j-google-ai-gemini + + dev.langchain4j + langchain4j-vertex-ai-gemini + dev.langchain4j langchain4j-mistral-ai 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 d3b23b65b5..91e9c7874e 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 @@ -15,25 +15,37 @@ */ package org.thingsboard.server.service.ai; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.auth.oauth2.ServiceAccountCredentials; +import com.google.cloud.vertexai.Transport; +import com.google.cloud.vertexai.VertexAI; +import com.google.cloud.vertexai.api.GenerationConfig; +import com.google.cloud.vertexai.generativeai.GenerativeModel; import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModel; import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModel; import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModel; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModel; +import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +import java.io.ByteArrayInputStream; +import java.io.IOException; import java.time.Duration; @Component -public class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigurer { +class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigurer { @Override public ChatModel configureChatModel(OpenAiChatModel chatModel) { OpenAiChatModel.Config modelConfig = chatModel.modelConfig(); return dev.langchain4j.model.openai.OpenAiChatModel.builder() .apiKey(chatModel.providerConfig().apiKey()) - .modelName(chatModel.modelId()) + .modelName(modelConfig.modelId()) .temperature(modelConfig.temperature()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) @@ -45,7 +57,7 @@ public class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelC AzureOpenAiChatModel.Config modelConfig = chatModel.modelConfig(); return dev.langchain4j.model.azure.AzureOpenAiChatModel.builder() .apiKey(chatModel.providerConfig().apiKey()) - .deploymentName(chatModel.modelId()) + .deploymentName(modelConfig.modelId()) .temperature(modelConfig.temperature()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) @@ -57,19 +69,56 @@ public class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelC GoogleAiGeminiChatModel.Config modelConfig = chatModel.modelConfig(); return dev.langchain4j.model.googleai.GoogleAiGeminiChatModel.builder() .apiKey(chatModel.providerConfig().apiKey()) - .modelName(chatModel.modelId()) + .modelName(modelConfig.modelId()) .temperature(modelConfig.temperature()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); } + @Override + public ChatModel configureChatModel(GoogleVertexAiGeminiChatModel chatModel) { + GoogleVertexAiGeminiProviderConfig providerConfig = chatModel.providerConfig(); + GoogleVertexAiGeminiChatModel.Config modelConfig = chatModel.modelConfig(); + + // construct service account credentials using service account key JSON + ObjectNode serviceAccountKeyJson = providerConfig.serviceAccountKey(); + ServiceAccountCredentials serviceAccountCredentials; + try { + serviceAccountCredentials = ServiceAccountCredentials + .fromStream(new ByteArrayInputStream(JacksonUtil.writeValueAsBytes(serviceAccountKeyJson))); + } catch (IOException e) { + throw new RuntimeException("Failed to parse service account key JSON", e); + } + + // construct Vertex AI instance + var vertexAI = new VertexAI.Builder() + .setProjectId(providerConfig.projectId()) + .setLocation(providerConfig.location()) + .setCredentials(serviceAccountCredentials) + .setTransport(Transport.REST) // GRPC also possible, but likely does not work with service account keys + .build(); + + // map model config to generation config + var generationConfigBuilder = GenerationConfig.newBuilder(); + if (modelConfig.temperature() != null) { + generationConfigBuilder.setTemperature(modelConfig.temperature().floatValue()); + } + var generationConfig = generationConfigBuilder.build(); + + // construct generative model instance + var generativeModel = new GenerativeModel(modelConfig.modelId(), vertexAI) + .withGenerationConfig(generationConfig); + + return new VertexAiGeminiChatModel(generativeModel, generationConfig, modelConfig.maxRetries()); + } + @Override public ChatModel configureChatModel(MistralAiChatModel chatModel) { MistralAiChatModel.Config modelConfig = chatModel.modelConfig(); return dev.langchain4j.model.mistralai.MistralAiChatModel.builder() .apiKey(chatModel.providerConfig().apiKey()) - .modelName(chatModel.modelId()) + .modelName(modelConfig.modelId()) .temperature(modelConfig.temperature()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java index 8f88b7aa01..114fe2a528 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java @@ -15,61 +15,22 @@ */ package org.thingsboard.server.common.data.ai.model; -import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; -import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModel; -import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModel; -import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModel; +import com.fasterxml.jackson.databind.annotation.JsonTypeIdResolver; import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; @JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, + use = JsonTypeInfo.Id.CUSTOM, include = JsonTypeInfo.As.PROPERTY, - property = "modelId", - visible = true + property = "@type" ) -@JsonSubTypes({ - // OpenAI models - @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o4-mini"), - // @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o3-pro"), needs verification with Gov ID :) - // @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o3"), needs verification with Gov ID :) - @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o3-mini"), - // @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o1-pro"), LC4j sends requests to v1/chat/completions, but o1-pro is only supported in v1/responses - @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "o1"), - @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4.1"), - @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4.1-mini"), - @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4.1-nano"), - @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4o"), - @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4o-mini"), - - // Google AI Gemini models - @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-2.5-pro"), - @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-2.5-flash"), - @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-2.0-flash"), - @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-2.0-flash-lite"), - @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-1.5-pro"), - @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-1.5-flash"), - @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-1.5-flash-8b"), - - // Mistral AI models - @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "magistral-medium-latest"), - @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "magistral-small-latest"), - @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "mistral-large-latest"), - @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "mistral-medium-latest"), - @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "mistral-small-latest"), - @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "pixtral-large-latest"), - @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "ministral-8b-latest"), - @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "ministral-3b-latest"), - @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "open-mistral-nemo") -}) +@JsonTypeIdResolver(AiModelTypeIdResolver.class) public interface AiModel> { AiProviderConfig providerConfig(); AiModelType modelType(); - String modelId(); - C modelConfig(); AiModel withModelConfig(C config); 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 b07007c301..7f9526a4db 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 @@ -15,4 +15,8 @@ */ package org.thingsboard.server.common.data.ai.model; -public interface AiModelConfig> {} +public interface AiModelConfig> { + + String modelId(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java new file mode 100644 index 0000000000..34dab4b6aa --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java @@ -0,0 +1,120 @@ +/** + * 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; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.DatabindContext; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.jsontype.impl.TypeIdResolverBase; +import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModel; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public final class AiModelTypeIdResolver extends TypeIdResolverBase { + + private static final Map> typeIdToModelClass; + + static { + Map> map = new HashMap<>(); + + // OpenAI models + map.put("OPENAI::o4-mini", OpenAiChatModel.class); + // map.put("OPENAI::o3-pro", OpenAiChatModel.class); // needs verification with Gov ID :) + // map.put("OPENAI::o3", OpenAiChatModel.class); // needs verification with Gov ID :) + map.put("OPENAI::o3-mini", OpenAiChatModel.class); + // map.put("OPENAI::o1-pro", OpenAiChatModel.class); // LC4j sends requests to v1/chat/completions, but o1-pro is only supported in v1/responses + map.put("OPENAI::o1", OpenAiChatModel.class); + map.put("OPENAI::gpt-4.1", OpenAiChatModel.class); + map.put("OPENAI::gpt-4.1-mini", OpenAiChatModel.class); + map.put("OPENAI::gpt-4.1-nano", OpenAiChatModel.class); + map.put("OPENAI::gpt-4o", OpenAiChatModel.class); + map.put("OPENAI::gpt-4o-mini", OpenAiChatModel.class); + + // Google AI Gemini models + map.put("GOOGLE_AI_GEMINI::gemini-2.5-pro", GoogleAiGeminiChatModel.class); + map.put("GOOGLE_AI_GEMINI::gemini-2.5-flash", GoogleAiGeminiChatModel.class); + map.put("GOOGLE_AI_GEMINI::gemini-2.0-flash", GoogleAiGeminiChatModel.class); + map.put("GOOGLE_AI_GEMINI::gemini-2.0-flash-lite", GoogleAiGeminiChatModel.class); + map.put("GOOGLE_AI_GEMINI::gemini-1.5-pro", GoogleAiGeminiChatModel.class); + map.put("GOOGLE_AI_GEMINI::gemini-1.5-flash", GoogleAiGeminiChatModel.class); + map.put("GOOGLE_AI_GEMINI::gemini-1.5-flash-8b", GoogleAiGeminiChatModel.class); + + // Google Vertex AI Gemini models + map.put("GOOGLE_VERTEX_AI_GEMINI::gemini-2.5-pro", GoogleVertexAiGeminiChatModel.class); + map.put("GOOGLE_VERTEX_AI_GEMINI::gemini-2.5-flash", GoogleVertexAiGeminiChatModel.class); + map.put("GOOGLE_VERTEX_AI_GEMINI::gemini-2.0-flash", GoogleVertexAiGeminiChatModel.class); + map.put("GOOGLE_VERTEX_AI_GEMINI::gemini-2.0-flash-lite", GoogleVertexAiGeminiChatModel.class); + map.put("GOOGLE_VERTEX_AI_GEMINI::gemini-1.5-pro", GoogleVertexAiGeminiChatModel.class); + map.put("GOOGLE_VERTEX_AI_GEMINI::gemini-1.5-flash", GoogleVertexAiGeminiChatModel.class); + map.put("GOOGLE_VERTEX_AI_GEMINI::gemini-1.5-flash-8b", GoogleVertexAiGeminiChatModel.class); + + // Mistral AI models + map.put("MISTRAL_AI::magistral-medium-latest", MistralAiChatModel.class); + map.put("MISTRAL_AI::magistral-small-latest", MistralAiChatModel.class); + map.put("MISTRAL_AI::mistral-large-latest", MistralAiChatModel.class); + map.put("MISTRAL_AI::mistral-medium-latest", MistralAiChatModel.class); + map.put("MISTRAL_AI::mistral-small-latest", MistralAiChatModel.class); + map.put("MISTRAL_AI::pixtral-large-latest", MistralAiChatModel.class); + map.put("MISTRAL_AI::ministral-8b-latest", MistralAiChatModel.class); + map.put("MISTRAL_AI::ministral-3b-latest", MistralAiChatModel.class); + map.put("MISTRAL_AI::open-mistral-nemo", MistralAiChatModel.class); + + typeIdToModelClass = Collections.unmodifiableMap(map); + } + + private JavaType baseType; + + @Override + public void init(JavaType baseType) { + this.baseType = baseType; + } + + @Override + public String idFromValue(Object value) { + return generateId((AiModel) value); + } + + @Override + public String idFromValueAndType(Object value, Class suggestedType) { + return generateId((AiModel) value); + } + + @Override + public JavaType typeFromId(DatabindContext context, String id) { + Class modelClass = typeIdToModelClass.get(id); + if (modelClass == null) { + throw new IllegalArgumentException("Unknown model type ID: " + id); + } + return context.constructSpecializedType(baseType, modelClass); + } + + @Override + public JsonTypeInfo.Id getMechanism() { + return JsonTypeInfo.Id.CUSTOM; + } + + private static String generateId(AiModel model) { + String provider = model.providerConfig().provider().name(); + String modelId = model.modelConfig().modelId(); + return provider + "::" + modelId; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java index 4e944ed6ec..08d4630d10 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java @@ -20,7 +20,7 @@ import org.thingsboard.server.common.data.ai.model.AiModel; import org.thingsboard.server.common.data.ai.model.AiModelType; public sealed interface AiChatModel> extends AiModel - permits OpenAiChatModel, AzureOpenAiChatModel, GoogleAiGeminiChatModel, MistralAiChatModel { + permits OpenAiChatModel, AzureOpenAiChatModel, GoogleAiGeminiChatModel, GoogleVertexAiGeminiChatModel, MistralAiChatModel { ChatModel configure(Langchain4jChatModelConfigurer configurer); 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 e44c9c5fa1..4ef5369005 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 @@ -18,7 +18,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import org.thingsboard.server.common.data.ai.model.AiModelConfig; public sealed interface AiChatModelConfig> extends AiModelConfig - permits OpenAiChatModel.Config, AzureOpenAiChatModel.Config, GoogleAiGeminiChatModel.Config, MistralAiChatModel.Config { + permits OpenAiChatModel.Config, AzureOpenAiChatModel.Config, GoogleAiGeminiChatModel.Config, GoogleVertexAiGeminiChatModel.Config, MistralAiChatModel.Config { Double temperature(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java index 37a1d84095..9920b546f0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java @@ -20,11 +20,11 @@ import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; public record AzureOpenAiChatModel( AzureOpenAiProviderConfig providerConfig, - String modelId, Config modelConfig ) implements AiChatModel { public record Config( + String modelId, Double temperature, Integer timeoutSeconds, Integer maxRetries @@ -32,17 +32,17 @@ public record AzureOpenAiChatModel( @Override public AzureOpenAiChatModel.Config withTemperature(Double temperature) { - return new Config(temperature, timeoutSeconds, maxRetries); + return new Config(modelId, temperature, timeoutSeconds, maxRetries); } @Override public AzureOpenAiChatModel.Config withTimeoutSeconds(Integer timeoutSeconds) { - return new Config(temperature, timeoutSeconds, maxRetries); + return new Config(modelId, temperature, timeoutSeconds, maxRetries); } @Override public AzureOpenAiChatModel.Config withMaxRetries(Integer maxRetries) { - return new Config(temperature, timeoutSeconds, maxRetries); + return new Config(modelId, temperature, timeoutSeconds, maxRetries); } } @@ -54,7 +54,7 @@ public record AzureOpenAiChatModel( @Override public AzureOpenAiChatModel withModelConfig(AzureOpenAiChatModel.Config config) { - return new AzureOpenAiChatModel(providerConfig, modelId, config); + return new AzureOpenAiChatModel(providerConfig, config); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java index 875262abb6..c09903b305 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java @@ -20,11 +20,11 @@ import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConf public record GoogleAiGeminiChatModel( GoogleAiGeminiProviderConfig providerConfig, - String modelId, Config modelConfig ) implements AiChatModel { public record Config( + String modelId, Double temperature, Integer timeoutSeconds, Integer maxRetries @@ -32,17 +32,17 @@ public record GoogleAiGeminiChatModel( @Override public Config withTemperature(Double temperature) { - return new Config(temperature, timeoutSeconds, maxRetries); + return new Config(modelId, temperature, timeoutSeconds, maxRetries); } @Override public Config withTimeoutSeconds(Integer timeoutSeconds) { - return new Config(temperature, timeoutSeconds, maxRetries); + return new Config(modelId, temperature, timeoutSeconds, maxRetries); } @Override public Config withMaxRetries(Integer maxRetries) { - return new Config(temperature, timeoutSeconds, maxRetries); + return new Config(modelId, temperature, timeoutSeconds, maxRetries); } } @@ -54,7 +54,7 @@ public record GoogleAiGeminiChatModel( @Override public GoogleAiGeminiChatModel withModelConfig(GoogleAiGeminiChatModel.Config config) { - return new GoogleAiGeminiChatModel(providerConfig, modelId, config); + return new GoogleAiGeminiChatModel(providerConfig, config); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java new file mode 100644 index 0000000000..a340430828 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java @@ -0,0 +1,60 @@ +/** + * 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 org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; + +public record GoogleVertexAiGeminiChatModel( + GoogleVertexAiGeminiProviderConfig providerConfig, + Config modelConfig +) implements AiChatModel { + + public record Config( + String modelId, + Double temperature, + Integer timeoutSeconds, // TODO: not supported by Vertex AI + Integer maxRetries + ) implements AiChatModelConfig { + + @Override + public Config withTemperature(Double temperature) { + return new Config(modelId, temperature, timeoutSeconds, maxRetries); + } + + @Override + public Config withTimeoutSeconds(Integer timeoutSeconds) { + return new Config(modelId, temperature, timeoutSeconds, maxRetries); + } + + @Override + public Config withMaxRetries(Integer maxRetries) { + return new Config(modelId, temperature, timeoutSeconds, maxRetries); + } + + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public GoogleVertexAiGeminiChatModel withModelConfig(GoogleVertexAiGeminiChatModel.Config config) { + return new GoogleVertexAiGeminiChatModel(providerConfig, config); + } + +} 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 41dd1fe4ad..3602fb14a7 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 @@ -25,6 +25,8 @@ public interface Langchain4jChatModelConfigurer { ChatModel configureChatModel(GoogleAiGeminiChatModel chatModel); + ChatModel configureChatModel(GoogleVertexAiGeminiChatModel chatModel); + ChatModel configureChatModel(MistralAiChatModel chatModel); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java index 413d2b93a8..e4eae1b766 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java @@ -20,11 +20,11 @@ import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; public record MistralAiChatModel( MistralAiProviderConfig providerConfig, - String modelId, Config modelConfig ) implements AiChatModel { public record Config( + String modelId, Double temperature, Integer timeoutSeconds, Integer maxRetries @@ -32,17 +32,17 @@ public record MistralAiChatModel( @Override public Config withTemperature(Double temperature) { - return new Config(temperature, timeoutSeconds, maxRetries); + return new Config(modelId, temperature, timeoutSeconds, maxRetries); } @Override public Config withTimeoutSeconds(Integer timeoutSeconds) { - return new Config(temperature, timeoutSeconds, maxRetries); + return new Config(modelId, temperature, timeoutSeconds, maxRetries); } @Override public Config withMaxRetries(Integer maxRetries) { - return new Config(temperature, timeoutSeconds, maxRetries); + return new Config(modelId, temperature, timeoutSeconds, maxRetries); } } @@ -54,7 +54,7 @@ public record MistralAiChatModel( @Override public MistralAiChatModel withModelConfig(Config config) { - return new MistralAiChatModel(providerConfig, modelId, config); + return new MistralAiChatModel(providerConfig, config); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java index 0d5031d512..a4d7401cbf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java @@ -20,11 +20,11 @@ import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; public record OpenAiChatModel( OpenAiProviderConfig providerConfig, - String modelId, Config modelConfig ) implements AiChatModel { public record Config( + String modelId, Double temperature, Integer timeoutSeconds, Integer maxRetries @@ -32,17 +32,17 @@ public record OpenAiChatModel( @Override public OpenAiChatModel.Config withTemperature(Double temperature) { - return new Config(temperature, timeoutSeconds, maxRetries); + return new Config(modelId, temperature, timeoutSeconds, maxRetries); } @Override public OpenAiChatModel.Config withTimeoutSeconds(Integer timeoutSeconds) { - return new Config(temperature, timeoutSeconds, maxRetries); + return new Config(modelId, temperature, timeoutSeconds, maxRetries); } @Override public OpenAiChatModel.Config withMaxRetries(Integer maxRetries) { - return new Config(temperature, timeoutSeconds, maxRetries); + return new Config(modelId, temperature, timeoutSeconds, maxRetries); } } @@ -54,7 +54,7 @@ public record OpenAiChatModel( @Override public OpenAiChatModel withModelConfig(OpenAiChatModel.Config config) { - return new OpenAiChatModel(providerConfig, modelId, config); + return new OpenAiChatModel(providerConfig, config); } } 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 20fb379f7d..da4df1a076 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 @@ -20,6 +20,7 @@ public enum AiProvider { OPENAI, AZURE_OPENAI, GOOGLE_AI_GEMINI, + GOOGLE_VERTEX_AI_GEMINI, MISTRAL_AI } 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 84cda054d0..e3b3b250cc 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 @@ -27,13 +27,12 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; @JsonSubTypes.Type(value = OpenAiProviderConfig.class, name = "OPENAI"), @JsonSubTypes.Type(value = AzureOpenAiProviderConfig.class, name = "AZURE_OPENAI"), @JsonSubTypes.Type(value = GoogleAiGeminiProviderConfig.class, name = "GOOGLE_AI_GEMINI"), + @JsonSubTypes.Type(value = GoogleVertexAiGeminiProviderConfig.class, name = "GOOGLE_VERTEX_AI_GEMINI"), @JsonSubTypes.Type(value = MistralAiProviderConfig.class, name = "MISTRAL_AI") }) public sealed interface AiProviderConfig - permits OpenAiProviderConfig, AzureOpenAiProviderConfig, GoogleAiGeminiProviderConfig, MistralAiProviderConfig { + permits OpenAiProviderConfig, AzureOpenAiProviderConfig, GoogleAiGeminiProviderConfig, GoogleVertexAiGeminiProviderConfig, MistralAiProviderConfig { AiProvider provider(); - String apiKey(); - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java index f9a2a98a21..0b948eabab 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java @@ -22,9 +22,4 @@ public record AzureOpenAiProviderConfig(String apiKey) implements AiProviderConf return AiProvider.AZURE_OPENAI; } - @Override - public String apiKey() { - return apiKey; - } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java index 0bb9d21b52..35def6f0f5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java @@ -22,9 +22,4 @@ public record GoogleAiGeminiProviderConfig(String apiKey) implements AiProviderC return AiProvider.GOOGLE_AI_GEMINI; } - @Override - public String apiKey() { - return apiKey; - } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java new file mode 100644 index 0000000000..a5140279ac --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.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.ai.provider; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +public record GoogleVertexAiGeminiProviderConfig( + String projectId, + String location, + ObjectNode serviceAccountKey +) implements AiProviderConfig { + + @Override + public AiProvider provider() { + return AiProvider.GOOGLE_VERTEX_AI_GEMINI; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java index 45e3b68800..29c251b3cd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java @@ -22,9 +22,4 @@ public record MistralAiProviderConfig(String apiKey) implements AiProviderConfig return AiProvider.MISTRAL_AI; } - @Override - public String apiKey() { - return apiKey; - } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java index 0536d1176e..6c069276d5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java @@ -22,9 +22,4 @@ public record OpenAiProviderConfig(String apiKey) implements AiProviderConfig { return AiProvider.OPENAI; } - @Override - public String apiKey() { - return apiKey; - } - } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java index e5909c5818..a86d5ba5c8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java @@ -35,15 +35,27 @@ interface AiModelSettingsRepository extends JpaRepository findByTenantIdAndName(UUID tenantId, String name); - @Query(nativeQuery = true, value = """ - SELECT * - FROM ai_model_settings ai_model - WHERE ai_model.tenant_id = :tenantId - AND (:textSearch IS NULL - OR ai_model.name ILIKE '%' || :textSearch || '%' - OR (ai_model.configuration -> 'providerConfig' ->> 'provider') ILIKE '%' || :textSearch || '%' - OR (ai_model.configuration ->> 'modelId') ILIKE '%' || :textSearch || '%') - """) + @Query( + value = """ + SELECT * + FROM ai_model_settings ai_model + WHERE ai_model.tenant_id = :tenantId + AND (:textSearch IS NULL + OR ai_model.name ILIKE '%' || :textSearch || '%' + OR (ai_model.configuration -> 'providerConfig' ->> 'provider') ILIKE '%' || :textSearch || '%' + OR (ai_model.configuration -> 'modelConfig' ->> 'modelId') ILIKE '%' || :textSearch || '%') + """, + countQuery = """ + SELECT COUNT(*) + FROM ai_model_settings ai_model + WHERE ai_model.tenant_id = :tenantId + AND (:textSearch IS NULL + OR ai_model.name ILIKE '%' || :textSearch || '%' + OR (ai_model.configuration -> 'providerConfig' ->> 'provider') ILIKE '%' || :textSearch || '%' + OR (ai_model.configuration -> 'modelConfig' ->> 'modelId') ILIKE '%' || :textSearch || '%') + """, + nativeQuery = true + ) Page findByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable); @Query("SELECT ai_model.id FROM AiModelSettingsEntity ai_model WHERE ai_model.tenantId = :tenantId") From 0cc59801561a4addaf0312884d2bd76a72f7cade Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 26 Jun 2025 12:23:19 +0300 Subject: [PATCH 056/249] AI rule node: add Anthropic support --- application/pom.xml | 8 ++- .../Langchain4jChatModelConfigurerImpl.java | 13 ++++ .../data/ai/model/AiModelTypeIdResolver.java | 9 +++ .../data/ai/model/chat/AiChatModel.java | 4 +- .../data/ai/model/chat/AiChatModelConfig.java | 4 +- .../ai/model/chat/AnthropicChatModel.java | 60 +++++++++++++++++++ .../chat/Langchain4jChatModelConfigurer.java | 2 + .../common/data/ai/provider/AiProvider.java | 3 +- .../data/ai/provider/AiProviderConfig.java | 7 ++- .../ai/provider/AnthropicProviderConfig.java | 25 ++++++++ .../thingsboard/rule/engine/ai/TbAiNode.java | 20 +++---- 11 files changed, 135 insertions(+), 20 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java diff --git a/application/pom.xml b/application/pom.xml index 7ec326758b..e92aeba533 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -383,11 +383,11 @@ dev.langchain4j - langchain4j-azure-open-ai + langchain4j-open-ai dev.langchain4j - langchain4j-open-ai + langchain4j-azure-open-ai dev.langchain4j @@ -401,6 +401,10 @@ dev.langchain4j langchain4j-mistral-ai + + dev.langchain4j + langchain4j-anthropic + 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 91e9c7874e..aa4aed988d 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 @@ -25,6 +25,7 @@ import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModel; import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModel; import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModel; import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModel; @@ -125,6 +126,18 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .build(); } + @Override + public ChatModel configureChatModel(AnthropicChatModel chatModel) { + AnthropicChatModel.Config modelConfig = chatModel.modelConfig(); + return dev.langchain4j.model.anthropic.AnthropicChatModel.builder() + .apiKey(chatModel.providerConfig().apiKey()) + .modelName(modelConfig.modelId()) + .temperature(modelConfig.temperature()) + .timeout(toDuration(modelConfig.timeoutSeconds())) + .maxRetries(modelConfig.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/model/AiModelTypeIdResolver.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java index 34dab4b6aa..0f3634e1f4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.databind.DatabindContext; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.jsontype.impl.TypeIdResolverBase; +import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModel; import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModel; import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModel; import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModel; @@ -77,6 +78,14 @@ public final class AiModelTypeIdResolver extends TypeIdResolverBase { map.put("MISTRAL_AI::ministral-3b-latest", MistralAiChatModel.class); map.put("MISTRAL_AI::open-mistral-nemo", MistralAiChatModel.class); + // Anthropic models + map.put("ANTHROPIC::claude-opus-4-0", AnthropicChatModel.class); + map.put("ANTHROPIC::claude-sonnet-4-0", AnthropicChatModel.class); + map.put("ANTHROPIC::claude-3-7-sonnet-latest", AnthropicChatModel.class); + map.put("ANTHROPIC::claude-3-5-sonnet-latest", AnthropicChatModel.class); + map.put("ANTHROPIC::claude-3-5-haiku-latest", AnthropicChatModel.class); + map.put("ANTHROPIC::claude-3-opus-latest", AnthropicChatModel.class); + typeIdToModelClass = Collections.unmodifiableMap(map); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java index 08d4630d10..2043efd860 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java @@ -20,7 +20,9 @@ import org.thingsboard.server.common.data.ai.model.AiModel; import org.thingsboard.server.common.data.ai.model.AiModelType; public sealed interface AiChatModel> extends AiModel - permits OpenAiChatModel, AzureOpenAiChatModel, GoogleAiGeminiChatModel, GoogleVertexAiGeminiChatModel, MistralAiChatModel { + permits + OpenAiChatModel, AzureOpenAiChatModel, GoogleAiGeminiChatModel, + GoogleVertexAiGeminiChatModel, MistralAiChatModel, AnthropicChatModel { ChatModel configure(Langchain4jChatModelConfigurer configurer); 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 4ef5369005..d346b22731 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 @@ -18,7 +18,9 @@ package org.thingsboard.server.common.data.ai.model.chat; import org.thingsboard.server.common.data.ai.model.AiModelConfig; public sealed interface AiChatModelConfig> extends AiModelConfig - permits OpenAiChatModel.Config, AzureOpenAiChatModel.Config, GoogleAiGeminiChatModel.Config, GoogleVertexAiGeminiChatModel.Config, MistralAiChatModel.Config { + permits + OpenAiChatModel.Config, AzureOpenAiChatModel.Config, GoogleAiGeminiChatModel.Config, + GoogleVertexAiGeminiChatModel.Config, MistralAiChatModel.Config, AnthropicChatModel.Config { Double temperature(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java new file mode 100644 index 0000000000..329f35382a --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java @@ -0,0 +1,60 @@ +/** + * 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 org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; + +public record AnthropicChatModel( + AnthropicProviderConfig providerConfig, + Config modelConfig +) implements AiChatModel { + + public record Config( + String modelId, + Double temperature, + Integer timeoutSeconds, + Integer maxRetries + ) implements AiChatModelConfig { + + @Override + public AnthropicChatModel.Config withTemperature(Double temperature) { + return new Config(modelId, temperature, timeoutSeconds, maxRetries); + } + + @Override + public AnthropicChatModel.Config withTimeoutSeconds(Integer timeoutSeconds) { + return new Config(modelId, temperature, timeoutSeconds, maxRetries); + } + + @Override + public AnthropicChatModel.Config withMaxRetries(Integer maxRetries) { + return new Config(modelId, temperature, timeoutSeconds, maxRetries); + } + + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public AnthropicChatModel withModelConfig(AnthropicChatModel.Config config) { + return new AnthropicChatModel(providerConfig, config); + } + +} 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 3602fb14a7..3cb75419f0 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 @@ -29,4 +29,6 @@ public interface Langchain4jChatModelConfigurer { ChatModel configureChatModel(MistralAiChatModel chatModel); + ChatModel configureChatModel(AnthropicChatModel chatModel); + } 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 da4df1a076..78709fd52c 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 @@ -21,6 +21,7 @@ public enum AiProvider { AZURE_OPENAI, GOOGLE_AI_GEMINI, GOOGLE_VERTEX_AI_GEMINI, - MISTRAL_AI + MISTRAL_AI, + ANTHROPIC } 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 e3b3b250cc..e13f3fac7b 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 @@ -28,10 +28,13 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; @JsonSubTypes.Type(value = AzureOpenAiProviderConfig.class, name = "AZURE_OPENAI"), @JsonSubTypes.Type(value = GoogleAiGeminiProviderConfig.class, name = "GOOGLE_AI_GEMINI"), @JsonSubTypes.Type(value = GoogleVertexAiGeminiProviderConfig.class, name = "GOOGLE_VERTEX_AI_GEMINI"), - @JsonSubTypes.Type(value = MistralAiProviderConfig.class, name = "MISTRAL_AI") + @JsonSubTypes.Type(value = MistralAiProviderConfig.class, name = "MISTRAL_AI"), + @JsonSubTypes.Type(value = AnthropicProviderConfig.class, name = "ANTHROPIC") }) public sealed interface AiProviderConfig - permits OpenAiProviderConfig, AzureOpenAiProviderConfig, GoogleAiGeminiProviderConfig, GoogleVertexAiGeminiProviderConfig, MistralAiProviderConfig { + permits + OpenAiProviderConfig, AzureOpenAiProviderConfig, GoogleAiGeminiProviderConfig, + GoogleVertexAiGeminiProviderConfig, MistralAiProviderConfig, AnthropicProviderConfig { AiProvider provider(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java new file mode 100644 index 0000000000..91ef41a072 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java @@ -0,0 +1,25 @@ +/** + * 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; + +public record AnthropicProviderConfig(String apiKey) implements AiProviderConfig { + + @Override + public AiProvider provider() { + return AiProvider.ANTHROPIC; + } + +} 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 2ac3ea3e6c..edcf2065eb 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 @@ -16,7 +16,6 @@ package org.thingsboard.rule.engine.ai; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.FutureCallback; import dev.langchain4j.data.message.SystemMessage; @@ -25,7 +24,6 @@ import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.request.ResponseFormat; import dev.langchain4j.model.chat.request.ResponseFormatType; -import dev.langchain4j.model.chat.request.json.JsonSchema; import dev.langchain4j.model.chat.response.ChatResponse; import org.checkerframework.checker.nullness.qual.NonNull; import org.thingsboard.common.util.JacksonUtil; @@ -81,10 +79,13 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { throw new TbNodeException(e, true); } - responseFormat = ResponseFormat.builder() - .type(config.getResponseFormatType()) - .jsonSchema(getJsonSchema(config.getResponseFormatType(), config.getJsonSchema())) - .build(); + // LC4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT + if (config.getResponseFormatType() == ResponseFormatType.JSON) { + responseFormat = ResponseFormat.builder() + .type(config.getResponseFormatType()) + .jsonSchema(config.getJsonSchema() != null ? Langchain4jJsonSchemaAdapter.fromJsonNode(config.getJsonSchema()) : null) + .build(); + } systemPrompt = config.getSystemPrompt(); userPrompt = config.getUserPrompt(); @@ -101,13 +102,6 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { } } - private static JsonSchema getJsonSchema(ResponseFormatType responseFormatType, ObjectNode jsonSchema) { - if (responseFormatType == ResponseFormatType.TEXT) { - return null; - } - return responseFormatType == ResponseFormatType.JSON && jsonSchema != null ? Langchain4jJsonSchemaAdapter.fromJsonNode(jsonSchema) : null; - } - @Override public void onMsg(TbContext ctx, TbMsg msg) { var ackedMsg = ackIfNeeded(ctx, msg); From 4f38422df8748b89f01f612292eb4ce7d34431b5 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 26 Jun 2025 12:59:41 +0300 Subject: [PATCH 057/249] AI rule node: add basic Amazon Bedrock support --- application/pom.xml | 4 ++ .../Langchain4jChatModelConfigurerImpl.java | 35 +++++++++++ .../data/ai/model/AiModelTypeIdResolver.java | 4 ++ .../data/ai/model/chat/AiChatModel.java | 3 +- .../data/ai/model/chat/AiChatModelConfig.java | 3 +- .../ai/model/chat/AmazonBedrockChatModel.java | 60 +++++++++++++++++++ .../chat/Langchain4jChatModelConfigurer.java | 2 + .../common/data/ai/provider/AiProvider.java | 3 +- .../data/ai/provider/AiProviderConfig.java | 6 +- .../provider/AmazonBedrockProviderConfig.java | 25 ++++++++ 10 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java diff --git a/application/pom.xml b/application/pom.xml index e92aeba533..6a71074171 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -405,6 +405,10 @@ dev.langchain4j langchain4j-anthropic + + dev.langchain4j + langchain4j-bedrock + 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 aa4aed988d..404f9eac91 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 @@ -21,10 +21,13 @@ import com.google.cloud.vertexai.Transport; import com.google.cloud.vertexai.VertexAI; import com.google.cloud.vertexai.api.GenerationConfig; import com.google.cloud.vertexai.generativeai.GenerativeModel; +import dev.langchain4j.model.bedrock.BedrockChatModel; import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequestParameters; import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModel; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModel; import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModel; import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModel; @@ -32,7 +35,12 @@ import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChat import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModel; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModel; +import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -138,6 +146,33 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .build(); } + @Override + public ChatModel configureChatModel(AmazonBedrockChatModel chatModel) { + AmazonBedrockProviderConfig providerConfig = chatModel.providerConfig(); + AmazonBedrockChatModel.Config modelConfig = chatModel.modelConfig(); + + var credentialsProvider = StaticCredentialsProvider.create( + AwsBasicCredentials.create(providerConfig.accessKeyId(), providerConfig.secretAccessKey()) + ); + + var bedrockClient = BedrockRuntimeClient.builder() + .region(Region.of(providerConfig.region())) + .credentialsProvider(credentialsProvider) + .build(); + + var defaultChatRequestParams = ChatRequestParameters.builder() + .temperature(modelConfig.temperature()) + .build(); + + return BedrockChatModel.builder() + .client(bedrockClient) + .modelId(modelConfig.modelId()) + .defaultRequestParameters(defaultChatRequestParams) + .timeout(toDuration(modelConfig.timeoutSeconds())) + .maxRetries(modelConfig.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/model/AiModelTypeIdResolver.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java index 0f3634e1f4..3c7766c4ea 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.databind.DatabindContext; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.jsontype.impl.TypeIdResolverBase; +import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModel; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModel; import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModel; import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModel; @@ -86,6 +87,9 @@ public final class AiModelTypeIdResolver extends TypeIdResolverBase { map.put("ANTHROPIC::claude-3-5-haiku-latest", AnthropicChatModel.class); map.put("ANTHROPIC::claude-3-opus-latest", AnthropicChatModel.class); + // Amazon Bedrock models + map.put("AMAZON_BEDROCK::amazon.nova-lite-v1:0", AmazonBedrockChatModel.class); + typeIdToModelClass = Collections.unmodifiableMap(map); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java index 2043efd860..ac0f2226f9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java @@ -22,7 +22,8 @@ import org.thingsboard.server.common.data.ai.model.AiModelType; public sealed interface AiChatModel> extends AiModel permits OpenAiChatModel, AzureOpenAiChatModel, GoogleAiGeminiChatModel, - GoogleVertexAiGeminiChatModel, MistralAiChatModel, AnthropicChatModel { + GoogleVertexAiGeminiChatModel, MistralAiChatModel, AnthropicChatModel, + AmazonBedrockChatModel { ChatModel configure(Langchain4jChatModelConfigurer configurer); 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 d346b22731..e0d7232fa6 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 @@ -20,7 +20,8 @@ import org.thingsboard.server.common.data.ai.model.AiModelConfig; public sealed interface AiChatModelConfig> extends AiModelConfig permits OpenAiChatModel.Config, AzureOpenAiChatModel.Config, GoogleAiGeminiChatModel.Config, - GoogleVertexAiGeminiChatModel.Config, MistralAiChatModel.Config, AnthropicChatModel.Config { + GoogleVertexAiGeminiChatModel.Config, MistralAiChatModel.Config, AnthropicChatModel.Config, + AmazonBedrockChatModel.Config { Double temperature(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java new file mode 100644 index 0000000000..e3cbaf426c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java @@ -0,0 +1,60 @@ +/** + * 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 org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; + +public record AmazonBedrockChatModel( + AmazonBedrockProviderConfig providerConfig, + Config modelConfig +) implements AiChatModel { + + public record Config( + String modelId, + Double temperature, + Integer timeoutSeconds, + Integer maxRetries + ) implements AiChatModelConfig { + + @Override + public AmazonBedrockChatModel.Config withTemperature(Double temperature) { + return new Config(modelId, temperature, timeoutSeconds, maxRetries); + } + + @Override + public AmazonBedrockChatModel.Config withTimeoutSeconds(Integer timeoutSeconds) { + return new Config(modelId, temperature, timeoutSeconds, maxRetries); + } + + @Override + public AmazonBedrockChatModel.Config withMaxRetries(Integer maxRetries) { + return new Config(modelId, temperature, timeoutSeconds, maxRetries); + } + + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public AmazonBedrockChatModel withModelConfig(AmazonBedrockChatModel.Config config) { + return new AmazonBedrockChatModel(providerConfig, config); + } + +} 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 3cb75419f0..d9d4c1dbb6 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 @@ -31,4 +31,6 @@ public interface Langchain4jChatModelConfigurer { ChatModel configureChatModel(AnthropicChatModel chatModel); + ChatModel configureChatModel(AmazonBedrockChatModel chatModel); + } 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 78709fd52c..55b3cbe7f4 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 @@ -22,6 +22,7 @@ public enum AiProvider { GOOGLE_AI_GEMINI, GOOGLE_VERTEX_AI_GEMINI, MISTRAL_AI, - ANTHROPIC + ANTHROPIC, + AMAZON_BEDROCK } 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 e13f3fac7b..890ea197a0 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 @@ -29,12 +29,14 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; @JsonSubTypes.Type(value = GoogleAiGeminiProviderConfig.class, name = "GOOGLE_AI_GEMINI"), @JsonSubTypes.Type(value = GoogleVertexAiGeminiProviderConfig.class, name = "GOOGLE_VERTEX_AI_GEMINI"), @JsonSubTypes.Type(value = MistralAiProviderConfig.class, name = "MISTRAL_AI"), - @JsonSubTypes.Type(value = AnthropicProviderConfig.class, name = "ANTHROPIC") + @JsonSubTypes.Type(value = AnthropicProviderConfig.class, name = "ANTHROPIC"), + @JsonSubTypes.Type(value = AmazonBedrockProviderConfig.class, name = "AMAZON_BEDROCK") }) public sealed interface AiProviderConfig permits OpenAiProviderConfig, AzureOpenAiProviderConfig, GoogleAiGeminiProviderConfig, - GoogleVertexAiGeminiProviderConfig, MistralAiProviderConfig, AnthropicProviderConfig { + GoogleVertexAiGeminiProviderConfig, MistralAiProviderConfig, AnthropicProviderConfig, + AmazonBedrockProviderConfig { AiProvider provider(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java new file mode 100644 index 0000000000..7ecb0bcdb4 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java @@ -0,0 +1,25 @@ +/** + * 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; + +public record AmazonBedrockProviderConfig(String region, String accessKeyId, String secretAccessKey) implements AiProviderConfig { + + @Override + public AiProvider provider() { + return AiProvider.AMAZON_BEDROCK; + } + +} From 4507b312decda4f8756ce9385a2bb79192bf5be1 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 26 Jun 2025 15:02:05 +0300 Subject: [PATCH 058/249] AI rule node: add support for GitHub Models --- application/pom.xml | 4 ++ .../Langchain4jChatModelConfigurerImpl.java | 17 +++++- .../data/ai/model/AiModelTypeIdResolver.java | 4 ++ .../data/ai/model/chat/AiChatModel.java | 2 +- .../data/ai/model/chat/AiChatModelConfig.java | 2 +- .../ai/model/chat/GitHubModelsChatModel.java | 60 +++++++++++++++++++ .../chat/Langchain4jChatModelConfigurer.java | 2 + .../common/data/ai/provider/AiProvider.java | 3 +- .../data/ai/provider/AiProviderConfig.java | 5 +- .../provider/GithubModelsProviderConfig.java | 25 ++++++++ 10 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GithubModelsProviderConfig.java diff --git a/application/pom.xml b/application/pom.xml index 6a71074171..543724fa10 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -409,6 +409,10 @@ dev.langchain4j langchain4j-bedrock + + dev.langchain4j + langchain4j-github-models + 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 404f9eac91..72e9506fdc 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 @@ -15,7 +15,6 @@ */ package org.thingsboard.server.service.ai; -import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.auth.oauth2.ServiceAccountCredentials; import com.google.cloud.vertexai.Transport; import com.google.cloud.vertexai.VertexAI; @@ -30,6 +29,7 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModel; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModel; import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModel; import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModel; import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModel; import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; @@ -91,11 +91,10 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur GoogleVertexAiGeminiChatModel.Config modelConfig = chatModel.modelConfig(); // construct service account credentials using service account key JSON - ObjectNode serviceAccountKeyJson = providerConfig.serviceAccountKey(); ServiceAccountCredentials serviceAccountCredentials; try { serviceAccountCredentials = ServiceAccountCredentials - .fromStream(new ByteArrayInputStream(JacksonUtil.writeValueAsBytes(serviceAccountKeyJson))); + .fromStream(new ByteArrayInputStream(JacksonUtil.writeValueAsBytes(providerConfig.serviceAccountKey()))); } catch (IOException e) { throw new RuntimeException("Failed to parse service account key JSON", e); } @@ -173,6 +172,18 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .build(); } + @Override + public ChatModel configureChatModel(GitHubModelsChatModel chatModel) { + GitHubModelsChatModel.Config modelConfig = chatModel.modelConfig(); + return dev.langchain4j.model.github.GitHubModelsChatModel.builder() + .gitHubToken(chatModel.providerConfig().personalAccessToken()) + .modelName(modelConfig.modelId()) + .temperature(modelConfig.temperature()) + .timeout(toDuration(modelConfig.timeoutSeconds())) + .maxRetries(modelConfig.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/model/AiModelTypeIdResolver.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java index 3c7766c4ea..d4339ccd7b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.jsontype.impl.TypeIdResolverBase; import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModel; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModel; +import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModel; import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModel; import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModel; import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModel; @@ -90,6 +91,9 @@ public final class AiModelTypeIdResolver extends TypeIdResolverBase { // Amazon Bedrock models map.put("AMAZON_BEDROCK::amazon.nova-lite-v1:0", AmazonBedrockChatModel.class); + // GitHub Models models + map.put("GITHUB_MODELS::gpt-4o", GitHubModelsChatModel.class); + typeIdToModelClass = Collections.unmodifiableMap(map); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java index ac0f2226f9..b9a2737b20 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java @@ -23,7 +23,7 @@ public sealed interface AiChatModel> extends AiMo permits OpenAiChatModel, AzureOpenAiChatModel, GoogleAiGeminiChatModel, GoogleVertexAiGeminiChatModel, MistralAiChatModel, AnthropicChatModel, - AmazonBedrockChatModel { + AmazonBedrockChatModel, GitHubModelsChatModel { ChatModel configure(Langchain4jChatModelConfigurer configurer); 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 e0d7232fa6..d5e794cec0 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 @@ -21,7 +21,7 @@ public sealed interface AiChatModelConfig> extend permits OpenAiChatModel.Config, AzureOpenAiChatModel.Config, GoogleAiGeminiChatModel.Config, GoogleVertexAiGeminiChatModel.Config, MistralAiChatModel.Config, AnthropicChatModel.Config, - AmazonBedrockChatModel.Config { + AmazonBedrockChatModel.Config, GitHubModelsChatModel.Config { Double temperature(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java new file mode 100644 index 0000000000..165873e6e3 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java @@ -0,0 +1,60 @@ +/** + * 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 org.thingsboard.server.common.data.ai.provider.GithubModelsProviderConfig; + +public record GitHubModelsChatModel( + GithubModelsProviderConfig providerConfig, + Config modelConfig +) implements AiChatModel { + + public record Config( + String modelId, + Double temperature, + Integer timeoutSeconds, + Integer maxRetries + ) implements AiChatModelConfig { + + @Override + public GitHubModelsChatModel.Config withTemperature(Double temperature) { + return new Config(modelId, temperature, timeoutSeconds, maxRetries); + } + + @Override + public GitHubModelsChatModel.Config withTimeoutSeconds(Integer timeoutSeconds) { + return new Config(modelId, temperature, timeoutSeconds, maxRetries); + } + + @Override + public GitHubModelsChatModel.Config withMaxRetries(Integer maxRetries) { + return new Config(modelId, temperature, timeoutSeconds, maxRetries); + } + + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public GitHubModelsChatModel withModelConfig(GitHubModelsChatModel.Config config) { + return new GitHubModelsChatModel(providerConfig, config); + } + +} 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 d9d4c1dbb6..ea9fb80e73 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 @@ -33,4 +33,6 @@ public interface Langchain4jChatModelConfigurer { ChatModel configureChatModel(AmazonBedrockChatModel chatModel); + ChatModel configureChatModel(GitHubModelsChatModel chatModel); + } 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 55b3cbe7f4..d0a5bd0510 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 @@ -23,6 +23,7 @@ public enum AiProvider { GOOGLE_VERTEX_AI_GEMINI, MISTRAL_AI, ANTHROPIC, - AMAZON_BEDROCK + AMAZON_BEDROCK, + GITHUB_MODELS } 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 890ea197a0..04ca775f7f 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 @@ -30,13 +30,14 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; @JsonSubTypes.Type(value = GoogleVertexAiGeminiProviderConfig.class, name = "GOOGLE_VERTEX_AI_GEMINI"), @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 = AmazonBedrockProviderConfig.class, name = "AMAZON_BEDROCK"), + @JsonSubTypes.Type(value = GithubModelsProviderConfig.class, name = "GITHUB_MODELS") }) public sealed interface AiProviderConfig permits OpenAiProviderConfig, AzureOpenAiProviderConfig, GoogleAiGeminiProviderConfig, GoogleVertexAiGeminiProviderConfig, MistralAiProviderConfig, AnthropicProviderConfig, - AmazonBedrockProviderConfig { + AmazonBedrockProviderConfig, GithubModelsProviderConfig { AiProvider provider(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GithubModelsProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GithubModelsProviderConfig.java new file mode 100644 index 0000000000..afbac79dd1 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GithubModelsProviderConfig.java @@ -0,0 +1,25 @@ +/** + * 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; + +public record GithubModelsProviderConfig(String personalAccessToken) implements AiProviderConfig { + + @Override + public AiProvider provider() { + return AiProvider.GITHUB_MODELS; + } + +} From f2c6fd45d85e24a9246193972623cf49c827fd5a Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 26 Jun 2025 17:46:55 +0300 Subject: [PATCH 059/249] AI rule node: serialize model type to JSON --- .../org/thingsboard/server/common/data/ai/model/AiModel.java | 2 ++ .../common/data/ai/model/chat/AmazonBedrockChatModel.java | 4 +++- .../server/common/data/ai/model/chat/AnthropicChatModel.java | 4 +++- .../common/data/ai/model/chat/AzureOpenAiChatModel.java | 4 +++- .../common/data/ai/model/chat/GitHubModelsChatModel.java | 4 +++- .../common/data/ai/model/chat/GoogleAiGeminiChatModel.java | 4 +++- .../data/ai/model/chat/GoogleVertexAiGeminiChatModel.java | 4 +++- .../server/common/data/ai/model/chat/MistralAiChatModel.java | 4 +++- .../server/common/data/ai/model/chat/OpenAiChatModel.java | 4 +++- 9 files changed, 26 insertions(+), 8 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java index 114fe2a528..f8e3eb6853 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.ai.model; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.databind.annotation.JsonTypeIdResolver; import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; @@ -29,6 +30,7 @@ public interface AiModel> { AiProviderConfig providerConfig(); + @JsonProperty("modelType") AiModelType modelType(); C modelConfig(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java index e3cbaf426c..26eb885078 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java @@ -16,9 +16,11 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; public record AmazonBedrockChatModel( + AiModelType modelType, AmazonBedrockProviderConfig providerConfig, Config modelConfig ) implements AiChatModel { @@ -54,7 +56,7 @@ public record AmazonBedrockChatModel( @Override public AmazonBedrockChatModel withModelConfig(AmazonBedrockChatModel.Config config) { - return new AmazonBedrockChatModel(providerConfig, config); + return new AmazonBedrockChatModel(modelType, providerConfig, config); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java index 329f35382a..93c195ecab 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java @@ -16,9 +16,11 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; public record AnthropicChatModel( + AiModelType modelType, AnthropicProviderConfig providerConfig, Config modelConfig ) implements AiChatModel { @@ -54,7 +56,7 @@ public record AnthropicChatModel( @Override public AnthropicChatModel withModelConfig(AnthropicChatModel.Config config) { - return new AnthropicChatModel(providerConfig, config); + return new AnthropicChatModel(modelType, providerConfig, config); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java index 9920b546f0..70e8b26711 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java @@ -16,9 +16,11 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; public record AzureOpenAiChatModel( + AiModelType modelType, AzureOpenAiProviderConfig providerConfig, Config modelConfig ) implements AiChatModel { @@ -54,7 +56,7 @@ public record AzureOpenAiChatModel( @Override public AzureOpenAiChatModel withModelConfig(AzureOpenAiChatModel.Config config) { - return new AzureOpenAiChatModel(providerConfig, config); + return new AzureOpenAiChatModel(modelType, providerConfig, config); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java index 165873e6e3..828169ec37 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java @@ -16,9 +16,11 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.GithubModelsProviderConfig; public record GitHubModelsChatModel( + AiModelType modelType, GithubModelsProviderConfig providerConfig, Config modelConfig ) implements AiChatModel { @@ -54,7 +56,7 @@ public record GitHubModelsChatModel( @Override public GitHubModelsChatModel withModelConfig(GitHubModelsChatModel.Config config) { - return new GitHubModelsChatModel(providerConfig, config); + return new GitHubModelsChatModel(modelType, providerConfig, config); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java index c09903b305..80b7e46858 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java @@ -16,9 +16,11 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; public record GoogleAiGeminiChatModel( + AiModelType modelType, GoogleAiGeminiProviderConfig providerConfig, Config modelConfig ) implements AiChatModel { @@ -54,7 +56,7 @@ public record GoogleAiGeminiChatModel( @Override public GoogleAiGeminiChatModel withModelConfig(GoogleAiGeminiChatModel.Config config) { - return new GoogleAiGeminiChatModel(providerConfig, config); + return new GoogleAiGeminiChatModel(modelType, providerConfig, config); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java index a340430828..201311de23 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java @@ -16,9 +16,11 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; public record GoogleVertexAiGeminiChatModel( + AiModelType modelType, GoogleVertexAiGeminiProviderConfig providerConfig, Config modelConfig ) implements AiChatModel { @@ -54,7 +56,7 @@ public record GoogleVertexAiGeminiChatModel( @Override public GoogleVertexAiGeminiChatModel withModelConfig(GoogleVertexAiGeminiChatModel.Config config) { - return new GoogleVertexAiGeminiChatModel(providerConfig, config); + return new GoogleVertexAiGeminiChatModel(modelType, providerConfig, config); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java index e4eae1b766..493c0582c6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java @@ -16,9 +16,11 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; public record MistralAiChatModel( + AiModelType modelType, MistralAiProviderConfig providerConfig, Config modelConfig ) implements AiChatModel { @@ -54,7 +56,7 @@ public record MistralAiChatModel( @Override public MistralAiChatModel withModelConfig(Config config) { - return new MistralAiChatModel(providerConfig, config); + return new MistralAiChatModel(modelType, providerConfig, config); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java index a4d7401cbf..3c138d97b7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java @@ -16,9 +16,11 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; public record OpenAiChatModel( + AiModelType modelType, OpenAiProviderConfig providerConfig, Config modelConfig ) implements AiChatModel { @@ -54,7 +56,7 @@ public record OpenAiChatModel( @Override public OpenAiChatModel withModelConfig(OpenAiChatModel.Config config) { - return new OpenAiChatModel(providerConfig, config); + return new OpenAiChatModel(modelType, providerConfig, config); } } From 00a653c29fc887889dcbe3c5c07e9e8c4a1e2198 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 27 Jun 2025 14:56:48 +0300 Subject: [PATCH 060/249] AI rule node: use `Map.ofEntries()` in model type resolver --- .../data/ai/model/AiModelTypeIdResolver.java | 124 ++++++++---------- 1 file changed, 58 insertions(+), 66 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java index d4339ccd7b..ff31515096 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java @@ -27,75 +27,67 @@ import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChat import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModel; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModel; -import java.util.Collections; -import java.util.HashMap; import java.util.Map; public final class AiModelTypeIdResolver extends TypeIdResolverBase { - private static final Map> typeIdToModelClass; - - static { - Map> map = new HashMap<>(); - - // OpenAI models - map.put("OPENAI::o4-mini", OpenAiChatModel.class); - // map.put("OPENAI::o3-pro", OpenAiChatModel.class); // needs verification with Gov ID :) - // map.put("OPENAI::o3", OpenAiChatModel.class); // needs verification with Gov ID :) - map.put("OPENAI::o3-mini", OpenAiChatModel.class); - // map.put("OPENAI::o1-pro", OpenAiChatModel.class); // LC4j sends requests to v1/chat/completions, but o1-pro is only supported in v1/responses - map.put("OPENAI::o1", OpenAiChatModel.class); - map.put("OPENAI::gpt-4.1", OpenAiChatModel.class); - map.put("OPENAI::gpt-4.1-mini", OpenAiChatModel.class); - map.put("OPENAI::gpt-4.1-nano", OpenAiChatModel.class); - map.put("OPENAI::gpt-4o", OpenAiChatModel.class); - map.put("OPENAI::gpt-4o-mini", OpenAiChatModel.class); - - // Google AI Gemini models - map.put("GOOGLE_AI_GEMINI::gemini-2.5-pro", GoogleAiGeminiChatModel.class); - map.put("GOOGLE_AI_GEMINI::gemini-2.5-flash", GoogleAiGeminiChatModel.class); - map.put("GOOGLE_AI_GEMINI::gemini-2.0-flash", GoogleAiGeminiChatModel.class); - map.put("GOOGLE_AI_GEMINI::gemini-2.0-flash-lite", GoogleAiGeminiChatModel.class); - map.put("GOOGLE_AI_GEMINI::gemini-1.5-pro", GoogleAiGeminiChatModel.class); - map.put("GOOGLE_AI_GEMINI::gemini-1.5-flash", GoogleAiGeminiChatModel.class); - map.put("GOOGLE_AI_GEMINI::gemini-1.5-flash-8b", GoogleAiGeminiChatModel.class); - - // Google Vertex AI Gemini models - map.put("GOOGLE_VERTEX_AI_GEMINI::gemini-2.5-pro", GoogleVertexAiGeminiChatModel.class); - map.put("GOOGLE_VERTEX_AI_GEMINI::gemini-2.5-flash", GoogleVertexAiGeminiChatModel.class); - map.put("GOOGLE_VERTEX_AI_GEMINI::gemini-2.0-flash", GoogleVertexAiGeminiChatModel.class); - map.put("GOOGLE_VERTEX_AI_GEMINI::gemini-2.0-flash-lite", GoogleVertexAiGeminiChatModel.class); - map.put("GOOGLE_VERTEX_AI_GEMINI::gemini-1.5-pro", GoogleVertexAiGeminiChatModel.class); - map.put("GOOGLE_VERTEX_AI_GEMINI::gemini-1.5-flash", GoogleVertexAiGeminiChatModel.class); - map.put("GOOGLE_VERTEX_AI_GEMINI::gemini-1.5-flash-8b", GoogleVertexAiGeminiChatModel.class); - - // Mistral AI models - map.put("MISTRAL_AI::magistral-medium-latest", MistralAiChatModel.class); - map.put("MISTRAL_AI::magistral-small-latest", MistralAiChatModel.class); - map.put("MISTRAL_AI::mistral-large-latest", MistralAiChatModel.class); - map.put("MISTRAL_AI::mistral-medium-latest", MistralAiChatModel.class); - map.put("MISTRAL_AI::mistral-small-latest", MistralAiChatModel.class); - map.put("MISTRAL_AI::pixtral-large-latest", MistralAiChatModel.class); - map.put("MISTRAL_AI::ministral-8b-latest", MistralAiChatModel.class); - map.put("MISTRAL_AI::ministral-3b-latest", MistralAiChatModel.class); - map.put("MISTRAL_AI::open-mistral-nemo", MistralAiChatModel.class); - - // Anthropic models - map.put("ANTHROPIC::claude-opus-4-0", AnthropicChatModel.class); - map.put("ANTHROPIC::claude-sonnet-4-0", AnthropicChatModel.class); - map.put("ANTHROPIC::claude-3-7-sonnet-latest", AnthropicChatModel.class); - map.put("ANTHROPIC::claude-3-5-sonnet-latest", AnthropicChatModel.class); - map.put("ANTHROPIC::claude-3-5-haiku-latest", AnthropicChatModel.class); - map.put("ANTHROPIC::claude-3-opus-latest", AnthropicChatModel.class); - - // Amazon Bedrock models - map.put("AMAZON_BEDROCK::amazon.nova-lite-v1:0", AmazonBedrockChatModel.class); - - // GitHub Models models - map.put("GITHUB_MODELS::gpt-4o", GitHubModelsChatModel.class); - - typeIdToModelClass = Collections.unmodifiableMap(map); - } + private static final Map> typeIdToModelClass = Map.ofEntries( + // OpenAI models + Map.entry("OPENAI::o4-mini", OpenAiChatModel.class), + // Map.entry("OPENAI::o3-pro", OpenAiChatModel.class); // needs verification with Gov ID :) + // Map.entry("OPENAI::o3", OpenAiChatModel.class); // needs verification with Gov ID :) + Map.entry("OPENAI::o3-mini", OpenAiChatModel.class), + // Map.entry("OPENAI::o1-pro", OpenAiChatModel.class); // LC4j sends requests to v1/chat/completions, but o1-pro is only supported in v1/responses + Map.entry("OPENAI::o1", OpenAiChatModel.class), + Map.entry("OPENAI::gpt-4.1", OpenAiChatModel.class), + Map.entry("OPENAI::gpt-4.1-mini", OpenAiChatModel.class), + Map.entry("OPENAI::gpt-4.1-nano", OpenAiChatModel.class), + Map.entry("OPENAI::gpt-4o", OpenAiChatModel.class), + Map.entry("OPENAI::gpt-4o-mini", OpenAiChatModel.class), + + // Google AI Gemini models + Map.entry("GOOGLE_AI_GEMINI::gemini-2.5-pro", GoogleAiGeminiChatModel.class), + Map.entry("GOOGLE_AI_GEMINI::gemini-2.5-flash", GoogleAiGeminiChatModel.class), + Map.entry("GOOGLE_AI_GEMINI::gemini-2.0-flash", GoogleAiGeminiChatModel.class), + Map.entry("GOOGLE_AI_GEMINI::gemini-2.0-flash-lite", GoogleAiGeminiChatModel.class), + Map.entry("GOOGLE_AI_GEMINI::gemini-1.5-pro", GoogleAiGeminiChatModel.class), + Map.entry("GOOGLE_AI_GEMINI::gemini-1.5-flash", GoogleAiGeminiChatModel.class), + Map.entry("GOOGLE_AI_GEMINI::gemini-1.5-flash-8b", GoogleAiGeminiChatModel.class), + + // Google Vertex AI Gemini models + Map.entry("GOOGLE_VERTEX_AI_GEMINI::gemini-2.5-pro", GoogleVertexAiGeminiChatModel.class), + Map.entry("GOOGLE_VERTEX_AI_GEMINI::gemini-2.5-flash", GoogleVertexAiGeminiChatModel.class), + Map.entry("GOOGLE_VERTEX_AI_GEMINI::gemini-2.0-flash", GoogleVertexAiGeminiChatModel.class), + Map.entry("GOOGLE_VERTEX_AI_GEMINI::gemini-2.0-flash-lite", GoogleVertexAiGeminiChatModel.class), + Map.entry("GOOGLE_VERTEX_AI_GEMINI::gemini-1.5-pro", GoogleVertexAiGeminiChatModel.class), + Map.entry("GOOGLE_VERTEX_AI_GEMINI::gemini-1.5-flash", GoogleVertexAiGeminiChatModel.class), + Map.entry("GOOGLE_VERTEX_AI_GEMINI::gemini-1.5-flash-8b", GoogleVertexAiGeminiChatModel.class), + + // Mistral AI models + Map.entry("MISTRAL_AI::magistral-medium-latest", MistralAiChatModel.class), + Map.entry("MISTRAL_AI::magistral-small-latest", MistralAiChatModel.class), + Map.entry("MISTRAL_AI::mistral-large-latest", MistralAiChatModel.class), + Map.entry("MISTRAL_AI::mistral-medium-latest", MistralAiChatModel.class), + Map.entry("MISTRAL_AI::mistral-small-latest", MistralAiChatModel.class), + Map.entry("MISTRAL_AI::pixtral-large-latest", MistralAiChatModel.class), + Map.entry("MISTRAL_AI::ministral-8b-latest", MistralAiChatModel.class), + Map.entry("MISTRAL_AI::ministral-3b-latest", MistralAiChatModel.class), + Map.entry("MISTRAL_AI::open-mistral-nemo", MistralAiChatModel.class), + + // Anthropic models + Map.entry("ANTHROPIC::claude-opus-4-0", AnthropicChatModel.class), + Map.entry("ANTHROPIC::claude-sonnet-4-0", AnthropicChatModel.class), + Map.entry("ANTHROPIC::claude-3-7-sonnet-latest", AnthropicChatModel.class), + Map.entry("ANTHROPIC::claude-3-5-sonnet-latest", AnthropicChatModel.class), + Map.entry("ANTHROPIC::claude-3-5-haiku-latest", AnthropicChatModel.class), + Map.entry("ANTHROPIC::claude-3-opus-latest", AnthropicChatModel.class), + + // Amazon Bedrock models + Map.entry("AMAZON_BEDROCK::amazon.nova-lite-v1:0", AmazonBedrockChatModel.class), + + // GitHub Models models + Map.entry("GITHUB_MODELS::gpt-4o", GitHubModelsChatModel.class) + ); private JavaType baseType; @@ -117,7 +109,7 @@ public final class AiModelTypeIdResolver extends TypeIdResolverBase { @Override public JavaType typeFromId(DatabindContext context, String id) { Class modelClass = typeIdToModelClass.get(id); - if (modelClass == null) { + if (modelClass == null) { // TODO: if provider is unknown - throw, if provider is valid but model is unknown - fallback to default model throw new IllegalArgumentException("Unknown model type ID: " + id); } return context.constructSpecializedType(baseType, modelClass); From d732e31370b1ea39886b3d698d57a103ddae1a8f Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 27 Jun 2025 14:58:08 +0300 Subject: [PATCH 061/249] AI rule node: support sorting on provider and model ID --- .../controller/AiModelSettingsController.java | 26 +++------ .../org/thingsboard/server/dao/DaoUtil.java | 23 ++++---- .../dao/ai/AiModelSettingsServiceImpl.java | 3 +- .../dao/model/sql/AiModelSettingsEntity.java | 12 +++- .../server/dao/service/Validator.java | 56 ++++++++++++++++--- .../dao/sql/ai/JpaAiModelSettingsDao.java | 24 ++++++-- 6 files changed, 101 insertions(+), 43 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java b/application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java index 69c3181e26..9659be1941 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java @@ -35,7 +35,6 @@ import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; import java.util.Optional; -import java.util.Set; import java.util.UUID; import static org.thingsboard.server.controller.ControllerConstants.AI_MODEL_SETTINGS_TEXT_SEARCH_DESCRIPTION; @@ -48,9 +47,7 @@ import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHO @RestController @RequestMapping("/api/ai-model-settings") -public class AiModelSettingsController extends BaseController { - - private static final Set ALLOWED_SORT_PROPERTIES = Set.of("createdTime", "name"); +class AiModelSettingsController extends BaseController { @ApiOperation( value = "Create or update AI model settings (saveAiModelSettings)", @@ -75,16 +72,16 @@ public class AiModelSettingsController extends BaseController { TENANT_AUTHORITY_PARAGRAPH ) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping("/{aiModelSettingsId}") + @GetMapping("/{settingsUuid}") public AiModelSettings getAiModelSettingsById( @Parameter( description = "ID of the AI model settings record", required = true, example = "de7900d4-30e2-11f0-9cd2-0242ac120002" ) - @PathVariable("aiModelSettingsId") UUID aiModelSettingsUuid + @PathVariable UUID settingsUuid ) throws ThingsboardException { - return checkAiModelSettingsId(new AiModelSettingsId(aiModelSettingsUuid), Operation.READ); + return checkAiModelSettingsId(new AiModelSettingsId(settingsUuid), Operation.READ); } @ApiOperation( @@ -101,24 +98,17 @@ public class AiModelSettingsController extends BaseController { @RequestParam int page, @Parameter(description = AI_MODEL_SETTINGS_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch, - @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"})) + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name", "provider", "modelId"})) @RequestParam(required = false) String sortProperty, @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @RequestParam(required = false) String sortOrder ) throws ThingsboardException { var user = getCurrentUser(); accessControlService.checkPermission(user, Resource.AI_MODEL_SETTINGS, Operation.READ); - validateSortProperty(sortProperty); var pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); return aiModelSettingsService.findAiModelSettingsByTenantId(user.getTenantId(), pageLink); } - private static void validateSortProperty(String sortProperty) { - if (sortProperty != null && !ALLOWED_SORT_PROPERTIES.contains(sortProperty)) { - throw new IllegalArgumentException("Unsupported sort property '" + sortProperty + "'! Only '" + String.join("', '", ALLOWED_SORT_PROPERTIES) + "' are allowed."); - } - } - @ApiOperation( value = "Delete AI model settings by ID (deleteAiModelSettingsById)", notes = "Deletes the AI model settings record by its `id`. " + @@ -127,17 +117,17 @@ public class AiModelSettingsController extends BaseController { TENANT_AUTHORITY_PARAGRAPH ) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @DeleteMapping("/{aiModelSettingsId}") + @DeleteMapping("/{settingsUuid}") public boolean deleteAiModelSettingsById( @Parameter( description = "ID of the AI model settings record", required = true, example = "de7900d4-30e2-11f0-9cd2-0242ac120002" ) - @PathVariable("aiModelSettingsId") UUID aiModelSettingsUuid + @PathVariable UUID settingsUuid ) throws ThingsboardException { var user = getCurrentUser(); - var settingsId = new AiModelSettingsId(aiModelSettingsUuid); + var settingsId = new AiModelSettingsId(settingsUuid); accessControlService.checkPermission(user, Resource.AI_MODEL_SETTINGS, Operation.DELETE); Optional toDelete = aiModelSettingsService.findAiModelSettingsByTenantIdAndId(user.getTenantId(), settingsId); if (toDelete.isEmpty()) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java b/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java index 43b651bb24..7b158fe9a0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java +++ b/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java @@ -43,10 +43,9 @@ import java.util.function.Consumer; import java.util.function.Function; import java.util.stream.Collectors; -public abstract class DaoUtil { +public final class DaoUtil { - private DaoUtil() { - } + private DaoUtil() {} public static PageData toPageData(Page> page) { List data = convertDataList(page.getContent()); @@ -98,17 +97,17 @@ public abstract class DaoUtil { return PageRequest.of(pageLink.getPage(), pageLink.getPageSize(), pageLink.toSort(sortOrders, columnMap, addDefaultSorting)); } - public static List convertDataList(Collection> toDataList) { - List list = Collections.emptyList(); - if (toDataList != null && !toDataList.isEmpty()) { - list = new ArrayList<>(); - for (ToData object : toDataList) { - if (object != null) { - list.add(object.toData()); - } + public static List convertDataList(Collection> toConvert) { + if (CollectionUtils.isEmpty(toConvert)) { + return Collections.emptyList(); + } + List converted = new ArrayList<>(toConvert.size()); + for (ToData object : toConvert) { + if (object != null) { + converted.add(object.toData()); } } - return list; + return converted; } public static T getData(ToData data) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java index 3875169305..411a67ca98 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java @@ -32,6 +32,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.entity.CachedVersionedEntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; +import org.thingsboard.server.dao.model.sql.AiModelSettingsEntity; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.sql.JpaExecutorService; @@ -92,7 +93,7 @@ class AiModelSettingsServiceImpl extends CachedVersionedEntityService findAiModelSettingsByTenantId(TenantId tenantId, PageLink pageLink) { - validatePageLink(pageLink); + validatePageLink(pageLink, AiModelSettingsEntity.ALLOWED_SORT_PROPERTIES); return aiModelSettingsDao.findAllByTenantId(tenantId, pageLink); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelSettingsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelSettingsEntity.java index 7d27a44cd1..2a866c8620 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelSettingsEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelSettingsEntity.java @@ -31,8 +31,12 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.model.BaseVersionedEntity; import org.thingsboard.server.dao.model.ModelConstants; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.UUID; @Getter @@ -43,7 +47,13 @@ import java.util.UUID; public class AiModelSettingsEntity extends BaseVersionedEntity { public static final Map COLUMN_MAP = Map.of( - "createdTime", "created_time" + "createdTime", "created_time", + "provider", "(configuration -> 'providerConfig' ->> 'provider')", + "modelId", "(configuration -> 'modelConfig' ->> 'modelId')" + ); + + public static final Set ALLOWED_SORT_PROPERTIES = Collections.unmodifiableSet( + new LinkedHashSet<>(List.of("createdTime", "name", "provider", "modelId")) ); @Column(name = ModelConstants.AI_MODEL_SETTINGS_TENANT_ID_COLUMN_NAME, nullable = false, columnDefinition = "UUID") diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/Validator.java b/dao/src/main/java/org/thingsboard/server/dao/service/Validator.java index e051e99dc5..f94026ce89 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/Validator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/Validator.java @@ -26,11 +26,14 @@ import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.dao.exception.IncorrectParameterException; import java.util.List; +import java.util.Set; import java.util.UUID; import java.util.function.Function; import java.util.regex.Pattern; -public class Validator { +public final class Validator { + + private Validator() {} public static final Pattern PROPERTY_PATTERN = Pattern.compile("^[\\p{L}0-9_-]+$"); // Unicode letters, numbers, '_' and '-' allowed @@ -204,22 +207,61 @@ public class Validator { } /** - * This method validate PageLink page link. If pageLink is invalid than throw - * IncorrectParameterException exception + * Validates the specified PageLink object delegating to {@link #validatePageLink(PageLink, Set)} + * with no restrictions on allowed sort properties. * - * @param pageLink the page link + * @param pageLink the PageLink object to validate + * @throws IncorrectParameterException if the pageLink is null, has invalid page size, + * invalid page number, or invalid sort property + * @see #validatePageLink(PageLink, Set) */ public static void validatePageLink(PageLink pageLink) { + validatePageLink(pageLink, null); + } + + /** + * Validates the specified PageLink object ensuring that: + *
    + *
  • The PageLink object is not null
  • + *
  • The page size is greater than zero
  • + *
  • The page number is non-negative
  • + *
  • If sorting is specified, the sort property is valid and allowed
  • + *
+ * + *

When {@code allowedSortProperties} is provided, the sort property + * must be contained within this set. If {@code allowedSortProperties} is null, + * only basic sort property validation is performed. + * + * @param pageLink the PageLink object to validate. + * @param allowedSortProperties a Set of allowed sort property names, or null to skip + * this validation. If provided and the PageLink contains + * a sort order, the sort property must be in this set. + * @throws IncorrectParameterException if any of the following conditions are met: + *

    + *
  • {@code pageLink} is null
  • + *
  • page size is less than 1
  • + *
  • page number is negative
  • + *
  • sort property is malformed
  • + *
  • sort property is not in the {@code allowedSortProperties} set (when the set is provided and not null)
  • + *
+ */ + public static void validatePageLink(PageLink pageLink, Set allowedSortProperties) { if (pageLink == null) { throw new IncorrectParameterException("Page link must be specified."); } else if (pageLink.getPageSize() < 1) { - throw new IncorrectParameterException("Incorrect page link page size '"+pageLink.getPageSize()+"'. Page size must be greater than zero."); + throw new IncorrectParameterException("Incorrect page link page size '" + pageLink.getPageSize() + "'. Page size must be greater than zero."); } else if (pageLink.getPage() < 0) { - throw new IncorrectParameterException("Incorrect page link page '"+pageLink.getPage()+"'. Page must be positive integer."); + throw new IncorrectParameterException("Incorrect page link page '" + pageLink.getPage() + "'. Page must be positive integer."); } else if (pageLink.getSortOrder() != null) { - if (!isValidProperty(pageLink.getSortOrder().getProperty())) { + String sortProperty = pageLink.getSortOrder().getProperty(); + if (!isValidProperty(sortProperty)) { throw new IncorrectParameterException("Invalid page link sort property"); } + if (allowedSortProperties != null && !allowedSortProperties.contains(sortProperty)) { + throw new IncorrectParameterException( + "Unsupported sort property '" + sortProperty + "'. Only '" + String.join("', '", allowedSortProperties) + "' are allowed." + ); + } } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelSettingsDao.java index e7b87b0a1a..a09179d31e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelSettingsDao.java @@ -17,6 +17,9 @@ package org.thingsboard.server.dao.sql.ai; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.JpaSort; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntityType; @@ -25,6 +28,7 @@ import org.thingsboard.server.common.data.id.AiModelSettingsId; 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.page.SortOrder; import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.ai.AiModelSettingsDao; import org.thingsboard.server.dao.model.sql.AiModelSettingsEntity; @@ -65,15 +69,27 @@ class JpaAiModelSettingsDao extends JpaAbstractDao findByTenantId(UUID tenantId, PageLink pageLink) { return DaoUtil.toPageData(aiModelSettingsRepository.findByTenantId( - tenantId, StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), DaoUtil.toPageable(pageLink, AiModelSettingsEntity.COLUMN_MAP)) + tenantId, StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), toPageRequest(pageLink)) ); } @Override public PageData findIdsByTenantId(UUID tenantId, PageLink pageLink) { - return DaoUtil.pageToPageData( - aiModelSettingsRepository.findIdsByTenantId(tenantId, DaoUtil.toPageable(pageLink, AiModelSettingsEntity.COLUMN_MAP)).map(AiModelSettingsId::new) - ); + return DaoUtil.pageToPageData(aiModelSettingsRepository.findIdsByTenantId(tenantId, toPageRequest(pageLink)).map(AiModelSettingsId::new)); + } + + private static PageRequest toPageRequest(PageLink pageLink) { + Sort sort; + SortOrder sortOrder = pageLink.getSortOrder(); + if (sortOrder == null) { + sort = Sort.by(Sort.Direction.ASC, "id"); + } else { + sort = JpaSort.unsafe( + Sort.Direction.fromString(sortOrder.getDirection().name()), + AiModelSettingsEntity.COLUMN_MAP.getOrDefault(sortOrder.getProperty(), sortOrder.getProperty()) + ); + } + return PageRequest.of(pageLink.getPage(), pageLink.getPageSize(), sort); } @Override From 3f2a6440a75b4a0a7852ba162c4eed5d920abd48 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 27 Jun 2025 15:48:24 +0300 Subject: [PATCH 062/249] AI rule node: support unknown models --- .../data/ai/model/AiModelTypeIdResolver.java | 28 +++++++++++++-- .../common/data/ai/provider/AiProvider.java | 35 ++++++++++++++----- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java index ff31515096..96a5a70390 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelTypeIdResolver.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModel; import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModel; import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModel; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModel; +import org.thingsboard.server.common.data.ai.provider.AiProvider; import java.util.Map; @@ -89,6 +90,8 @@ public final class AiModelTypeIdResolver extends TypeIdResolverBase { Map.entry("GITHUB_MODELS::gpt-4o", GitHubModelsChatModel.class) ); + private static final String PROVIDER_MODEL_SEPARATOR = "::"; + private JavaType baseType; @Override @@ -109,9 +112,28 @@ public final class AiModelTypeIdResolver extends TypeIdResolverBase { @Override public JavaType typeFromId(DatabindContext context, String id) { Class modelClass = typeIdToModelClass.get(id); - if (modelClass == null) { // TODO: if provider is unknown - throw, if provider is valid but model is unknown - fallback to default model - throw new IllegalArgumentException("Unknown model type ID: " + id); + if (modelClass != null) { // known model + return context.constructSpecializedType(baseType, modelClass); + } + + String[] parts = id.split(PROVIDER_MODEL_SEPARATOR, 2); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid model type ID format: " + id + ". Expected format: PROVIDER::MODEL_ID"); + } + + String providerName = parts[0]; + + // Check if the provider exists + AiProvider provider; + try { + provider = AiProvider.valueOf(providerName); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Unknown AI provider: " + providerName); } + + // Provider is valid but model is unknown - fallback to default model class + modelClass = provider.getDefaultModelClass(); + return context.constructSpecializedType(baseType, modelClass); } @@ -123,7 +145,7 @@ public final class AiModelTypeIdResolver extends TypeIdResolverBase { private static String generateId(AiModel model) { String provider = model.providerConfig().provider().name(); String modelId = model.modelConfig().modelId(); - return provider + "::" + modelId; + return provider + PROVIDER_MODEL_SEPARATOR + modelId; } } 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..f4ae843447 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 @@ -15,15 +15,34 @@ */ package org.thingsboard.server.common.data.ai.provider; +import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModel; +import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModel; +import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModel; +import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModel; + public enum AiProvider { - OPENAI, - AZURE_OPENAI, - GOOGLE_AI_GEMINI, - GOOGLE_VERTEX_AI_GEMINI, - MISTRAL_AI, - ANTHROPIC, - AMAZON_BEDROCK, - GITHUB_MODELS + OPENAI(OpenAiChatModel.class), + AZURE_OPENAI(AzureOpenAiChatModel.class), + GOOGLE_AI_GEMINI(GoogleAiGeminiChatModel.class), + GOOGLE_VERTEX_AI_GEMINI(GoogleVertexAiGeminiChatModel.class), + MISTRAL_AI(MistralAiChatModel.class), + ANTHROPIC(AnthropicChatModel.class), + AMAZON_BEDROCK(AmazonBedrockChatModel.class), + GITHUB_MODELS(GitHubModelsChatModel.class); + + private final Class defaultModelClass; + + AiProvider(Class defaultModelClass) { + this.defaultModelClass = defaultModelClass; + } + + public Class getDefaultModelClass() { + return defaultModelClass; + } } From 3f58ff01c3edfdb0f7ad629b270098503e097f9b Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 27 Jun 2025 16:37:10 +0300 Subject: [PATCH 063/249] AI rule node: support top N for all models --- .../Langchain4jChatModelConfigurerImpl.java | 10 +++++++ .../data/ai/model/chat/AiChatModelConfig.java | 4 --- .../ai/model/chat/AmazonBedrockChatModel.java | 29 ++++--------------- .../ai/model/chat/AnthropicChatModel.java | 29 ++++--------------- .../ai/model/chat/AzureOpenAiChatModel.java | 29 ++++--------------- .../ai/model/chat/GitHubModelsChatModel.java | 29 ++++--------------- .../model/chat/GoogleAiGeminiChatModel.java | 29 ++++--------------- .../chat/GoogleVertexAiGeminiChatModel.java | 29 ++++--------------- .../ai/model/chat/MistralAiChatModel.java | 29 ++++--------------- .../data/ai/model/chat/OpenAiChatModel.java | 29 ++++--------------- 10 files changed, 50 insertions(+), 196 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 72e9506fdc..97863a0a4a 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 @@ -56,6 +56,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .apiKey(chatModel.providerConfig().apiKey()) .modelName(modelConfig.modelId()) .temperature(modelConfig.temperature()) + .topP(modelConfig.topP()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -68,6 +69,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .apiKey(chatModel.providerConfig().apiKey()) .deploymentName(modelConfig.modelId()) .temperature(modelConfig.temperature()) + .topP(modelConfig.topP()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -80,6 +82,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .apiKey(chatModel.providerConfig().apiKey()) .modelName(modelConfig.modelId()) .temperature(modelConfig.temperature()) + .topP(modelConfig.topP()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -112,6 +115,9 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur if (modelConfig.temperature() != null) { generationConfigBuilder.setTemperature(modelConfig.temperature().floatValue()); } + if (modelConfig.topP() != null) { + generationConfigBuilder.setTopP(modelConfig.topP().floatValue()); + } var generationConfig = generationConfigBuilder.build(); // construct generative model instance @@ -128,6 +134,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .apiKey(chatModel.providerConfig().apiKey()) .modelName(modelConfig.modelId()) .temperature(modelConfig.temperature()) + .topP(modelConfig.topP()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -140,6 +147,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .apiKey(chatModel.providerConfig().apiKey()) .modelName(modelConfig.modelId()) .temperature(modelConfig.temperature()) + .topP(modelConfig.topP()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -161,6 +169,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur var defaultChatRequestParams = ChatRequestParameters.builder() .temperature(modelConfig.temperature()) + .topP(modelConfig.topP()) .build(); return BedrockChatModel.builder() @@ -179,6 +188,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .gitHubToken(chatModel.providerConfig().personalAccessToken()) .modelName(modelConfig.modelId()) .temperature(modelConfig.temperature()) + .topP(modelConfig.topP()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); 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 d5e794cec0..5562c12740 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 @@ -23,14 +23,10 @@ public sealed interface AiChatModelConfig> extend GoogleVertexAiGeminiChatModel.Config, MistralAiChatModel.Config, AnthropicChatModel.Config, AmazonBedrockChatModel.Config, GitHubModelsChatModel.Config { - Double temperature(); - Integer timeoutSeconds(); Integer maxRetries(); - C withTemperature(Double temperature); - C withTimeoutSeconds(Integer timeoutSeconds); C withMaxRetries(Integer maxRetries); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java index 26eb885078..7d38d6e721 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java @@ -16,47 +16,28 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import lombok.With; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; public record AmazonBedrockChatModel( AiModelType modelType, AmazonBedrockProviderConfig providerConfig, - Config modelConfig + @With Config modelConfig ) implements AiChatModel { + @With public record Config( String modelId, Double temperature, + Double topP, Integer timeoutSeconds, Integer maxRetries - ) implements AiChatModelConfig { - - @Override - public AmazonBedrockChatModel.Config withTemperature(Double temperature) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - @Override - public AmazonBedrockChatModel.Config withTimeoutSeconds(Integer timeoutSeconds) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - @Override - public AmazonBedrockChatModel.Config withMaxRetries(Integer maxRetries) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - } + ) implements AiChatModelConfig {} @Override public ChatModel configure(Langchain4jChatModelConfigurer configurer) { return configurer.configureChatModel(this); } - @Override - public AmazonBedrockChatModel withModelConfig(AmazonBedrockChatModel.Config config) { - return new AmazonBedrockChatModel(modelType, providerConfig, config); - } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java index 93c195ecab..a15a3f9078 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java @@ -16,47 +16,28 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import lombok.With; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; public record AnthropicChatModel( AiModelType modelType, AnthropicProviderConfig providerConfig, - Config modelConfig + @With Config modelConfig ) implements AiChatModel { + @With public record Config( String modelId, Double temperature, + Double topP, Integer timeoutSeconds, Integer maxRetries - ) implements AiChatModelConfig { - - @Override - public AnthropicChatModel.Config withTemperature(Double temperature) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - @Override - public AnthropicChatModel.Config withTimeoutSeconds(Integer timeoutSeconds) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - @Override - public AnthropicChatModel.Config withMaxRetries(Integer maxRetries) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - } + ) implements AiChatModelConfig {} @Override public ChatModel configure(Langchain4jChatModelConfigurer configurer) { return configurer.configureChatModel(this); } - @Override - public AnthropicChatModel withModelConfig(AnthropicChatModel.Config config) { - return new AnthropicChatModel(modelType, providerConfig, config); - } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java index 70e8b26711..6d54975748 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java @@ -16,47 +16,28 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import lombok.With; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; public record AzureOpenAiChatModel( AiModelType modelType, AzureOpenAiProviderConfig providerConfig, - Config modelConfig + @With Config modelConfig ) implements AiChatModel { + @With public record Config( String modelId, Double temperature, + Double topP, Integer timeoutSeconds, Integer maxRetries - ) implements AiChatModelConfig { - - @Override - public AzureOpenAiChatModel.Config withTemperature(Double temperature) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - @Override - public AzureOpenAiChatModel.Config withTimeoutSeconds(Integer timeoutSeconds) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - @Override - public AzureOpenAiChatModel.Config withMaxRetries(Integer maxRetries) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - } + ) implements AiChatModelConfig {} @Override public ChatModel configure(Langchain4jChatModelConfigurer configurer) { return configurer.configureChatModel(this); } - @Override - public AzureOpenAiChatModel withModelConfig(AzureOpenAiChatModel.Config config) { - return new AzureOpenAiChatModel(modelType, providerConfig, config); - } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java index 828169ec37..075473f111 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java @@ -16,47 +16,28 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import lombok.With; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.GithubModelsProviderConfig; public record GitHubModelsChatModel( AiModelType modelType, GithubModelsProviderConfig providerConfig, - Config modelConfig + @With Config modelConfig ) implements AiChatModel { + @With public record Config( String modelId, Double temperature, + Double topP, Integer timeoutSeconds, Integer maxRetries - ) implements AiChatModelConfig { - - @Override - public GitHubModelsChatModel.Config withTemperature(Double temperature) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - @Override - public GitHubModelsChatModel.Config withTimeoutSeconds(Integer timeoutSeconds) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - @Override - public GitHubModelsChatModel.Config withMaxRetries(Integer maxRetries) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - } + ) implements AiChatModelConfig {} @Override public ChatModel configure(Langchain4jChatModelConfigurer configurer) { return configurer.configureChatModel(this); } - @Override - public GitHubModelsChatModel withModelConfig(GitHubModelsChatModel.Config config) { - return new GitHubModelsChatModel(modelType, providerConfig, config); - } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java index 80b7e46858..9b1d6b9e91 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java @@ -16,47 +16,28 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import lombok.With; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; public record GoogleAiGeminiChatModel( AiModelType modelType, GoogleAiGeminiProviderConfig providerConfig, - Config modelConfig + @With Config modelConfig ) implements AiChatModel { + @With public record Config( String modelId, Double temperature, + Double topP, Integer timeoutSeconds, Integer maxRetries - ) implements AiChatModelConfig { - - @Override - public Config withTemperature(Double temperature) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - @Override - public Config withTimeoutSeconds(Integer timeoutSeconds) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - @Override - public Config withMaxRetries(Integer maxRetries) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - } + ) implements AiChatModelConfig {} @Override public ChatModel configure(Langchain4jChatModelConfigurer configurer) { return configurer.configureChatModel(this); } - @Override - public GoogleAiGeminiChatModel withModelConfig(GoogleAiGeminiChatModel.Config config) { - return new GoogleAiGeminiChatModel(modelType, providerConfig, config); - } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java index 201311de23..5659aed918 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java @@ -16,47 +16,28 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import lombok.With; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; public record GoogleVertexAiGeminiChatModel( AiModelType modelType, GoogleVertexAiGeminiProviderConfig providerConfig, - Config modelConfig + @With Config modelConfig ) implements AiChatModel { + @With public record Config( String modelId, Double temperature, + Double topP, Integer timeoutSeconds, // TODO: not supported by Vertex AI Integer maxRetries - ) implements AiChatModelConfig { - - @Override - public Config withTemperature(Double temperature) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - @Override - public Config withTimeoutSeconds(Integer timeoutSeconds) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - @Override - public Config withMaxRetries(Integer maxRetries) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - } + ) implements AiChatModelConfig {} @Override public ChatModel configure(Langchain4jChatModelConfigurer configurer) { return configurer.configureChatModel(this); } - @Override - public GoogleVertexAiGeminiChatModel withModelConfig(GoogleVertexAiGeminiChatModel.Config config) { - return new GoogleVertexAiGeminiChatModel(modelType, providerConfig, config); - } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java index 493c0582c6..b76ce500f4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java @@ -16,47 +16,28 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import lombok.With; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; public record MistralAiChatModel( AiModelType modelType, MistralAiProviderConfig providerConfig, - Config modelConfig + @With Config modelConfig ) implements AiChatModel { + @With public record Config( String modelId, Double temperature, + Double topP, Integer timeoutSeconds, Integer maxRetries - ) implements AiChatModelConfig { - - @Override - public Config withTemperature(Double temperature) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - @Override - public Config withTimeoutSeconds(Integer timeoutSeconds) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - @Override - public Config withMaxRetries(Integer maxRetries) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - } + ) implements AiChatModelConfig {} @Override public ChatModel configure(Langchain4jChatModelConfigurer configurer) { return configurer.configureChatModel(this); } - @Override - public MistralAiChatModel withModelConfig(Config config) { - return new MistralAiChatModel(modelType, providerConfig, config); - } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java index 3c138d97b7..d066ee4d50 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java @@ -16,47 +16,28 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import lombok.With; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; public record OpenAiChatModel( AiModelType modelType, OpenAiProviderConfig providerConfig, - Config modelConfig + @With Config modelConfig ) implements AiChatModel { + @With public record Config( String modelId, Double temperature, + Double topP, Integer timeoutSeconds, Integer maxRetries - ) implements AiChatModelConfig { - - @Override - public OpenAiChatModel.Config withTemperature(Double temperature) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - @Override - public OpenAiChatModel.Config withTimeoutSeconds(Integer timeoutSeconds) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - @Override - public OpenAiChatModel.Config withMaxRetries(Integer maxRetries) { - return new Config(modelId, temperature, timeoutSeconds, maxRetries); - } - - } + ) implements AiChatModelConfig {} @Override public ChatModel configure(Langchain4jChatModelConfigurer configurer) { return configurer.configureChatModel(this); } - @Override - public OpenAiChatModel withModelConfig(OpenAiChatModel.Config config) { - return new OpenAiChatModel(modelType, providerConfig, config); - } - } From afb0259010529e8cebf9441c851cf826062831cb Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 27 Jun 2025 18:41:21 +0300 Subject: [PATCH 064/249] AI rule node: support timeout for Vertex AI --- .../Langchain4jChatModelConfigurerImpl.java | 48 +++++++++++++++++-- .../chat/GoogleVertexAiGeminiChatModel.java | 2 +- 2 files changed, 44 insertions(+), 6 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 97863a0a4a..156949fdc5 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 @@ -15,10 +15,14 @@ */ package org.thingsboard.server.service.ai; +import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.api.gax.retrying.RetrySettings; import com.google.auth.oauth2.ServiceAccountCredentials; import com.google.cloud.vertexai.Transport; import com.google.cloud.vertexai.VertexAI; import com.google.cloud.vertexai.api.GenerationConfig; +import com.google.cloud.vertexai.api.PredictionServiceClient; +import com.google.cloud.vertexai.api.PredictionServiceSettings; import com.google.cloud.vertexai.generativeai.GenerativeModel; import dev.langchain4j.model.bedrock.BedrockChatModel; import dev.langchain4j.model.chat.ChatModel; @@ -96,17 +100,44 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur // construct service account credentials using service account key JSON ServiceAccountCredentials serviceAccountCredentials; try { - serviceAccountCredentials = ServiceAccountCredentials - .fromStream(new ByteArrayInputStream(JacksonUtil.writeValueAsBytes(providerConfig.serviceAccountKey()))); + serviceAccountCredentials = ServiceAccountCredentials.fromStream( + new ByteArrayInputStream(JacksonUtil.writeValueAsBytes(providerConfig.serviceAccountKey())) + ); } catch (IOException e) { throw new RuntimeException("Failed to parse service account key JSON", e); } + PredictionServiceSettings predictionServiceClientSettings; + try { + // create prediction service settings for REST transport with service account key credentials + PredictionServiceSettings.Builder settingsBuilder = PredictionServiceSettings.newHttpJsonBuilder() + .setCredentialsProvider(FixedCredentialsProvider.create(serviceAccountCredentials)); + + // get the retry settings that control request timeout for generateContent RPC + RetrySettings.Builder retrySettings = settingsBuilder + .generateContentSettings() + .getRetrySettings() + .toBuilder(); + + // set request timeout from model config + if (modelConfig.timeoutSeconds() != null) { + retrySettings.setTotalTimeout(org.threeten.bp.Duration.ofSeconds(modelConfig.timeoutSeconds())); + } + + // set updated retry settings + settingsBuilder.generateContentSettings().setRetrySettings(retrySettings.build()); + + // build the client settings + predictionServiceClientSettings = settingsBuilder.build(); + } catch (IOException e) { + throw new RuntimeException("Failed to create prediction service client settings", e); + } + // construct Vertex AI instance var vertexAI = new VertexAI.Builder() .setProjectId(providerConfig.projectId()) .setLocation(providerConfig.location()) - .setCredentials(serviceAccountCredentials) + .setPredictionClientSupplier(() -> createPredictionServiceClient(predictionServiceClientSettings)) .setTransport(Transport.REST) // GRPC also possible, but likely does not work with service account keys .build(); @@ -121,12 +152,19 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur var generationConfig = generationConfigBuilder.build(); // construct generative model instance - var generativeModel = new GenerativeModel(modelConfig.modelId(), vertexAI) - .withGenerationConfig(generationConfig); + var generativeModel = new GenerativeModel(modelConfig.modelId(), vertexAI).withGenerationConfig(generationConfig); return new VertexAiGeminiChatModel(generativeModel, generationConfig, modelConfig.maxRetries()); } + private static PredictionServiceClient createPredictionServiceClient(PredictionServiceSettings settings) { + try { + return PredictionServiceClient.create(settings); + } catch (IOException e) { + throw new RuntimeException("Failed to create prediction service client", e); + } + } + @Override public ChatModel configureChatModel(MistralAiChatModel chatModel) { MistralAiChatModel.Config modelConfig = chatModel.modelConfig(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java index 5659aed918..67a852e33c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java @@ -31,7 +31,7 @@ public record GoogleVertexAiGeminiChatModel( String modelId, Double temperature, Double topP, - Integer timeoutSeconds, // TODO: not supported by Vertex AI + Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} From 72db8f9823e86899e35dd900ec68fa9d34cf9572 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 27 Jun 2025 19:02:44 +0300 Subject: [PATCH 065/249] AI rule node: support top K for Google AI Gemini, Google Vertex AI Gemini and Anthropic --- .../service/ai/Langchain4jChatModelConfigurerImpl.java | 5 +++++ .../server/common/data/ai/model/chat/AnthropicChatModel.java | 1 + .../common/data/ai/model/chat/GoogleAiGeminiChatModel.java | 1 + .../data/ai/model/chat/GoogleVertexAiGeminiChatModel.java | 1 + 4 files changed, 8 insertions(+) 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 156949fdc5..2a9c792b32 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 @@ -87,6 +87,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .modelName(modelConfig.modelId()) .temperature(modelConfig.temperature()) .topP(modelConfig.topP()) + .topK(modelConfig.topK()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -149,6 +150,9 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur if (modelConfig.topP() != null) { generationConfigBuilder.setTopP(modelConfig.topP().floatValue()); } + if (modelConfig.topK() != null) { + generationConfigBuilder.setTopK(modelConfig.topK()); + } var generationConfig = generationConfigBuilder.build(); // construct generative model instance @@ -186,6 +190,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .modelName(modelConfig.modelId()) .temperature(modelConfig.temperature()) .topP(modelConfig.topP()) + .topK(modelConfig.topK()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java index a15a3f9078..ff218d99af 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java @@ -31,6 +31,7 @@ public record AnthropicChatModel( String modelId, Double temperature, Double topP, + Integer topK, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java index 9b1d6b9e91..e5d282ee50 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java @@ -31,6 +31,7 @@ public record GoogleAiGeminiChatModel( String modelId, Double temperature, Double topP, + Integer topK, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java index 67a852e33c..2f5fac23c2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java @@ -31,6 +31,7 @@ public record GoogleVertexAiGeminiChatModel( String modelId, Double temperature, Double topP, + Integer topK, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} From dfe4dea436fde3f5f17876fc167f0d8683476451 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 27 Jun 2025 19:16:13 +0300 Subject: [PATCH 066/249] AI rule node: add frequency penalty for the models that support it --- .../service/ai/Langchain4jChatModelConfigurerImpl.java | 8 ++++++++ .../common/data/ai/model/chat/AzureOpenAiChatModel.java | 1 + .../common/data/ai/model/chat/GitHubModelsChatModel.java | 1 + .../data/ai/model/chat/GoogleAiGeminiChatModel.java | 1 + .../data/ai/model/chat/GoogleVertexAiGeminiChatModel.java | 1 + .../common/data/ai/model/chat/MistralAiChatModel.java | 1 + .../server/common/data/ai/model/chat/OpenAiChatModel.java | 1 + 7 files changed, 14 insertions(+) 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 2a9c792b32..f7e5203e1b 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 @@ -61,6 +61,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .modelName(modelConfig.modelId()) .temperature(modelConfig.temperature()) .topP(modelConfig.topP()) + .frequencyPenalty(modelConfig.frequencyPenalty()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -74,6 +75,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .deploymentName(modelConfig.modelId()) .temperature(modelConfig.temperature()) .topP(modelConfig.topP()) + .frequencyPenalty(modelConfig.frequencyPenalty()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -88,6 +90,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .temperature(modelConfig.temperature()) .topP(modelConfig.topP()) .topK(modelConfig.topK()) + .frequencyPenalty(modelConfig.frequencyPenalty()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -153,6 +156,9 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur if (modelConfig.topK() != null) { generationConfigBuilder.setTopK(modelConfig.topK()); } + if (modelConfig.frequencyPenalty() != null) { + generationConfigBuilder.setFrequencyPenalty(modelConfig.frequencyPenalty().floatValue()); + } var generationConfig = generationConfigBuilder.build(); // construct generative model instance @@ -177,6 +183,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .modelName(modelConfig.modelId()) .temperature(modelConfig.temperature()) .topP(modelConfig.topP()) + .frequencyPenalty(modelConfig.frequencyPenalty()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -232,6 +239,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .modelName(modelConfig.modelId()) .temperature(modelConfig.temperature()) .topP(modelConfig.topP()) + .frequencyPenalty(modelConfig.frequencyPenalty()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java index 6d54975748..97f2d0c3ac 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java @@ -31,6 +31,7 @@ public record AzureOpenAiChatModel( String modelId, Double temperature, Double topP, + Double frequencyPenalty, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java index 075473f111..3730c9aab9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java @@ -31,6 +31,7 @@ public record GitHubModelsChatModel( String modelId, Double temperature, Double topP, + Double frequencyPenalty, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java index e5d282ee50..a0e8ef04ee 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java @@ -32,6 +32,7 @@ public record GoogleAiGeminiChatModel( Double temperature, Double topP, Integer topK, + Double frequencyPenalty, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java index 2f5fac23c2..7acf566b46 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java @@ -32,6 +32,7 @@ public record GoogleVertexAiGeminiChatModel( Double temperature, Double topP, Integer topK, + Double frequencyPenalty, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java index b76ce500f4..04c5e96943 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java @@ -31,6 +31,7 @@ public record MistralAiChatModel( String modelId, Double temperature, Double topP, + Double frequencyPenalty, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java index d066ee4d50..497650fe3c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java @@ -31,6 +31,7 @@ public record OpenAiChatModel( String modelId, Double temperature, Double topP, + Double frequencyPenalty, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} From bc96c63fcfff6d3d8e4bda65e986f68015c46f1c Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 27 Jun 2025 19:27:52 +0300 Subject: [PATCH 067/249] AI rule node: add presence penalty for the models that support it --- .../service/ai/Langchain4jChatModelConfigurerImpl.java | 8 ++++++++ .../common/data/ai/model/chat/AzureOpenAiChatModel.java | 1 + .../common/data/ai/model/chat/GitHubModelsChatModel.java | 1 + .../data/ai/model/chat/GoogleAiGeminiChatModel.java | 1 + .../data/ai/model/chat/GoogleVertexAiGeminiChatModel.java | 1 + .../common/data/ai/model/chat/MistralAiChatModel.java | 1 + .../server/common/data/ai/model/chat/OpenAiChatModel.java | 1 + 7 files changed, 14 insertions(+) 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 f7e5203e1b..c8022e518c 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 @@ -62,6 +62,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .temperature(modelConfig.temperature()) .topP(modelConfig.topP()) .frequencyPenalty(modelConfig.frequencyPenalty()) + .presencePenalty(modelConfig.presencePenalty()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -76,6 +77,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .temperature(modelConfig.temperature()) .topP(modelConfig.topP()) .frequencyPenalty(modelConfig.frequencyPenalty()) + .presencePenalty(modelConfig.presencePenalty()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -91,6 +93,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .topP(modelConfig.topP()) .topK(modelConfig.topK()) .frequencyPenalty(modelConfig.frequencyPenalty()) + .presencePenalty(modelConfig.presencePenalty()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -159,6 +162,9 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur if (modelConfig.frequencyPenalty() != null) { generationConfigBuilder.setFrequencyPenalty(modelConfig.frequencyPenalty().floatValue()); } + if (modelConfig.frequencyPenalty() != null) { + generationConfigBuilder.setPresencePenalty(modelConfig.frequencyPenalty().floatValue()); + } var generationConfig = generationConfigBuilder.build(); // construct generative model instance @@ -184,6 +190,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .temperature(modelConfig.temperature()) .topP(modelConfig.topP()) .frequencyPenalty(modelConfig.frequencyPenalty()) + .presencePenalty(modelConfig.presencePenalty()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -240,6 +247,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .temperature(modelConfig.temperature()) .topP(modelConfig.topP()) .frequencyPenalty(modelConfig.frequencyPenalty()) + .presencePenalty(modelConfig.presencePenalty()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java index 97f2d0c3ac..c6ba4c9b80 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java @@ -32,6 +32,7 @@ public record AzureOpenAiChatModel( Double temperature, Double topP, Double frequencyPenalty, + Double presencePenalty, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java index 3730c9aab9..24ece4ec16 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java @@ -32,6 +32,7 @@ public record GitHubModelsChatModel( Double temperature, Double topP, Double frequencyPenalty, + Double presencePenalty, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java index a0e8ef04ee..ffed1c7de0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java @@ -33,6 +33,7 @@ public record GoogleAiGeminiChatModel( Double topP, Integer topK, Double frequencyPenalty, + Double presencePenalty, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java index 7acf566b46..16744d3b90 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java @@ -33,6 +33,7 @@ public record GoogleVertexAiGeminiChatModel( Double topP, Integer topK, Double frequencyPenalty, + Double presencePenalty, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java index 04c5e96943..c44e7b4ec8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java @@ -32,6 +32,7 @@ public record MistralAiChatModel( Double temperature, Double topP, Double frequencyPenalty, + Double presencePenalty, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java index 497650fe3c..439786abda 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java @@ -32,6 +32,7 @@ public record OpenAiChatModel( Double temperature, Double topP, Double frequencyPenalty, + Double presencePenalty, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} From d81d41fd7bfe5d2f5e6be6a5aa9439c96fdaaea0 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 27 Jun 2025 19:37:12 +0300 Subject: [PATCH 068/249] AI rule node: add max output tokens for all providers --- .../service/ai/Langchain4jChatModelConfigurerImpl.java | 10 ++++++++++ .../data/ai/model/chat/AmazonBedrockChatModel.java | 1 + .../common/data/ai/model/chat/AnthropicChatModel.java | 1 + .../data/ai/model/chat/AzureOpenAiChatModel.java | 1 + .../data/ai/model/chat/GitHubModelsChatModel.java | 1 + .../data/ai/model/chat/GoogleAiGeminiChatModel.java | 1 + .../ai/model/chat/GoogleVertexAiGeminiChatModel.java | 1 + .../common/data/ai/model/chat/MistralAiChatModel.java | 1 + .../common/data/ai/model/chat/OpenAiChatModel.java | 1 + 9 files changed, 18 insertions(+) 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 c8022e518c..fdcea9df18 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 @@ -63,6 +63,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .topP(modelConfig.topP()) .frequencyPenalty(modelConfig.frequencyPenalty()) .presencePenalty(modelConfig.presencePenalty()) + .maxTokens(modelConfig.maxOutputTokens()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -78,6 +79,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .topP(modelConfig.topP()) .frequencyPenalty(modelConfig.frequencyPenalty()) .presencePenalty(modelConfig.presencePenalty()) + .maxTokens(modelConfig.maxOutputTokens()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -94,6 +96,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .topK(modelConfig.topK()) .frequencyPenalty(modelConfig.frequencyPenalty()) .presencePenalty(modelConfig.presencePenalty()) + .maxOutputTokens(modelConfig.maxOutputTokens()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -165,6 +168,9 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur if (modelConfig.frequencyPenalty() != null) { generationConfigBuilder.setPresencePenalty(modelConfig.frequencyPenalty().floatValue()); } + if (modelConfig.maxOutputTokens() != null) { + generationConfigBuilder.setMaxOutputTokens(modelConfig.maxOutputTokens()); + } var generationConfig = generationConfigBuilder.build(); // construct generative model instance @@ -191,6 +197,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .topP(modelConfig.topP()) .frequencyPenalty(modelConfig.frequencyPenalty()) .presencePenalty(modelConfig.presencePenalty()) + .maxTokens(modelConfig.maxOutputTokens()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -205,6 +212,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .temperature(modelConfig.temperature()) .topP(modelConfig.topP()) .topK(modelConfig.topK()) + .maxTokens(modelConfig.maxOutputTokens()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -227,6 +235,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur var defaultChatRequestParams = ChatRequestParameters.builder() .temperature(modelConfig.temperature()) .topP(modelConfig.topP()) + .maxOutputTokens(modelConfig.maxOutputTokens()) .build(); return BedrockChatModel.builder() @@ -248,6 +257,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .topP(modelConfig.topP()) .frequencyPenalty(modelConfig.frequencyPenalty()) .presencePenalty(modelConfig.presencePenalty()) + .maxTokens(modelConfig.maxOutputTokens()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java index 7d38d6e721..cd4a27326b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java @@ -31,6 +31,7 @@ public record AmazonBedrockChatModel( String modelId, Double temperature, Double topP, + Integer maxOutputTokens, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java index ff218d99af..eff282645a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java @@ -32,6 +32,7 @@ public record AnthropicChatModel( Double temperature, Double topP, Integer topK, + Integer maxOutputTokens, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java index c6ba4c9b80..ff59f042c6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java @@ -33,6 +33,7 @@ public record AzureOpenAiChatModel( Double topP, Double frequencyPenalty, Double presencePenalty, + Integer maxOutputTokens, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java index 24ece4ec16..684c8133b7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java @@ -33,6 +33,7 @@ public record GitHubModelsChatModel( Double topP, Double frequencyPenalty, Double presencePenalty, + Integer maxOutputTokens, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java index ffed1c7de0..7618032837 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java @@ -34,6 +34,7 @@ public record GoogleAiGeminiChatModel( Integer topK, Double frequencyPenalty, Double presencePenalty, + Integer maxOutputTokens, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java index 16744d3b90..8755760fe6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java @@ -34,6 +34,7 @@ public record GoogleVertexAiGeminiChatModel( Integer topK, Double frequencyPenalty, Double presencePenalty, + Integer maxOutputTokens, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java index c44e7b4ec8..c896b2c158 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java @@ -33,6 +33,7 @@ public record MistralAiChatModel( Double topP, Double frequencyPenalty, Double presencePenalty, + Integer maxOutputTokens, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java index 439786abda..435e543595 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java @@ -33,6 +33,7 @@ public record OpenAiChatModel( Double topP, Double frequencyPenalty, Double presencePenalty, + Integer maxOutputTokens, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} From 0960b4b1799fef7bbff2933bed4438ad7b20d5ff Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 27 Jun 2025 19:46:37 +0300 Subject: [PATCH 069/249] AI rule node: add stop sequences for all providers --- .../service/ai/Langchain4jChatModelConfigurerImpl.java | 10 ++++++++++ .../data/ai/model/chat/AmazonBedrockChatModel.java | 3 +++ .../common/data/ai/model/chat/AnthropicChatModel.java | 3 +++ .../data/ai/model/chat/AzureOpenAiChatModel.java | 3 +++ .../data/ai/model/chat/GitHubModelsChatModel.java | 3 +++ .../data/ai/model/chat/GoogleAiGeminiChatModel.java | 3 +++ .../ai/model/chat/GoogleVertexAiGeminiChatModel.java | 3 +++ .../common/data/ai/model/chat/MistralAiChatModel.java | 3 +++ .../common/data/ai/model/chat/OpenAiChatModel.java | 3 +++ 9 files changed, 34 insertions(+) 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 fdcea9df18..30942beda7 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 @@ -64,6 +64,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .frequencyPenalty(modelConfig.frequencyPenalty()) .presencePenalty(modelConfig.presencePenalty()) .maxTokens(modelConfig.maxOutputTokens()) + .stop(modelConfig.stopSequences()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -80,6 +81,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .frequencyPenalty(modelConfig.frequencyPenalty()) .presencePenalty(modelConfig.presencePenalty()) .maxTokens(modelConfig.maxOutputTokens()) + .stop(modelConfig.stopSequences()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -97,6 +99,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .frequencyPenalty(modelConfig.frequencyPenalty()) .presencePenalty(modelConfig.presencePenalty()) .maxOutputTokens(modelConfig.maxOutputTokens()) + .stopSequences(modelConfig.stopSequences()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -171,6 +174,9 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur if (modelConfig.maxOutputTokens() != null) { generationConfigBuilder.setMaxOutputTokens(modelConfig.maxOutputTokens()); } + if (modelConfig.stopSequences() != null) { + generationConfigBuilder.addAllStopSequences(modelConfig.stopSequences()); + } var generationConfig = generationConfigBuilder.build(); // construct generative model instance @@ -198,6 +204,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .frequencyPenalty(modelConfig.frequencyPenalty()) .presencePenalty(modelConfig.presencePenalty()) .maxTokens(modelConfig.maxOutputTokens()) + .stopSequences(modelConfig.stopSequences()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -213,6 +220,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .topP(modelConfig.topP()) .topK(modelConfig.topK()) .maxTokens(modelConfig.maxOutputTokens()) + .stopSequences(modelConfig.stopSequences()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); @@ -236,6 +244,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .temperature(modelConfig.temperature()) .topP(modelConfig.topP()) .maxOutputTokens(modelConfig.maxOutputTokens()) + .stopSequences(modelConfig.stopSequences()) .build(); return BedrockChatModel.builder() @@ -258,6 +267,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .frequencyPenalty(modelConfig.frequencyPenalty()) .presencePenalty(modelConfig.presencePenalty()) .maxTokens(modelConfig.maxOutputTokens()) + .stop(modelConfig.stopSequences()) .timeout(toDuration(modelConfig.timeoutSeconds())) .maxRetries(modelConfig.maxRetries()) .build(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java index cd4a27326b..422f646f8f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java @@ -20,6 +20,8 @@ import lombok.With; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; +import java.util.List; + public record AmazonBedrockChatModel( AiModelType modelType, AmazonBedrockProviderConfig providerConfig, @@ -32,6 +34,7 @@ public record AmazonBedrockChatModel( Double temperature, Double topP, Integer maxOutputTokens, + List stopSequences, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java index eff282645a..3258229372 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java @@ -20,6 +20,8 @@ import lombok.With; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; +import java.util.List; + public record AnthropicChatModel( AiModelType modelType, AnthropicProviderConfig providerConfig, @@ -33,6 +35,7 @@ public record AnthropicChatModel( Double topP, Integer topK, Integer maxOutputTokens, + List stopSequences, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java index ff59f042c6..2e783d26d1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java @@ -20,6 +20,8 @@ import lombok.With; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +import java.util.List; + public record AzureOpenAiChatModel( AiModelType modelType, AzureOpenAiProviderConfig providerConfig, @@ -34,6 +36,7 @@ public record AzureOpenAiChatModel( Double frequencyPenalty, Double presencePenalty, Integer maxOutputTokens, + List stopSequences, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java index 684c8133b7..cc981cc5c5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java @@ -20,6 +20,8 @@ import lombok.With; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.GithubModelsProviderConfig; +import java.util.List; + public record GitHubModelsChatModel( AiModelType modelType, GithubModelsProviderConfig providerConfig, @@ -34,6 +36,7 @@ public record GitHubModelsChatModel( Double frequencyPenalty, Double presencePenalty, Integer maxOutputTokens, + List stopSequences, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java index 7618032837..e5fba391b0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java @@ -20,6 +20,8 @@ import lombok.With; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; +import java.util.List; + public record GoogleAiGeminiChatModel( AiModelType modelType, GoogleAiGeminiProviderConfig providerConfig, @@ -35,6 +37,7 @@ public record GoogleAiGeminiChatModel( Double frequencyPenalty, Double presencePenalty, Integer maxOutputTokens, + List stopSequences, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java index 8755760fe6..f0e5834d54 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java @@ -20,6 +20,8 @@ import lombok.With; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +import java.util.List; + public record GoogleVertexAiGeminiChatModel( AiModelType modelType, GoogleVertexAiGeminiProviderConfig providerConfig, @@ -35,6 +37,7 @@ public record GoogleVertexAiGeminiChatModel( Double frequencyPenalty, Double presencePenalty, Integer maxOutputTokens, + List stopSequences, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java index c896b2c158..ac1d644dcc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java @@ -20,6 +20,8 @@ import lombok.With; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; +import java.util.List; + public record MistralAiChatModel( AiModelType modelType, MistralAiProviderConfig providerConfig, @@ -34,6 +36,7 @@ public record MistralAiChatModel( Double frequencyPenalty, Double presencePenalty, Integer maxOutputTokens, + List stopSequences, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java index 435e543595..8a5556b309 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java @@ -20,6 +20,8 @@ import lombok.With; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +import java.util.List; + public record OpenAiChatModel( AiModelType modelType, OpenAiProviderConfig providerConfig, @@ -34,6 +36,7 @@ public record OpenAiChatModel( Double frequencyPenalty, Double presencePenalty, Integer maxOutputTokens, + List stopSequences, Integer timeoutSeconds, Integer maxRetries ) implements AiChatModelConfig {} From 80ebd784d8b11cb2d58d80063354197543d7368e Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 1 Jul 2025 12:18:43 +0300 Subject: [PATCH 070/249] AI rule node: make system prompt optional --- .../java/org/thingsboard/rule/engine/ai/TbAiNode.java | 11 ++++++++--- .../rule/engine/ai/TbAiNodeConfiguration.java | 3 ++- 2 files changed, 10 insertions(+), 4 deletions(-) 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 edcf2065eb..75faf00c04 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,6 +18,7 @@ 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 dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.model.chat.ChatModel; @@ -44,6 +45,7 @@ import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.dao.exception.DataValidationException; +import java.util.ArrayList; import java.util.List; import java.util.NoSuchElementException; import java.util.Optional; @@ -106,11 +108,14 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { public void onMsg(TbContext ctx, TbMsg msg) { var ackedMsg = ackIfNeeded(ctx, msg); - var systemMessage = SystemMessage.from(TbNodeUtils.processPattern(systemPrompt, ackedMsg)); - var userMessage = UserMessage.from(TbNodeUtils.processPattern(userPrompt, ackedMsg)); + List chatMessages = new ArrayList<>(2); + if (systemPrompt != null) { + chatMessages.add(SystemMessage.from(TbNodeUtils.processPattern(systemPrompt, ackedMsg))); + } + chatMessages.add(UserMessage.from(TbNodeUtils.processPattern(userPrompt, ackedMsg))); var chatRequest = ChatRequest.builder() - .messages(List.of(systemMessage, userMessage)) + .messages(chatMessages) .responseFormat(responseFormat) .build(); 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 4c99b6f613..1a6bf935a3 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 @@ -23,6 +23,7 @@ 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.common.util.JsonSchemaUtils; import org.thingsboard.rule.engine.api.NodeConfiguration; @@ -35,7 +36,7 @@ public class TbAiNodeConfiguration implements NodeConfiguration Date: Tue, 1 Jul 2025 14:18:42 +0300 Subject: [PATCH 071/249] AI rule node: use fixed-size thread pool with unbounded queue --- .../server/service/ai/DefaultAiRequestsExecutor.java | 8 +++----- application/src/main/resources/thingsboard.yml | 2 -- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java b/application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java index ae0663eefc..077efa227d 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java @@ -32,10 +32,11 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; import org.springframework.validation.annotation.Validated; -import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.rule.engine.api.AiRequestsExecutor; import java.time.Duration; +import java.util.concurrent.Executors; @Lazy @Component @@ -56,9 +57,6 @@ class DefaultAiRequestsExecutor implements AiRequestsExecutor { @Min(value = 1, message = "Pool size must be at least 1") private int poolSize = 50; - @Min(value = 1, message = "Max queue size must be at least 1") - private int maxQueueSize = 10000; - @Min(value = 1, message = "Termination timeout must be at least 1 second") private int terminationTimeoutSeconds = 60; @@ -69,7 +67,7 @@ class DefaultAiRequestsExecutor implements AiRequestsExecutor { @PostConstruct private void init() { executorService = MoreExecutors.listeningDecorator( - ThingsBoardExecutors.newLimitedTasksExecutor(properties.getPoolSize(), properties.getMaxQueueSize(), properties.getPoolName()) + Executors.newFixedThreadPool(properties.getPoolSize(), ThingsBoardThreadFactory.forName(properties.getPoolName())) ); } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index ee7ff18831..429bbd4a29 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -470,8 +470,6 @@ actors: pool-name: "${ACTORS_RULE_AI_REQUESTS_THREAD_POOL_NAME:ai-requests}" # The maximum number of concurrent HTTP requests pool-size: "${ACTORS_RULE_AI_REQUESTS_THREAD_POOL_SIZE:50}" - # The maximum queue size for pending AI requests - max-queue-size: "${ACTORS_RULE_AI_REQUESTS_THREAD_POOL_QUEUE_SIZE:10000}" # The maximum time in seconds to wait for active tasks to complete during graceful shutdown termination-timeout-seconds: "${ACTORS_RULE_AI_REQUESTS_THREAD_POOL_TERMINATION_TIMEOUT_SECONDS:60}" chain: From 61669dde3f60b7fc08263d81ce7a4c41b2af1f6b Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 1 Jul 2025 14:19:51 +0300 Subject: [PATCH 072/249] AI rule node: increase length limit for prompts --- .../org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 1a6bf935a3..e5a22219c9 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 @@ -37,11 +37,11 @@ public class TbAiNodeConfiguration implements NodeConfiguration Date: Tue, 1 Jul 2025 14:23:27 +0300 Subject: [PATCH 073/249] AI rule node: remove redundant SQL from schema_update.sql --- .../main/data/upgrade/basic/schema_update.sql | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 4897a35b41..e4bbe4c69e 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -59,21 +59,3 @@ WHERE trigger_type = 'RATE_LIMITS' AND trigger_config LIKE '%"CASSANDRA_QUERIES"%'; -- UPDATE NOTIFICATION RULE CASSANDRA RATE LIMITS END - --- CREATE AI MODEL SETTINGS TABLE AND INDEX START - -CREATE TABLE ai_model_settings ( - id UUID NOT NULL PRIMARY KEY, - external_id UUID, - created_time BIGINT NOT NULL, - tenant_id UUID NOT NULL, - version BIGINT NOT NULL DEFAULT 1, - name VARCHAR(255) NOT NULL, - configuration JSONB NOT NULL, - CONSTRAINT ai_model_settings_name_unq_key UNIQUE (tenant_id, name), - CONSTRAINT ai_model_settings_external_id_unq_key UNIQUE (tenant_id, external_id) -); - -CREATE INDEX idx_ai_model_settings_tenant_id ON ai_model_settings(tenant_id); - --- CREATE AI MODEL SETTINGS TABLE AND INDEX END From 36e16bb877de87a9da918559ea62ceca0879c675 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 1 Jul 2025 15:43:13 +0300 Subject: [PATCH 074/249] AI rule node: improve cache eviction --- .../ai/AiModelSettingsCacheEvictEvent.java | 28 +++++++++++++++---- .../dao/ai/AiModelSettingsServiceImpl.java | 21 ++++++++------ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheEvictEvent.java index 16ce64256b..3bf7dce9ba 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheEvictEvent.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheEvictEvent.java @@ -15,15 +15,31 @@ */ package org.thingsboard.server.dao.ai; -import org.thingsboard.server.common.data.id.AiModelSettingsId; -import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.ai.AiModelSettings; -import java.util.Set; +import static java.util.Objects.requireNonNull; +import static org.thingsboard.server.dao.ai.AiModelSettingsCacheEvictEvent.Deleted; +import static org.thingsboard.server.dao.ai.AiModelSettingsCacheEvictEvent.Saved; -record AiModelSettingsCacheEvictEvent(Set keys) { +sealed interface AiModelSettingsCacheEvictEvent permits Saved, Deleted { + + AiModelSettingsCacheKey cacheKey(); + + record Saved(AiModelSettingsCacheKey cacheKey, AiModelSettings savedSettings) implements AiModelSettingsCacheEvictEvent { + + public Saved { + requireNonNull(cacheKey); + requireNonNull(savedSettings); + } + + } + + record Deleted(AiModelSettingsCacheKey cacheKey) implements AiModelSettingsCacheEvictEvent { + + public Deleted { + requireNonNull(cacheKey); + } - static AiModelSettingsCacheEvictEvent of(TenantId tenantId, AiModelSettingsId settingsId) { - return new AiModelSettingsCacheEvictEvent(Set.of(AiModelSettingsCacheKey.of(tenantId, settingsId))); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java index 411a67ca98..2dc3aba623 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.dao.ai; -import com.google.common.collect.Sets; import com.google.common.util.concurrent.FluentFuture; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -38,7 +37,6 @@ import org.thingsboard.server.dao.sql.JpaExecutorService; import java.util.List; import java.util.Optional; -import java.util.Set; import static org.thingsboard.server.dao.service.Validator.validatePageLink; @@ -54,7 +52,14 @@ class AiModelSettingsServiceImpl extends CachedVersionedEntityService cacheKeys = Sets.newHashSetWithExpectedSize(toDelete.size()); toDelete.forEach(settings -> { publishDeleteEvent(settings); - cacheKeys.add(AiModelSettingsCacheKey.of(settings.getTenantId(), settings.getId())); + publishEvictEvent(new AiModelSettingsCacheEvictEvent.Deleted(AiModelSettingsCacheKey.of(settings.getTenantId(), settings.getId()))); }); - - publishEvictEvent(new AiModelSettingsCacheEvictEvent(cacheKeys)); } private void publishDeleteEvent(AiModelSettings settings) { From cde8bd1c93f5075a08f25e9bc5ddf42ea20c06c1 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 1 Jul 2025 16:19:42 +0300 Subject: [PATCH 075/249] AI rule node: replace derived query for bulk deletion by tenant ID with JPQL query --- .../server/dao/sql/ai/AiModelSettingsRepository.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java index a86d5ba5c8..8bb432ca83 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java @@ -72,7 +72,9 @@ interface AiModelSettingsRepository extends JpaRepository ids); @Transactional - int deleteByTenantId(UUID tenantId); + @Modifying + @Query("DELETE FROM AiModelSettingsEntity ai_model WHERE ai_model.tenantId = :tenantId") + int deleteByTenantId(@Param("tenantId") UUID tenantId); @Transactional @Modifying From 8fb9e4968989bafa02dd9e1a0f040c12f69c51fb Mon Sep 17 00:00:00 2001 From: LeoMorgan113 Date: Tue, 1 Jul 2025 16:32:49 +0300 Subject: [PATCH 076/249] Style fix for custom action editor --- ...custom-action-pretty-editor.component.scss | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss index 002ccbeeb3..6db246723f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss @@ -19,6 +19,27 @@ padding: 8px; background-color: #fff; + .tb-custom-action-editor-container { + border: none; + } + + .tb-js-func-toolbar{ + padding: 0 3px; + } + + .tb-js-func { + &:not(.tb-fullscreen) { + &.tb-hide-brackets { + padding-bottom: 0; + } + } + + .tb-js-func-panel { + border: none; + border-top: 1px solid #c0c0c0; + } + } + .tb-fullscreen-panel { .tb-custom-action-editor-container { height: calc(100% - 40px); @@ -36,14 +57,46 @@ .mat-mdc-tab-group { .mat-mdc-tab-body-wrapper { height: 100%; + .mat-mdc-tab-body { height: 100%; + & > div { height: 100%; } } } } + + .right-panel{ + padding: 8px 0 25px; + } + + .tb-custom-action-editor-container { + border: none; + border-bottom: 1px solid #c0c0c0; + } + + .tb-js-func { + &.fill-height { + .tb-js-func-toolbar{ + padding: 0 5px; + } + &:not(.tb-fullscreen) { + &.tb-hide-brackets { + padding-bottom: 15px; + } + } + + &.tb-hide-brackets { + .tb-js-func-panel { + border: none; + border-top: 1px solid #c0c0c0; + border-bottom: 1px solid #c0c0c0; + } + } + } + } } .tb-split { From 41257b6138d706d545cfd7ad9cbf0f715cf27f1c Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 1 Jul 2025 16:58:24 +0300 Subject: [PATCH 077/249] AI rule node: optimize bulk deletion by tenant ID; remove application events --- .../entitiy/EntityStateSourcingListener.java | 4 +- .../queue/DefaultTbClusterService.java | 1 - .../server/dao/ai/AiModelSettingsDao.java | 3 +- .../dao/ai/AiModelSettingsServiceImpl.java | 41 ++----------------- .../dao/sql/ai/AiModelSettingsRepository.java | 9 +++- .../dao/sql/ai/JpaAiModelSettingsDao.java | 8 +++- 6 files changed, 21 insertions(+), 45 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java index 5843d59315..03ab77ac09 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java @@ -99,7 +99,7 @@ public class EntityStateSourcingListener { case ASSET -> { onAssetUpdate(event.getEntity(), event.getOldEntity()); } - case ASSET_PROFILE, ENTITY_VIEW, NOTIFICATION_RULE, AI_MODEL_SETTINGS -> { + case ASSET_PROFILE, ENTITY_VIEW, NOTIFICATION_RULE -> { tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, lifecycleEvent); } case RULE_CHAIN -> { @@ -164,7 +164,7 @@ public class EntityStateSourcingListener { Asset asset = (Asset) event.getEntity(); tbClusterService.onAssetDeleted(tenantId, asset, null); } - case ASSET_PROFILE, ENTITY_VIEW, CUSTOMER, EDGE, NOTIFICATION_RULE, AI_MODEL_SETTINGS -> { + case ASSET_PROFILE, ENTITY_VIEW, CUSTOMER, EDGE, NOTIFICATION_RULE -> { tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, ComponentLifecycleEvent.DELETED); } case NOTIFICATION_REQUEST -> { 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 3e509bd506..265f14c4e2 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 @@ -589,7 +589,6 @@ public class DefaultTbClusterService implements TbClusterService { EntityType.ENTITY_VIEW, EntityType.NOTIFICATION_RULE, EntityType.CALCULATED_FIELD, - EntityType.AI_MODEL_SETTINGS, EntityType.TENANT_PROFILE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE, diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsDao.java index 5ee0d49121..c8d6b65369 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsDao.java @@ -22,6 +22,7 @@ import org.thingsboard.server.dao.ExportableEntityDao; import org.thingsboard.server.dao.TenantEntityDao; import java.util.Optional; +import java.util.Set; public interface AiModelSettingsDao extends TenantEntityDao, ExportableEntityDao { @@ -29,7 +30,7 @@ public interface AiModelSettingsDao extends TenantEntityDao, Ex boolean deleteById(TenantId tenantId, AiModelSettingsId settingsId); - int deleteByTenantId(TenantId tenantId); + Set deleteByTenantId(TenantId tenantId); boolean deleteByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java index 2dc3aba623..2494ab6a15 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java @@ -29,14 +29,12 @@ 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.dao.entity.CachedVersionedEntityService; -import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; -import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.model.sql.AiModelSettingsEntity; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.sql.JpaExecutorService; -import java.util.List; import java.util.Optional; +import java.util.Set; import static org.thingsboard.server.dao.service.Validator.validatePageLink; @@ -65,7 +63,7 @@ class AiModelSettingsServiceImpl extends CachedVersionedEntityService toDeleteOpt = aiModelSettingsDao.findByTenantIdAndId(tenantId, settingsId); - if (toDeleteOpt.isEmpty()) { - return false; - } boolean deleted = aiModelSettingsDao.deleteByTenantIdAndId(tenantId, settingsId); if (deleted) { - publishDeleteEvent(toDeleteOpt.get()); publishEvictEvent(new AiModelSettingsCacheEvictEvent.Deleted(AiModelSettingsCacheKey.of(tenantId, settingsId))); } return deleted; @@ -153,25 +137,8 @@ class AiModelSettingsServiceImpl extends CachedVersionedEntityService toDelete = aiModelSettingsDao.findAllByTenantId(tenantId, new PageLink(Integer.MAX_VALUE)).getData(); - if (toDelete.isEmpty()) { - return; - } - - aiModelSettingsDao.deleteByTenantId(tenantId); - - toDelete.forEach(settings -> { - publishDeleteEvent(settings); - publishEvictEvent(new AiModelSettingsCacheEvictEvent.Deleted(AiModelSettingsCacheKey.of(settings.getTenantId(), settings.getId()))); - }); - } - - private void publishDeleteEvent(AiModelSettings settings) { - eventPublisher.publishEvent(DeleteEntityEvent.builder() - .tenantId(settings.getTenantId()) - .entityId(settings.getId()) - .entity(settings) - .build()); + Set deleted = aiModelSettingsDao.deleteByTenantId(tenantId); + deleted.forEach(id -> publishEvictEvent(new AiModelSettingsCacheEvictEvent.Deleted(AiModelSettingsCacheKey.of(tenantId, id)))); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java index 8bb432ca83..aae01a3b41 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java @@ -73,8 +73,13 @@ interface AiModelSettingsRepository extends JpaRepository deleteByTenantId(@Param("tenantId") UUID tenantId); @Transactional @Modifying diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelSettingsDao.java index a09179d31e..38e7280313 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelSettingsDao.java @@ -39,6 +39,8 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; +import static java.util.stream.Collectors.toSet; + @SqlDao @Component @RequiredArgsConstructor @@ -108,8 +110,10 @@ class JpaAiModelSettingsDao extends JpaAbstractDao deleteByTenantId(TenantId tenantId) { + return aiModelSettingsRepository.deleteByTenantId(tenantId.getId()).stream() + .map(AiModelSettingsId::new) + .collect(toSet()); } @Override From 17a534abf4bc7a52838f5ffe86d29cdfe8bf35fb Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 1 Jul 2025 18:10:17 +0300 Subject: [PATCH 078/249] AI rule node: remove redundant number of entities validation --- .../dao/service/validator/AiModelSettingsDataValidator.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelSettingsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelSettingsDataValidator.java index 6ed84e3c3e..726fb6331f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelSettingsDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelSettingsDataValidator.java @@ -17,7 +17,6 @@ package org.thingsboard.server.dao.service.validator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ai.AiModelSettings; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.ai.AiModelSettingsDao; @@ -34,11 +33,6 @@ class AiModelSettingsDataValidator extends DataValidator { private final TenantService tenantService; private final AiModelSettingsDao aiModelSettingsDao; - @Override - protected void validateCreate(TenantId tenantId, AiModelSettings settings) { - validateNumberOfEntitiesPerTenant(tenantId, EntityType.AI_MODEL_SETTINGS); - } - @Override protected AiModelSettings validateUpdate(TenantId tenantId, AiModelSettings settings) { Optional existing = aiModelSettingsDao.findByTenantIdAndId(tenantId, settings.getId()); From 3ef97cc2096496c5c9a2e17ed047f6d870e5f6fb Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 1 Jul 2025 18:10:47 +0300 Subject: [PATCH 079/249] AI rule node: ensure "null" UUIDs are not accepted by AI model settings cache key --- .../server/dao/ai/AiModelSettingsCacheKey.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheKey.java index 9d73289be7..60d0ccaee9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheKey.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.ai; import org.checkerframework.checker.nullness.qual.NonNull; import org.thingsboard.server.cache.VersionedCacheKey; import org.thingsboard.server.common.data.id.AiModelSettingsId; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import java.util.UUID; @@ -29,6 +30,13 @@ record AiModelSettingsCacheKey(UUID tenantId, UUID settingsId) implements Versio AiModelSettingsCacheKey { requireNonNull(tenantId); requireNonNull(settingsId); + + if (TenantId.SYS_TENANT_ID.getId().equals(tenantId)) { + throw new IllegalArgumentException("Tenant ID must not be the system tenant ID"); + } + if (EntityId.NULL_UUID.equals(settingsId)) { + throw new IllegalArgumentException("Settings ID must not be reserved null UUID"); + } } static AiModelSettingsCacheKey of(TenantId tenantId, AiModelSettingsId settingsId) { From c0b44a95b276f006e1f9c65ae0116ccaaa495804 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 1 Jul 2025 21:04:56 +0300 Subject: [PATCH 080/249] AI rule node: change URL for AI model settings API to be more RESTful --- .../server/controller/AiModelSettingsController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java b/application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java index 9659be1941..178d7017a0 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java @@ -46,7 +46,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERT import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; @RestController -@RequestMapping("/api/ai-model-settings") +@RequestMapping("/api/ai/model/settings") class AiModelSettingsController extends BaseController { @ApiOperation( From a5d9fe8751e38a61be6c4854b8920d6238e280c3 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Wed, 2 Jul 2025 13:39:42 +0300 Subject: [PATCH 081/249] UI: Improved help popup container style --- ui-ngx/src/app/shared/components/help-popup.component.html | 2 +- ui-ngx/src/app/shared/components/help-popup.component.scss | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/ui-ngx/src/app/shared/components/help-popup.component.html b/ui-ngx/src/app/shared/components/help-popup.component.html index c24b5234b9..c4a66ed452 100644 --- a/ui-ngx/src/app/shared/components/help-popup.component.html +++ b/ui-ngx/src/app/shared/components/help-popup.component.html @@ -34,7 +34,7 @@ -
+
-
mobile.configuration-step.configure-api-title
-
mobile.configuration-step.configure-api-text
- -
mobile.configuration-step.configure-api-hint
- -
-
-
mobile.configuration-step.configure-package-title
-
mobile.configuration-step.configure-package-text
-
mobile.configuration-step.configure-package-text-install
- -
mobile.configuration-step.configure-package-run-commands
- +
mobile.configuration-step.configure-app-settings-title
+
+
mobile.configuration-step.configure-app-settings-text
+ +
mobile.configuration-step.run-app-title
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.ts index 9168f14245..5e7d384383 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.ts @@ -22,6 +22,7 @@ import { Router } from '@angular/router'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { ActionPreferencesPutUserSettings } from '@core/auth/auth.actions'; import { MobileApp } from '@shared/models/mobile-app.models'; +import { ImportExportService } from '@shared/import-export/import-export.service'; export interface MobileAppConfigurationDialogData { afterAdd: boolean; @@ -36,53 +37,22 @@ export interface MobileAppConfigurationDialogData { }) export class MobileAppConfigurationDialogComponent extends DialogComponent { - notShowAgain = false; - setApplication = false; + private fileName = 'configs.json'; + notShowAgain = false; showDontShowAgain: boolean; gitRepositoryLink = 'git clone -b master https://github.com/thingsboard/flutter_thingsboard_app.git'; - pathToConstants = 'lib/constants/app_constants.dart'; - flutterRunCommand = 'flutter run'; - flutterInstallRenameCommand = 'flutter pub global activate rename'; - - configureApi: string; - - renameCommands: string[] = []; + flutterRunCommand = `flutter run --dart-define-from-file ${this.fileName}`; constructor(protected store: Store, protected router: Router, @Inject(MAT_DIALOG_DATA) private data: MobileAppConfigurationDialogData, protected dialogRef: MatDialogRef, + private importExportService: ImportExportService, ) { super(store, router, dialogRef); - this.showDontShowAgain = this.data.afterAdd; - - this.setApplication = !!this.data.androidApp || !!this.data.iosApp; - - this.configureApi = `static const thingsBoardApiEndpoint = '${window.location.origin}';`; - if (this.setApplication) { - this.configureApi += '\n'; - if (!!this.data.androidApp) { - this.configureApi += `\nstatic const thingsboardAndroidAppSecret = '${this.data.androidApp.appSecret}';`; - } - if (!!this.data.iosApp) { - this.configureApi += `\nstatic const thingsboardIOSAppSecret = '${this.data.iosApp.appSecret}';`; - } - } - if (this.setApplication) { - if (this.data.androidApp?.pkgName === this.data.iosApp?.pkgName) { - this.renameCommands.push(`rename setBundleId --targets android, ios --value "${this.data.androidApp.pkgName}"`); - } else { - if (!!this.data.androidApp) { - this.renameCommands.push(`rename setBundleId --targets android --value "${this.data.androidApp.pkgName}"`); - } - if (!!this.data.iosApp) { - this.renameCommands.push(`rename setBundleId --targets ios --value "${this.data.iosApp.pkgName}"`); - } - } - } } close(): void { @@ -94,14 +64,24 @@ export class MobileAppConfigurationDialogComponent extends DialogComponent = []; - commands.forEach(command => formatCommands.push(this.createMarkDownSingleCommand(command))); - return formatCommands.join(`\n
\n\n`); - } else { - return this.createMarkDownSingleCommand(commands); + createMarkDownCommand(commands: string): string { + return this.createMarkDownSingleCommand(commands); + } + + downloadSettings(): void { + const settings: any = { + thingsBoardApiEndpoint: window.location.origin + }; + if (!!this.data.androidApp) { + settings.androidApplicationId = this.data.androidApp.pkgName; + settings.thingsboardOAuth2CallbackUrlScheme = this.data.androidApp.pkgName + '.auth'; + settings.thingsboardAndroidAppSecret = this.data.androidApp.appSecret; + } + if (!!this.data.iosApp) { + settings.iosApplicationId = this.data.iosApp.pkgName; + settings.thingsboardIOSAppSecret = this.data.iosApp.appSecret; } + this.importExportService.exportJson(settings, this.fileName); } private createMarkDownSingleCommand(command: string): string { diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts index 2ad60f0139..94960cb838 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.service.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts @@ -1188,7 +1188,7 @@ export class ImportExportService { this.exportJson(data, filename); } - private exportJson(data: any, filename: string) { + public exportJson(data: any, filename: string) { if (isObject(data)) { data = JSON.stringify(data, null, 2); } 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 963bf769ee..14395f3b95 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3850,17 +3850,13 @@ "prepare-environment-text": "Flutter ThingsBoard Mobile Application requires Flutter SDK. Follow instructions to set up Flutter SDK.", "get-source-code-title": "Get app source code", "get-source-code-text": "You can get Flutter ThingsBoard Mobile Application source code by cloning it from the GitHub repository:", - "configure-api-title": "Configure ThingsBoard API endpoint", - "configure-api-text": "Open the flutter_thingsboard_app project in your editor/IDE. Edit:", - "configure-api-hint": "Set the value of the thingsBoardApiEndpoint constant to match the API endpoint of your ThingsBoard server instance. Do not use “localhost” or “127.0.0.1” hostnames.", + "configure-app-settings-title": "Configure app settings", + "configure-app-settings-text": "Download the configuration file and place it into the root directory of the project you cloned in the previous step.", + "download-file": "Download file", "run-app-title": "Run the app", "run-app-text": "Run the app as described in your IDE.\nIf using the terminal, run the app with the following command:", "more-information": "Detailed information may be found in our Getting Started documentation.", - "getting-started": "Getting Started", - "configure-package-title": "Configure application package", - "configure-package-text": "You can manually change the Application Package or use third party CLI tool.", - "configure-package-text-install": "To install the Rename CLI Tool, execute the following command:", - "configure-package-run-commands": "Run these commands in the root directory of your project:" + "getting-started": "Getting Started" } }, "notification": { From ad6e29145a88434b8666406f18838d332a1d0011 Mon Sep 17 00:00:00 2001 From: deaflynx Date: Fri, 4 Jul 2025 12:45:28 +0300 Subject: [PATCH 094/249] UI: Fix rewrite of dashboard duplicate state with same id. --- .../states/manage-dashboard-states-dialog.component.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/states/manage-dashboard-states-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/states/manage-dashboard-states-dialog.component.ts index 107d5c2686..7dddc18dbd 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/states/manage-dashboard-states-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/states/manage-dashboard-states-dialog.component.ts @@ -261,11 +261,16 @@ export class ManageDashboardStatesDialogComponent const originalState = state; const newStateName = this.getNextDuplicatedName(state.name); if (newStateName) { + const newStateId = newStateName.toLowerCase().replace(/\W/g, '_'); + if (this.states[newStateId]) { + this.stateNames.add(newStateName); + this.duplicateState(null, state); + } const duplicatedStates = deepClone(originalState); const duplicatedWidgets = deepClone(this.widgets); const mainWidgets = {}; const rightWidgets = {}; - duplicatedStates.id = newStateName.toLowerCase().replace(/\W/g, '_'); + duplicatedStates.id = newStateId; duplicatedStates.name = newStateName; duplicatedStates.root = false; this.stateNames.add(duplicatedStates.name); From 8415e91e98ef4ce851b370e94f7de2c4ad28f8a0 Mon Sep 17 00:00:00 2001 From: deaflynx Date: Fri, 4 Jul 2025 12:37:55 +0300 Subject: [PATCH 095/249] UI: OTA package form fix scrolling for long file name. --- .../app/modules/home/pages/ota-update/ota-update.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html index e028a2714a..ee8e34a4a2 100644 --- a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html @@ -63,7 +63,7 @@
-
+
ota-update.title From 075429fe87cf85fafeadf06786ffd8f739cad54d Mon Sep 17 00:00:00 2001 From: deaflynx Date: Fri, 4 Jul 2025 12:27:57 +0300 Subject: [PATCH 096/249] UI: Mobile center update app-id translations. --- ui-ngx/src/assets/locale/locale.constant-en_US.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 963bf769ee..4d3f53f460 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3731,9 +3731,9 @@ }, "mobile": { "add-application": "Add application", - "app-id": "App ID", - "app-id-required": "App ID is required", - "app-id-pattern": "Invalid format App ID", + "app-id": "App Site Association ID", + "app-id-required": "App Site Association ID is required", + "app-id-pattern": "Invalid format App Site Association ID", "app-store-link": "App Store link", "app-store-link-required": "App Store link is required", "application-details": "Application details", From cdb7e3b0c03bb07ced7a3a078383fbd32d5bcbc5 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 4 Jul 2025 14:52:19 +0300 Subject: [PATCH 097/249] AI rule node: correct method name for schema adapter --- .../rule/engine/ai/Langchain4jJsonSchemaAdapter.java | 2 +- .../src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java index e136bfaea8..b04d4592a2 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java @@ -46,7 +46,7 @@ final class Langchain4jJsonSchemaAdapter { * @param rootSchemaNode a valid JSON Schema as a Jackson {@link ObjectNode} * @return the corresponding Langchain4j {@link JsonSchema} */ - public static JsonSchema fromJsonNode(ObjectNode rootSchemaNode) { + public static JsonSchema fromObjectNode(ObjectNode rootSchemaNode) { return JsonSchema.builder() .name(rootSchemaNode.get("title").textValue()) .rootElement(parse(rootSchemaNode)) 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 9e7d3fb23b..fe05712bea 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 @@ -84,7 +84,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { if (config.getResponseFormatType() == ResponseFormatType.JSON) { responseFormat = ResponseFormat.builder() .type(config.getResponseFormatType()) - .jsonSchema(config.getJsonSchema() != null ? Langchain4jJsonSchemaAdapter.fromJsonNode(config.getJsonSchema()) : null) + .jsonSchema(config.getJsonSchema() != null ? Langchain4jJsonSchemaAdapter.fromObjectNode(config.getJsonSchema()) : null) .build(); } From 749b327795f3b2e26aa7f2afe3e34a958b415a7b Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 4 Jul 2025 20:21:29 +0300 Subject: [PATCH 098/249] AI rule node: resolve dependency conflicts --- application/pom.xml | 6 +++ common/data/pom.xml | 2 +- pom.xml | 104 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/application/pom.xml b/application/pom.xml index 543724fa10..e2faa21929 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -412,6 +412,12 @@ dev.langchain4j langchain4j-github-models + + + com.azure + azure-core-test + + diff --git a/common/data/pom.xml b/common/data/pom.xml index f57af8d24b..f582c32023 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -114,7 +114,7 @@ dev.langchain4j - langchain4j + langchain4j-core diff --git a/pom.xml b/pom.xml index 349f09606c..edd15c778c 100755 --- a/pom.xml +++ b/pom.xml @@ -93,6 +93,8 @@ 4.1.119.Final 2.0.65.Final 1.1.18 + 1.0.4 + 3.6.12 1.7.1 5.21.0 3.2.5 @@ -119,7 +121,14 @@ 2.2 1.12.701 1.128.1 - 2.37.1 + + 1.34.0 + 1.46.3 + 2.48.0 + 2.65.0 + 2.37.1 + 1.51.0 + 1.6.4 1.6.1 1.9.4 @@ -136,6 +145,12 @@ 3.8.0 2.9.0 1.1.0 + 2.38.0 + 1.24 + 1.11.0 + 3.49.3 + 0.27.0 + 1.7.0 4.2.1 2.7.3 @@ -1261,6 +1276,16 @@ reactor-netty-http ${reactor-netty.version} + + org.reactivestreams + reactive-streams + ${reactive-streams.version} + + + io.projectreactor + reactor-core + ${reactor-core.version} + org.apache.kafka kafka-clients @@ -2080,10 +2105,55 @@ google-cloud-pubsub ${pubsub.client.version} + + com.google.auth + google-auth-library-credentials + ${google-auth-library.version} + + + com.google.auth + google-auth-library-oauth2-http + ${google-auth-library.version} + + + com.google.http-client + google-http-client + ${google-http-client.version} + + + com.google.http-client + google-http-client-gson + ${google-http-client.version} + + + com.google.api + api-common + ${google-api-common.version} + + + com.google.api + gax + ${google-api-gax.version} + + + com.google.api + gax-grpc + ${google-api-gax.version} + + + com.google.api + gax-httpjson + ${google-api-gax.version} + com.google.api.grpc proto-google-common-protos - ${google.common.protos.version} + ${google-proto-common.version} + + + com.google.api.grpc + proto-google-iam-v1 + ${google-proto-iam-v1.version} org.passay @@ -2337,6 +2407,36 @@ rocksdbjni ${rocksdbjni.version} + + com.google.errorprone + error_prone_annotations + ${error_prone_annotations.version} + + + org.codehaus.mojo + animal-sniffer-annotations + ${animal-sniffer-annotations.version} + + + com.google.auto.value + auto-value-annotations + ${auto-value-annotations.version} + + + org.checkerframework + checker-qual + ${checker-qual.version} + + + io.perfmark + perfmark-api + ${perfmark-api.version} + + + org.threeten + threetenbp + ${threetenbp.version} + dev.langchain4j langchain4j-bom From 69964a2413933c8b97d4dafeb1746dee47d28429 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 7 Jul 2025 17:07:30 +0300 Subject: [PATCH 099/249] Make script compilation errors unrecoverable during rule node initialization --- .../service/script/RuleNodeScriptEngine.java | 8 ++++--- .../server/actors/TbActorMailbox.java | 5 +++-- .../script/api/TbScriptException.java | 21 +++++++++++++++++-- .../script/api/js/NashornJsInvokeService.java | 7 ++++++- .../api/tbel/DefaultTbelInvokeService.java | 16 ++++++++------ .../common/util/RecoveryAware.java} | 4 ++-- .../rule/engine/api/TbNodeException.java | 7 ++----- 7 files changed, 47 insertions(+), 21 deletions(-) rename common/{message/src/main/java/org/thingsboard/server/common/msg/TbActorError.java => util/src/main/java/org/thingsboard/common/util/RecoveryAware.java} (89%) diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptEngine.java index 8f19aeb0a3..d99f1654f3 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptEngine.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptEngine.java @@ -22,6 +22,7 @@ import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.ScriptEngine; import org.thingsboard.script.api.ScriptInvokeService; import org.thingsboard.script.api.ScriptType; +import org.thingsboard.script.api.TbScriptException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.TbMsg; @@ -32,7 +33,6 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.ExecutionException; - @Slf4j public abstract class RuleNodeScriptEngine implements ScriptEngine { @@ -51,7 +51,10 @@ public abstract class RuleNodeScriptEngine imp if (e instanceof ExecutionException) { t = e.getCause(); } - throw new IllegalArgumentException("Can't compile script: " + t.getMessage(), t); + if (t instanceof TbScriptException scriptException) { + throw scriptException; + } + throw new RuntimeException("Unexpected error when creating script engine: " + t.getMessage(), t); } } @@ -81,7 +84,6 @@ public abstract class RuleNodeScriptEngine imp return Futures.transformAsync(executeScriptAsync(msg), this::executeToStringTransform, MoreExecutors.directExecutor()); } - @Override public ListenableFuture executeFilterAsync(TbMsg msg) { return Futures.transformAsync(executeScriptAsync(msg), diff --git a/common/actor/src/main/java/org/thingsboard/server/actors/TbActorMailbox.java b/common/actor/src/main/java/org/thingsboard/server/actors/TbActorMailbox.java index 6cd28fa98d..c20726d765 100644 --- a/common/actor/src/main/java/org/thingsboard/server/actors/TbActorMailbox.java +++ b/common/actor/src/main/java/org/thingsboard/server/actors/TbActorMailbox.java @@ -19,8 +19,8 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.common.util.RecoveryAware; import org.thingsboard.server.common.msg.MsgType; -import org.thingsboard.server.common.msg.TbActorError; import org.thingsboard.server.common.msg.TbActorMsg; import org.thingsboard.server.common.msg.TbActorStopReason; @@ -35,6 +35,7 @@ import java.util.function.Supplier; @Getter @RequiredArgsConstructor public final class TbActorMailbox implements TbActorCtx { + private static final boolean HIGH_PRIORITY = true; private static final boolean NORMAL_PRIORITY = false; @@ -100,7 +101,7 @@ public final class TbActorMailbox implements TbActorCtx { if (t instanceof TbActorException && t.getCause() != null) { t = t.getCause(); } - return t instanceof TbActorError && ((TbActorError) t).isUnrecoverable(); + return t instanceof RecoveryAware recoveryAware && recoveryAware.isUnrecoverable(); } private void enqueue(TbActorMsg msg, boolean highPriority) { diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/TbScriptException.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/TbScriptException.java index 77888db255..347490b3fb 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/TbScriptException.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/TbScriptException.java @@ -16,13 +16,24 @@ package org.thingsboard.script.api; import lombok.Getter; +import org.thingsboard.common.util.RecoveryAware; +import java.io.Serial; import java.util.UUID; -public class TbScriptException extends RuntimeException { +public class TbScriptException extends RuntimeException implements RecoveryAware { + + @Serial private static final long serialVersionUID = -1958193538782818284L; - public static enum ErrorCode {COMPILATION, TIMEOUT, RUNTIME, OTHER} + public enum ErrorCode { + + COMPILATION, + TIMEOUT, + RUNTIME, + OTHER + + } @Getter private final UUID scriptId; @@ -37,4 +48,10 @@ public class TbScriptException extends RuntimeException { this.errorCode = errorCode; this.body = body; } + + @Override + public boolean isUnrecoverable() { + return errorCode == ErrorCode.COMPILATION; + } + } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/js/NashornJsInvokeService.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/NashornJsInvokeService.java index 3507dd87e6..4aedab8081 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/js/NashornJsInvokeService.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/NashornJsInvokeService.java @@ -20,6 +20,7 @@ import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import delight.nashornsandbox.NashornSandbox; import delight.nashornsandbox.NashornSandboxes; +import delight.nashornsandbox.exceptions.ScriptCPUAbuseException; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.Getter; @@ -153,8 +154,12 @@ public class NashornJsInvokeService extends AbstractJsInvokeService { } scriptInfoMap.put(scriptId, scriptInfo); return scriptId; - } catch (Exception e) { + } catch (ScriptException e) { throw new TbScriptException(scriptId, TbScriptException.ErrorCode.COMPILATION, jsScript, e); + } catch (ScriptCPUAbuseException e) { + throw new TbScriptException(scriptId, TbScriptException.ErrorCode.TIMEOUT, jsScript, e); + } catch (Exception e) { + throw new TbScriptException(scriptId, TbScriptException.ErrorCode.OTHER, jsScript, e); } }); } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java index 25a7ede547..e2195a6bbf 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java @@ -27,6 +27,7 @@ import jakarta.annotation.PreDestroy; import lombok.Getter; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; +import org.mvel2.CompileException; import org.mvel2.ExecutionContext; import org.mvel2.MVEL; import org.mvel2.ParserContext; @@ -52,11 +53,11 @@ import java.io.Serializable; import java.nio.charset.StandardCharsets; import java.util.Calendar; import java.util.Collections; -import java.util.Map; import java.util.Optional; import java.util.Random; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.Executor; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; @@ -66,9 +67,9 @@ import java.util.concurrent.locks.ReentrantLock; @Service public class DefaultTbelInvokeService extends AbstractScriptInvokeService implements TbelInvokeService { - protected final Map scriptIdToHash = new ConcurrentHashMap<>(); - protected final Map scriptMap = new ConcurrentHashMap<>(); - protected Cache compiledScriptsCache; + private final ConcurrentMap scriptIdToHash = new ConcurrentHashMap<>(); + private final ConcurrentMap scriptMap = new ConcurrentHashMap<>(); + private Cache compiledScriptsCache; private SandboxedParserConfiguration parserConfig; private final Optional apiUsageStateClient; @@ -204,8 +205,10 @@ public class DefaultTbelInvokeService extends AbstractScriptInvokeService implem lock.unlock(); } return scriptId; - } catch (Exception e) { + } catch (CompileException e) { throw new TbScriptException(scriptId, TbScriptException.ErrorCode.COMPILATION, scriptBody, e); + } catch (Exception e) { + throw new TbScriptException(scriptId, TbScriptException.ErrorCode.OTHER, scriptBody, e); } }); } @@ -246,7 +249,7 @@ public class DefaultTbelInvokeService extends AbstractScriptInvokeService implem } } - private Serializable compileScript(String scriptBody) { + private static Serializable compileScript(String scriptBody) throws CompileException { return MVEL.compileExpression(scriptBody, new ParserContext()); } @@ -269,4 +272,5 @@ public class DefaultTbelInvokeService extends AbstractScriptInvokeService implem protected StatsType getStatsType() { return StatsType.TBEL_INVOKE; } + } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/TbActorError.java b/common/util/src/main/java/org/thingsboard/common/util/RecoveryAware.java similarity index 89% rename from common/message/src/main/java/org/thingsboard/server/common/msg/TbActorError.java rename to common/util/src/main/java/org/thingsboard/common/util/RecoveryAware.java index 8c322d8eb5..e1553bec36 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/TbActorError.java +++ b/common/util/src/main/java/org/thingsboard/common/util/RecoveryAware.java @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg; +package org.thingsboard.common.util; -public interface TbActorError { +public interface RecoveryAware { boolean isUnrecoverable(); diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbNodeException.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbNodeException.java index 874a37792d..7d5a7443d3 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbNodeException.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbNodeException.java @@ -16,12 +16,9 @@ package org.thingsboard.rule.engine.api; import lombok.Getter; -import org.thingsboard.server.common.msg.TbActorError; +import org.thingsboard.common.util.RecoveryAware; -/** - * Created by ashvayka on 19.01.18. - */ -public class TbNodeException extends Exception implements TbActorError { +public class TbNodeException extends Exception implements RecoveryAware { @Getter private final boolean unrecoverable; From d4ec3f8b396294b6495e8e68fafcd5ef401f0087 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 7 Jul 2025 20:11:52 +0300 Subject: [PATCH 100/249] AI rule node: change structure of response format info in node config --- .../data/validation/ValidJsonSchema.java | 39 +++++++ .../common/util/JsonSchemaUtils.java | 12 +- .../dao/service/ConstraintValidator.java | 2 + .../dao/service/JsonSchemaValidator.java | 31 ++++++ .../thingsboard/rule/engine/ai/TbAiNode.java | 8 +- .../rule/engine/ai/TbAiNodeConfiguration.java | 21 +--- .../rule/engine/ai/TbResponseFormat.java | 103 ++++++++++++++++++ 7 files changed, 188 insertions(+), 28 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/validation/ValidJsonSchema.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/JsonSchemaValidator.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/validation/ValidJsonSchema.java b/common/data/src/main/java/org/thingsboard/server/common/data/validation/ValidJsonSchema.java new file mode 100644 index 0000000000..d37d7eb9e7 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/validation/ValidJsonSchema.java @@ -0,0 +1,39 @@ +/** + * 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.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Constraint(validatedBy = {}) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidJsonSchema { + + String message() default "must conform to the Draft 2020-12 meta-schema"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/common/util/src/main/java/org/thingsboard/common/util/JsonSchemaUtils.java b/common/util/src/main/java/org/thingsboard/common/util/JsonSchemaUtils.java index db45994826..57ddd8a5ac 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/JsonSchemaUtils.java +++ b/common/util/src/main/java/org/thingsboard/common/util/JsonSchemaUtils.java @@ -15,7 +15,7 @@ */ package org.thingsboard.common.util; -import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.networknt.schema.JsonSchemaFactory; import com.networknt.schema.SchemaId; import com.networknt.schema.SchemaLocation; @@ -26,17 +26,15 @@ import java.util.Set; public final class JsonSchemaUtils { - private JsonSchemaUtils() { - throw new AssertionError("Can't instantiate utility class"); - } + private JsonSchemaUtils() {} /** - * Validates that the provided JsonNode is a valid JSON Schema (Draft 2020-12). + * Validates that the provided ObjectNode is a valid JSON Schema (Draft 2020-12). * - * @param schemaNode the JSON Schema document as a JsonNode + * @param schemaNode the JSON Schema document as an ObjectNode * @return true if the schema is well-formed, false otherwise */ - public static boolean isValidJsonSchema(JsonNode schemaNode) { + public static boolean isValidJsonSchema(ObjectNode schemaNode) { Set errors = JsonSchemaFactory .getInstance(SpecVersion.VersionFlag.V202012) .getSchema(SchemaLocation.of(SchemaId.V202012)) diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java index d220762f1b..0fd55bb494 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java @@ -34,6 +34,7 @@ import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoNullChar; import org.thingsboard.server.common.data.validation.NoXss; import org.thingsboard.server.common.data.validation.RateLimit; +import org.thingsboard.server.common.data.validation.ValidJsonSchema; import org.thingsboard.server.dao.exception.DataValidationException; import java.util.Collection; @@ -107,6 +108,7 @@ public class ConstraintValidator { constraintMapping.constraintDefinition(Length.class).validatedBy(StringLengthValidator.class); constraintMapping.constraintDefinition(RateLimit.class).validatedBy(RateLimitValidator.class); constraintMapping.constraintDefinition(NoNullChar.class).validatedBy(NoNullCharValidator.class); + constraintMapping.constraintDefinition(ValidJsonSchema.class).validatedBy(JsonSchemaValidator.class); return constraintMapping; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/JsonSchemaValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/JsonSchemaValidator.java new file mode 100644 index 0000000000..eefefbb3d7 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/service/JsonSchemaValidator.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.dao.service; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.thingsboard.common.util.JsonSchemaUtils; +import org.thingsboard.server.common.data.validation.ValidJsonSchema; + +public final class JsonSchemaValidator implements ConstraintValidator { + + @Override + public boolean isValid(ObjectNode schema, ConstraintValidatorContext context) { + return schema == null || JsonSchemaUtils.isValidJsonSchema(schema); + } + +} 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 fe05712bea..62d92ef2ce 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 @@ -23,7 +23,6 @@ import dev.langchain4j.data.message.SystemMessage; 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.request.ResponseFormatType; import dev.langchain4j.model.chat.response.ChatResponse; import org.checkerframework.checker.nullness.qual.NonNull; import org.thingsboard.common.util.JacksonUtil; @@ -81,11 +80,8 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { } // LC4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT - if (config.getResponseFormatType() == ResponseFormatType.JSON) { - responseFormat = ResponseFormat.builder() - .type(config.getResponseFormatType()) - .jsonSchema(config.getJsonSchema() != null ? Langchain4jJsonSchemaAdapter.fromObjectNode(config.getJsonSchema()) : null) - .build(); + if (config.getResponseFormat().type() == TbResponseFormat.TbResponseFormatType.JSON) { + responseFormat = config.getResponseFormat().toLangChainResponseFormat(); } systemPrompt = config.getSystemPrompt(); 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 d9c815192d..ebfcf943f7 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 @@ -15,21 +15,19 @@ */ package org.thingsboard.rule.engine.ai; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.databind.node.ObjectNode; -import dev.langchain4j.model.chat.request.ResponseFormatType; -import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.Valid; 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.common.util.JsonSchemaUtils; import org.thingsboard.rule.engine.api.NodeConfiguration; import org.thingsboard.server.common.data.id.AiModelSettingsId; import org.thingsboard.server.common.data.validation.Length; +import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; + @Data public class TbAiNodeConfiguration implements NodeConfiguration { @@ -45,26 +43,19 @@ public class TbAiNodeConfiguration implements NodeConfiguration Date: Tue, 8 Jul 2025 11:40:18 +0300 Subject: [PATCH 101/249] Add admin settings entity type --- .../server/controller/AdminController.java | 51 ++--- .../service/mail/DefaultMailService.java | 183 ++++++----------- .../server/service/mail/TbMailSender.java | 22 +- .../service/security/permission/Resource.java | 2 +- .../service/sms/DefaultSmsSenderFactory.java | 16 +- .../dao/settings/AdminSettingsService.java | 5 +- .../server/common/data/EntityType.java | 6 +- .../common/data/id/AdminSettingsId.java | 18 +- .../server/common/data/id/EdgeId.java | 3 + .../server/common/data/id/EntityId.java | 4 - .../common/data/id/EntityIdFactory.java | 190 ++++++------------ .../server/common/data/id/JobId.java | 4 + .../server/common/data/id/TenantId.java | 2 + .../server/common/util/ProtoUtils.java | 12 +- common/proto/src/main/proto/queue.proto | 1 + .../HybridClientRegistrationRepository.java | 6 +- .../server/dao/settings/AdminSettingsDao.java | 14 +- .../settings/AdminSettingsServiceImpl.java | 22 +- .../sql/settings/AdminSettingsRepository.java | 3 - .../dao/sql/settings/JpaAdminSettingsDao.java | 34 ++-- .../server/dao/tenant/TenantServiceImpl.java | 8 +- .../rule/engine/util/TenantIdLoader.java | 1 + .../rule/engine/util/TenantIdLoaderTest.java | 8 +- 23 files changed, 252 insertions(+), 363 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AdminController.java b/application/src/main/java/org/thingsboard/server/controller/AdminController.java index 6b3132a1d2..154e349fdb 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AdminController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AdminController.java @@ -37,14 +37,13 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.async.DeferredResult; @@ -125,8 +124,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Get the Administration Settings object using key (getAdminSettings)", notes = "Get the Administration Settings object using specified string key. Referencing non-existing key will cause an error." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/settings/{key}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/settings/{key}") public AdminSettings getAdminSettings( @Parameter(description = "A string value of the key (e.g. 'general' or 'mail').") @PathVariable("key") String key) throws ThingsboardException { @@ -144,8 +142,7 @@ public class AdminController extends BaseController { "The Administration Settings Id will be present in the response. Specify the Administration Settings Id when you would like to update the Administration Settings. " + "Referencing non-existing Administration Settings Id will cause an error." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/settings", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/settings") public AdminSettings saveAdminSettings( @Parameter(description = "A JSON value representing the Administration Settings.") @RequestBody AdminSettings adminSettings) throws ThingsboardException { @@ -165,8 +162,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Get the Security Settings object (getSecuritySettings)", notes = "Get the Security Settings object that contains password policy, etc." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/securitySettings", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/securitySettings") public SecuritySettings getSecuritySettings() throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); return checkNotNull(securitySettingsService.getSecuritySettings()); @@ -175,8 +171,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Update Security Settings (saveSecuritySettings)", notes = "Updates the Security Settings object that contains password policy, etc." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/securitySettings", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/securitySettings") public SecuritySettings saveSecuritySettings( @Parameter(description = "A JSON value representing the Security Settings.") @RequestBody SecuritySettings securitySettings) throws ThingsboardException { @@ -188,8 +183,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Get the JWT Settings object (getJwtSettings)", notes = "Get the JWT Settings object that contains JWT token policy, etc. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/jwtSettings", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/jwtSettings") public JwtSettings getJwtSettings() throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); return checkNotNull(jwtSettingsService.getJwtSettings()); @@ -198,8 +192,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Update JWT Settings (saveJwtSettings)", notes = "Updates the JWT Settings object that contains JWT token policy, etc. The tokenSigningKey field is a Base64 encoded string." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/jwtSettings", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/jwtSettings") public JwtPair saveJwtSettings( @Parameter(description = "A JSON value representing the JWT Settings.") @RequestBody JwtSettings jwtSettings) throws ThingsboardException { @@ -213,7 +206,7 @@ public class AdminController extends BaseController { notes = "Attempts to send test email to the System Administrator User using Mail Settings provided as a parameter. " + "You may change the 'To' email in the user profile of the System Administrator. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/settings/testMail", method = RequestMethod.POST) + @PostMapping(value = "/settings/testMail") public void sendTestMail( @Parameter(description = "A JSON value representing the Mail Settings.") @RequestBody AdminSettings adminSettings) throws ThingsboardException { @@ -251,7 +244,7 @@ public class AdminController extends BaseController { notes = "Attempts to send test sms to the System Administrator User using SMS Settings and phone number provided as a parameters of the request. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/settings/testSms", method = RequestMethod.POST) + @PostMapping(value = "/settings/testSms") public void sendTestSms( @Parameter(description = "A JSON value representing the Test SMS request.") @RequestBody TestSmsRequest testSmsRequest) throws ThingsboardException { @@ -325,7 +318,7 @@ public class AdminController extends BaseController { notes = "Deletes the repository settings." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/repositorySettings", method = RequestMethod.DELETE) + @DeleteMapping(value = "/repositorySettings") @ResponseStatus(value = HttpStatus.OK) public DeferredResult deleteRepositorySettings() throws Exception { accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.DELETE); @@ -335,7 +328,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Check repository access (checkRepositoryAccess)", notes = "Attempts to check repository access. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/repositorySettings/checkAccess", method = RequestMethod.POST) + @PostMapping(value = "/repositorySettings/checkAccess") public DeferredResult checkRepositoryAccess( @Parameter(description = "A JSON value representing the Repository Settings.") @RequestBody RepositorySettings settings) throws Exception { @@ -376,7 +369,7 @@ public class AdminController extends BaseController { notes = "Deletes the auto commit settings." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/autoCommitSettings", method = RequestMethod.DELETE) + @DeleteMapping(value = "/autoCommitSettings") @ResponseStatus(value = HttpStatus.OK) public void deleteAutoCommitSettings() throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.DELETE); @@ -387,9 +380,8 @@ public class AdminController extends BaseController { notes = "Check notifications about new platform releases. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/updates", method = RequestMethod.GET) - @ResponseBody - public UpdateMessage checkUpdates() throws ThingsboardException { + @GetMapping(value = "/updates") + public UpdateMessage checkUpdates() { return updateService.checkUpdates(); } @@ -397,9 +389,8 @@ public class AdminController extends BaseController { notes = "Get main information about system. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/systemInfo", method = RequestMethod.GET) - @ResponseBody - public SystemInfo getSystemInfo() throws ThingsboardException { + @GetMapping(value = "/systemInfo") + public SystemInfo getSystemInfo() { return systemInfoService.getSystemInfo(); } @@ -407,8 +398,7 @@ public class AdminController extends BaseController { notes = "Get information about enabled/disabled features. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/featuresInfo", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/featuresInfo") public FeaturesInfo getFeaturesInfo() { return systemInfoService.getFeaturesInfo(); } @@ -417,8 +407,7 @@ public class AdminController extends BaseController { "double quotes. After successful authentication with OAuth2 provider and user consent for requested scope, it makes a redirect to this path so that the platform can do " + "further log in processing and generating access tokens. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") - @RequestMapping(value = "/mail/oauth2/loginProcessingUrl", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/mail/oauth2/loginProcessingUrl") public String getMailProcessingUrl() throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); return "\"/api/admin/mail/oauth2/code\""; @@ -427,7 +416,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Redirect user to mail provider login page. ", notes = "After user logged in and provided access" + "provider sends authorization code to specified redirect uri.)") @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/mail/oauth2/authorize", method = RequestMethod.GET, produces = "application/text") + @GetMapping(value = "/mail/oauth2/authorize", produces = "application/text") public String getAuthorizationUrl(HttpServletRequest request, HttpServletResponse response) throws ThingsboardException { String state = StringUtils.generateSafeToken(); if (request.getParameter(PREV_URI_PATH_PARAMETER) != null) { @@ -452,7 +441,7 @@ public class AdminController extends BaseController { .build() + "\""; } - @RequestMapping(value = "/mail/oauth2/code", params = {"code", "state"}, method = RequestMethod.GET) + @GetMapping(value = "/mail/oauth2/code", params = {"code", "state"}) public void codeProcessingUrl( @RequestParam(value = "code") String code, @RequestParam(value = "state") String state, HttpServletRequest request, HttpServletResponse response) throws ThingsboardException, IOException { diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java index 99faf1a1d0..c1cfe791da 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java @@ -22,9 +22,9 @@ import freemarker.template.Template; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.MessageSource; import org.springframework.context.annotation.Lazy; @@ -64,55 +64,37 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -@Service @Slf4j +@Service +@RequiredArgsConstructor public class DefaultMailService implements MailService { - public static final String TARGET_EMAIL = "targetEmail"; - public static final String UTF_8 = "UTF-8"; + private static final String TARGET_EMAIL = "targetEmail"; + private static final String UTF_8 = "UTF-8"; + private static final long DEFAULT_TIMEOUT = 10_000; + + private final ScheduledExecutorService timeoutScheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("mail-service-watchdog"); private final MessageSource messages; private final Configuration freemarkerConfig; private final AdminSettingsService adminSettingsService; private final TbApiUsageReportClient apiUsageClient; - - private static final long DEFAULT_TIMEOUT = 10_000; - @Lazy - @Autowired - private TbApiUsageStateService apiUsageStateService; - - @Autowired - private MailSenderInternalExecutorService mailExecutorService; - - @Autowired - private PasswordResetExecutorService passwordResetExecutorService; - - @Autowired - private TbMailContextComponent ctx; - - @Autowired - private RateLimitService rateLimitService; + private final TbApiUsageStateService apiUsageStateService; + private final MailSenderInternalExecutorService mailExecutorService; + private final PasswordResetExecutorService passwordResetExecutorService; + private final TbMailContextComponent ctx; + private final RateLimitService rateLimitService; @Value("${mail.per_tenant_rate_limits:}") private String perTenantRateLimitConfig; - private final ScheduledExecutorService timeoutScheduler; - private TbMailSender mailSender; private String mailFrom; private long timeout; - public DefaultMailService(MessageSource messages, Configuration freemarkerConfig, AdminSettingsService adminSettingsService, TbApiUsageReportClient apiUsageClient) { - this.messages = messages; - this.freemarkerConfig = freemarkerConfig; - this.adminSettingsService = adminSettingsService; - this.apiUsageClient = apiUsageClient; - this.timeoutScheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("mail-service-watchdog"); - } - @PostConstruct private void init() { updateMailConfiguration(); @@ -120,9 +102,7 @@ public class DefaultMailService implements MailService { @PreDestroy public void destroy() { - if (timeoutScheduler != null) { - timeoutScheduler.shutdownNow(); - } + timeoutScheduler.shutdownNow(); } @Override @@ -311,22 +291,21 @@ public class DefaultMailService implements MailService { model.put("apiFeature", apiFeature.getLabel()); model.put(TARGET_EMAIL, email); - String message = null; - - switch (stateValue) { - case ENABLED: + String message = switch (stateValue) { + case ENABLED -> { model.put("apiLabel", toEnabledValueLabel(apiFeature)); - message = mergeTemplateIntoString("state.enabled.ftl", model); - break; - case WARNING: + yield mergeTemplateIntoString("state.enabled.ftl", model); + } + case WARNING -> { model.put("apiValueLabel", toDisabledValueLabel(apiFeature) + " " + toWarningValueLabel(recordState)); - message = mergeTemplateIntoString("state.warning.ftl", model); - break; - case DISABLED: + yield mergeTemplateIntoString("state.warning.ftl", model); + } + case DISABLED -> { model.put("apiLimitValueLabel", toDisabledValueLabel(apiFeature) + " " + toDisabledValueLabel(recordState)); - message = mergeTemplateIntoString("state.disabled.ftl", model); - break; - } + yield mergeTemplateIntoString("state.disabled.ftl", model); + } + }; + sendMail(mailSender, mailFrom, email, subject, message, timeout); } @@ -341,89 +320,55 @@ public class DefaultMailService implements MailService { } private String toEnabledValueLabel(ApiFeature apiFeature) { - switch (apiFeature) { - case DB: - return "save"; - case TRANSPORT: - return "receive"; - case JS: - return "invoke"; - case RE: - return "process"; - case EMAIL: - case SMS: - return "send"; - case ALARM: - return "create"; - default: - throw new RuntimeException("Not implemented!"); - } + return switch (apiFeature) { + case DB -> "save"; + case TRANSPORT -> "receive"; + case JS -> "invoke"; + case RE -> "process"; + case EMAIL, SMS -> "send"; + case ALARM -> "create"; + default -> throw new RuntimeException("Not implemented!"); + }; } private String toDisabledValueLabel(ApiFeature apiFeature) { - switch (apiFeature) { - case DB: - return "saved"; - case TRANSPORT: - return "received"; - case JS: - return "invoked"; - case RE: - return "processed"; - case EMAIL: - case SMS: - return "sent"; - case ALARM: - return "created"; - default: - throw new RuntimeException("Not implemented!"); - } + return switch (apiFeature) { + case DB -> "saved"; + case TRANSPORT -> "received"; + case JS -> "invoked"; + case RE -> "processed"; + case EMAIL, SMS -> "sent"; + case ALARM -> "created"; + default -> throw new RuntimeException("Not implemented!"); + }; } private String toWarningValueLabel(ApiUsageRecordState recordState) { String valueInM = recordState.getValueAsString(); String thresholdInM = recordState.getThresholdAsString(); - switch (recordState.getKey()) { - case STORAGE_DP_COUNT: - case TRANSPORT_DP_COUNT: - return valueInM + " out of " + thresholdInM + " allowed data points"; - case TRANSPORT_MSG_COUNT: - return valueInM + " out of " + thresholdInM + " allowed messages"; - case JS_EXEC_COUNT: - return valueInM + " out of " + thresholdInM + " allowed JavaScript functions"; - case TBEL_EXEC_COUNT: - return valueInM + " out of " + thresholdInM + " allowed Tbel functions"; - case RE_EXEC_COUNT: - return valueInM + " out of " + thresholdInM + " allowed Rule Engine messages"; - case EMAIL_EXEC_COUNT: - return valueInM + " out of " + thresholdInM + " allowed Email messages"; - case SMS_EXEC_COUNT: - return valueInM + " out of " + thresholdInM + " allowed SMS messages"; - default: - throw new RuntimeException("Not implemented!"); - } + return switch (recordState.getKey()) { + case STORAGE_DP_COUNT, TRANSPORT_DP_COUNT -> valueInM + " out of " + thresholdInM + " allowed data points"; + case TRANSPORT_MSG_COUNT -> valueInM + " out of " + thresholdInM + " allowed messages"; + case JS_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed JavaScript functions"; + case TBEL_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed Tbel functions"; + case RE_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed Rule Engine messages"; + case EMAIL_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed Email messages"; + case SMS_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed SMS messages"; + default -> throw new RuntimeException("Not implemented!"); + }; } private String toDisabledValueLabel(ApiUsageRecordState recordState) { - switch (recordState.getKey()) { - case STORAGE_DP_COUNT: - case TRANSPORT_DP_COUNT: - return recordState.getValueAsString() + " data points"; - case TRANSPORT_MSG_COUNT: - return recordState.getValueAsString() + " messages"; - case JS_EXEC_COUNT: - return "JavaScript functions " + recordState.getValueAsString() + " times"; - case TBEL_EXEC_COUNT: - return "TBEL functions " + recordState.getValueAsString() + " times"; - case RE_EXEC_COUNT: - return recordState.getValueAsString() + " Rule Engine messages"; - case EMAIL_EXEC_COUNT: - return recordState.getValueAsString() + " Email messages"; - case SMS_EXEC_COUNT: - return recordState.getValueAsString() + " SMS messages"; - default: - throw new RuntimeException("Not implemented!"); - } + return switch (recordState.getKey()) { + case STORAGE_DP_COUNT, TRANSPORT_DP_COUNT -> recordState.getValueAsString() + " data points"; + case TRANSPORT_MSG_COUNT -> recordState.getValueAsString() + " messages"; + case JS_EXEC_COUNT -> "JavaScript functions " + recordState.getValueAsString() + " times"; + case TBEL_EXEC_COUNT -> "TBEL functions " + recordState.getValueAsString() + " times"; + case RE_EXEC_COUNT -> recordState.getValueAsString() + " Rule Engine messages"; + case EMAIL_EXEC_COUNT -> recordState.getValueAsString() + " Email messages"; + case SMS_EXEC_COUNT -> recordState.getValueAsString() + " SMS messages"; + default -> throw new RuntimeException("Not implemented!"); + }; } private void sendMail(JavaMailSenderImpl mailSender, String mailFrom, String email, diff --git a/application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java b/application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java index 8d7356a507..914a53e8a6 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java @@ -25,6 +25,7 @@ import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.client.json.gson.GsonFactory; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.lang.Nullable; import org.springframework.mail.MailException; @@ -50,8 +51,10 @@ public class TbMailSender extends JavaMailSenderImpl { private final TbMailContextComponent ctx; private final Lock lock; + @Getter private final Boolean oauth2Enabled; private volatile String accessToken; + @Getter private volatile long tokenExpires; public TbMailSender(TbMailContextComponent ctx, JsonNode jsonConfig) { @@ -70,14 +73,6 @@ public class TbMailSender extends JavaMailSenderImpl { setJavaMailProperties(createJavaMailProperties(jsonConfig)); } - public Boolean getOauth2Enabled() { - return oauth2Enabled; - } - - public long getTokenExpires() { - return tokenExpires; - } - @Override protected void doSend(MimeMessage[] mimeMessages, @Nullable Object[] originalMessages) throws MailException { updateOauth2PasswordIfExpired(); @@ -98,8 +93,8 @@ public class TbMailSender extends JavaMailSenderImpl { super.testConnection(); } - public void updateOauth2PasswordIfExpired() { - if (getOauth2Enabled() && (System.currentTimeMillis() > getTokenExpires())){ + public void updateOauth2PasswordIfExpired() { + if (getOauth2Enabled() && (System.currentTimeMillis() > getTokenExpires())) { refreshAccessToken(); setPassword(accessToken); } @@ -168,8 +163,8 @@ public class TbMailSender extends JavaMailSenderImpl { .setClientAuthentication(new ClientParametersAuthentication(clientId, clientSecret)) .execute(); if (MailOauth2Provider.OFFICE_365.name().equals(providerId)) { - ((ObjectNode)jsonValue).put("refreshToken", tokenResponse.getRefreshToken()); - ((ObjectNode)jsonValue).put("refreshTokenExpires", Instant.now().plus(Duration.ofDays(AZURE_DEFAULT_REFRESH_TOKEN_LIFETIME_IN_DAYS)).toEpochMilli()); + ((ObjectNode) jsonValue).put("refreshToken", tokenResponse.getRefreshToken()); + ((ObjectNode) jsonValue).put("refreshTokenExpires", Instant.now().plus(Duration.ofDays(AZURE_DEFAULT_REFRESH_TOKEN_LIFETIME_IN_DAYS)).toEpochMilli()); ctx.getAdminSettingsService().saveAdminSettings(TenantId.SYS_TENANT_ID, settings); } accessToken = tokenResponse.getAccessToken(); @@ -190,4 +185,5 @@ public class TbMailSender extends JavaMailSenderImpl { throw new IncorrectParameterException(String.format("Invalid smtp port value: %s", strPort)); } } -} \ No newline at end of file + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java index 2a92c040e3..22b75ea3f5 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java @@ -21,7 +21,7 @@ import java.util.Collections; import java.util.Set; public enum Resource { - ADMIN_SETTINGS(), + ADMIN_SETTINGS(EntityType.ADMIN_SETTINGS), ALARM(EntityType.ALARM), DEVICE(EntityType.DEVICE), ASSET(EntityType.ASSET), diff --git a/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsSenderFactory.java b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsSenderFactory.java index 5522047deb..475c9c2c75 100644 --- a/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsSenderFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsSenderFactory.java @@ -31,16 +31,12 @@ public class DefaultSmsSenderFactory implements SmsSenderFactory { @Override public SmsSender createSmsSender(SmsProviderConfiguration config) { - switch (config.getType()) { - case AWS_SNS: - return new AwsSmsSender((AwsSnsSmsProviderConfiguration)config); - case TWILIO: - return new TwilioSmsSender((TwilioSmsProviderConfiguration)config); - case SMPP: - return new SmppSmsSender((SmppSmsProviderConfiguration) config); - default: - throw new RuntimeException("Unknown SMS provider type " + config.getType()); - } + return switch (config.getType()) { + case AWS_SNS -> new AwsSmsSender((AwsSnsSmsProviderConfiguration) config); + case TWILIO -> new TwilioSmsSender((TwilioSmsProviderConfiguration) config); + case SMPP -> new SmppSmsSender((SmppSmsProviderConfiguration) config); + default -> throw new RuntimeException("Unknown SMS provider type " + config.getType()); + }; } } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java index b803700582..5223a2a9c9 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java @@ -18,8 +18,9 @@ package org.thingsboard.server.dao.settings; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.id.AdminSettingsId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.entity.EntityDaoService; -public interface AdminSettingsService { +public interface AdminSettingsService extends EntityDaoService { AdminSettings findAdminSettingsById(TenantId tenantId, AdminSettingsId adminSettingsId); @@ -31,6 +32,4 @@ public interface AdminSettingsService { boolean deleteAdminSettingsByTenantIdAndKey(TenantId tenantId, String key); - void deleteAdminSettingsByTenantId(TenantId tenantId); - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java index af5fec1827..c2614ccec6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java @@ -22,9 +22,6 @@ import java.util.Arrays; import java.util.EnumSet; import java.util.List; -/** - * @author Andrew Shvayka - */ public enum EntityType { TENANT(1), CUSTOMER(2), @@ -65,7 +62,8 @@ public enum EntityType { MOBILE_APP_BUNDLE(38), CALCULATED_FIELD(39), CALCULATED_FIELD_LINK(40), - JOB(41); + JOB(41), + ADMIN_SETTINGS(42); @Getter private final int protoNumber; // Corresponds to EntityTypeProto diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java index 8f2104b5fd..05194b8df5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java @@ -17,14 +17,26 @@ package org.thingsboard.server.common.data.id; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import org.thingsboard.server.common.data.EntityType; +import java.io.Serial; import java.util.UUID; -public class AdminSettingsId extends UUIDBased { +public class AdminSettingsId extends UUIDBased implements EntityId { + + @Serial + private static final long serialVersionUID = -4208011957475806567L; @JsonCreator - public AdminSettingsId(@JsonProperty("id") UUID id){ + public AdminSettingsId(@JsonProperty("id") UUID id) { super(id); } - + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "ADMIN_SETTINGS", allowableValues = "ADMIN_SETTINGS") + @Override + public EntityType getEntityType() { + return EntityType.ADMIN_SETTINGS; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java index 9a20be4eb9..4ccabd39b8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java @@ -23,10 +23,12 @@ import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.ConcurrentReferenceHashMap.ReferenceType; import org.thingsboard.server.common.data.EntityType; +import java.io.Serial; import java.util.UUID; public class EdgeId extends UUIDBased implements EntityId { + @Serial private static final long serialVersionUID = 1L; @JsonIgnore @@ -51,4 +53,5 @@ public class EdgeId extends UUIDBased implements EntityId { public static EdgeId fromUUID(@JsonProperty("id") UUID id) { return edges.computeIfAbsent(id, EdgeId::new); } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java index 24196e28b5..ddfcd85dc8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java @@ -24,10 +24,6 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serializable; import java.util.UUID; -/** - * @author Andrew Shvayka - */ - @JsonDeserialize(using = EntityIdDeserializer.class) @JsonSerialize(using = EntityIdSerializer.class) @Schema diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java index d23e4c078d..fa4421e2c2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java @@ -20,9 +20,6 @@ import org.thingsboard.server.common.data.edge.EdgeEventType; import java.util.UUID; -/** - * Created by ashvayka on 25.04.17. - */ public class EntityIdFactory { public static EntityId getByTypeAndUuid(int type, String uuid) { @@ -50,131 +47,74 @@ public class EntityIdFactory { } public static EntityId getByTypeAndUuid(EntityType type, UUID uuid) { - switch (type) { - case TENANT: - return TenantId.fromUUID(uuid); - case CUSTOMER: - return new CustomerId(uuid); - case USER: - return new UserId(uuid); - case DASHBOARD: - return new DashboardId(uuid); - case DEVICE: - return new DeviceId(uuid); - case ASSET: - return new AssetId(uuid); - case ALARM: - return new AlarmId(uuid); - case RULE_CHAIN: - return new RuleChainId(uuid); - case RULE_NODE: - return new RuleNodeId(uuid); - case ENTITY_VIEW: - return new EntityViewId(uuid); - case WIDGETS_BUNDLE: - return new WidgetsBundleId(uuid); - case WIDGET_TYPE: - return new WidgetTypeId(uuid); - case DEVICE_PROFILE: - return new DeviceProfileId(uuid); - case ASSET_PROFILE: - return new AssetProfileId(uuid); - case TENANT_PROFILE: - return new TenantProfileId(uuid); - case API_USAGE_STATE: - return new ApiUsageStateId(uuid); - case TB_RESOURCE: - return new TbResourceId(uuid); - case OTA_PACKAGE: - return new OtaPackageId(uuid); - case EDGE: - return new EdgeId(uuid); - case RPC: - return new RpcId(uuid); - case QUEUE: - return new QueueId(uuid); - case NOTIFICATION_TARGET: - return new NotificationTargetId(uuid); - case NOTIFICATION_REQUEST: - return new NotificationRequestId(uuid); - case NOTIFICATION_RULE: - return new NotificationRuleId(uuid); - case NOTIFICATION_TEMPLATE: - return new NotificationTemplateId(uuid); - case NOTIFICATION: - return new NotificationId(uuid); - case QUEUE_STATS: - return new QueueStatsId(uuid); - case OAUTH2_CLIENT: - return new OAuth2ClientId(uuid); - case MOBILE_APP: - return new MobileAppId(uuid); - case DOMAIN: - return new DomainId(uuid); - case MOBILE_APP_BUNDLE: - return new MobileAppBundleId(uuid); - case CALCULATED_FIELD: - return new CalculatedFieldId(uuid); - case CALCULATED_FIELD_LINK: - return new CalculatedFieldLinkId(uuid); - case JOB: - return new JobId(uuid); - } - throw new IllegalArgumentException("EntityType " + type + " is not supported!"); + return switch (type) { + case TENANT -> TenantId.fromUUID(uuid); + case CUSTOMER -> new CustomerId(uuid); + case USER -> new UserId(uuid); + case DASHBOARD -> new DashboardId(uuid); + case DEVICE -> new DeviceId(uuid); + case ASSET -> new AssetId(uuid); + case ALARM -> new AlarmId(uuid); + case RULE_CHAIN -> new RuleChainId(uuid); + case RULE_NODE -> new RuleNodeId(uuid); + case ENTITY_VIEW -> new EntityViewId(uuid); + case WIDGETS_BUNDLE -> new WidgetsBundleId(uuid); + case WIDGET_TYPE -> new WidgetTypeId(uuid); + case DEVICE_PROFILE -> new DeviceProfileId(uuid); + case ASSET_PROFILE -> new AssetProfileId(uuid); + case TENANT_PROFILE -> new TenantProfileId(uuid); + case API_USAGE_STATE -> new ApiUsageStateId(uuid); + case TB_RESOURCE -> new TbResourceId(uuid); + case OTA_PACKAGE -> new OtaPackageId(uuid); + case EDGE -> EdgeId.fromUUID(uuid); + case RPC -> new RpcId(uuid); + case QUEUE -> new QueueId(uuid); + case NOTIFICATION_TARGET -> new NotificationTargetId(uuid); + case NOTIFICATION_REQUEST -> new NotificationRequestId(uuid); + case NOTIFICATION_RULE -> new NotificationRuleId(uuid); + case NOTIFICATION_TEMPLATE -> new NotificationTemplateId(uuid); + case NOTIFICATION -> new NotificationId(uuid); + case QUEUE_STATS -> new QueueStatsId(uuid); + case OAUTH2_CLIENT -> new OAuth2ClientId(uuid); + case MOBILE_APP -> new MobileAppId(uuid); + case DOMAIN -> new DomainId(uuid); + case MOBILE_APP_BUNDLE -> new MobileAppBundleId(uuid); + case CALCULATED_FIELD -> new CalculatedFieldId(uuid); + case CALCULATED_FIELD_LINK -> new CalculatedFieldLinkId(uuid); + case JOB -> new JobId(uuid); + case ADMIN_SETTINGS -> new AdminSettingsId(uuid); + default -> throw new IllegalArgumentException("EntityType " + type + " is not supported!"); + }; } public static EntityId getByEdgeEventTypeAndUuid(EdgeEventType edgeEventType, UUID uuid) { - switch (edgeEventType) { - case TENANT: - return TenantId.fromUUID(uuid); - case CUSTOMER: - return new CustomerId(uuid); - case USER: - return new UserId(uuid); - case DASHBOARD: - return new DashboardId(uuid); - case DEVICE: - return new DeviceId(uuid); - case ASSET: - return new AssetId(uuid); - case ALARM: - return new AlarmId(uuid); - case RULE_CHAIN: - return new RuleChainId(uuid); - case ENTITY_VIEW: - return new EntityViewId(uuid); - case WIDGETS_BUNDLE: - return new WidgetsBundleId(uuid); - case WIDGET_TYPE: - return new WidgetTypeId(uuid); - case DEVICE_PROFILE: - return new DeviceProfileId(uuid); - case ASSET_PROFILE: - return new AssetProfileId(uuid); - case TENANT_PROFILE: - return new TenantProfileId(uuid); - case OTA_PACKAGE: - return new OtaPackageId(uuid); - case EDGE: - return new EdgeId(uuid); - case QUEUE: - return new QueueId(uuid); - case TB_RESOURCE: - return new TbResourceId(uuid); - case NOTIFICATION_RULE: - return new NotificationRuleId(uuid); - case NOTIFICATION_TARGET: - return new NotificationTargetId(uuid); - case NOTIFICATION_TEMPLATE: - return new NotificationTemplateId(uuid); - case OAUTH2_CLIENT: - return new OAuth2ClientId(uuid); - case DOMAIN: - return new DomainId(uuid); - case CALCULATED_FIELD: - return new CalculatedFieldId(uuid); - } - throw new IllegalArgumentException("EdgeEventType " + edgeEventType + " is not supported!"); + return switch (edgeEventType) { + case TENANT -> TenantId.fromUUID(uuid); + case CUSTOMER -> new CustomerId(uuid); + case USER -> new UserId(uuid); + case DASHBOARD -> new DashboardId(uuid); + case DEVICE -> new DeviceId(uuid); + case ASSET -> new AssetId(uuid); + case ALARM -> new AlarmId(uuid); + case RULE_CHAIN -> new RuleChainId(uuid); + case ENTITY_VIEW -> new EntityViewId(uuid); + case WIDGETS_BUNDLE -> new WidgetsBundleId(uuid); + case WIDGET_TYPE -> new WidgetTypeId(uuid); + case DEVICE_PROFILE -> new DeviceProfileId(uuid); + case ASSET_PROFILE -> new AssetProfileId(uuid); + case TENANT_PROFILE -> new TenantProfileId(uuid); + case OTA_PACKAGE -> new OtaPackageId(uuid); + case EDGE -> EdgeId.fromUUID(uuid); + case QUEUE -> new QueueId(uuid); + case TB_RESOURCE -> new TbResourceId(uuid); + case NOTIFICATION_RULE -> new NotificationRuleId(uuid); + case NOTIFICATION_TARGET -> new NotificationTargetId(uuid); + case NOTIFICATION_TEMPLATE -> new NotificationTemplateId(uuid); + case OAUTH2_CLIENT -> new OAuth2ClientId(uuid); + case DOMAIN -> new DomainId(uuid); + case CALCULATED_FIELD -> new CalculatedFieldId(uuid); + default -> throw new IllegalArgumentException("EdgeEventType " + edgeEventType + " is not supported!"); + }; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java index 76678b8b31..372452825f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java @@ -20,10 +20,14 @@ import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.EntityType; +import java.io.Serial; import java.util.UUID; public class JobId extends UUIDBased implements EntityId { + @Serial + private static final long serialVersionUID = -2225072123132918395L; + @JsonCreator public JobId(@JsonProperty("id") UUID id) { super(id); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java index 57561fb7ee..7cad1698d7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java @@ -23,6 +23,7 @@ import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.ConcurrentReferenceHashMap.ReferenceType; import org.thingsboard.server.common.data.EntityType; +import java.io.Serial; import java.util.UUID; public final class TenantId extends UUIDBased implements EntityId { @@ -33,6 +34,7 @@ public final class TenantId extends UUIDBased implements EntityId { @JsonIgnore public static final TenantId SYS_TENANT_ID = TenantId.fromUUID(EntityId.NULL_UUID); + @Serial private static final long serialVersionUID = 1L; @JsonCreator diff --git a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java index 3cded5a491..6d010a7a0d 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java @@ -252,7 +252,7 @@ public class ProtoUtils { public static EdgeEvent fromProto(TransportProtos.EdgeEventMsgProto proto) { EdgeEvent edgeEvent = new EdgeEvent(); - TenantId tenantId = new TenantId(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())); + TenantId tenantId = TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())); edgeEvent.setTenantId(tenantId); edgeEvent.setType(EdgeEventType.valueOf(proto.getEntityType())); edgeEvent.setAction(EdgeEventActionType.valueOf(proto.getAction())); @@ -845,7 +845,7 @@ public class ProtoUtils { public static Device fromProto(TransportProtos.DeviceProto proto) { Device device = new Device(getEntityId(proto.getDeviceIdMSB(), proto.getDeviceIdLSB(), DeviceId::new)); device.setCreatedTime(proto.getCreatedTime()); - device.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::new)); + device.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::fromUUID)); device.setName(proto.getDeviceName()); device.setType(proto.getDeviceType()); device.setDeviceProfileId(getEntityId(proto.getDeviceProfileIdMSB(), proto.getDeviceProfileIdLSB(), DeviceProfileId::new)); @@ -937,7 +937,7 @@ public class ProtoUtils { public static DeviceProfile fromProto(TransportProtos.DeviceProfileProto proto) { DeviceProfile deviceProfile = new DeviceProfile(getEntityId(proto.getDeviceProfileIdMSB(), proto.getDeviceProfileIdLSB(), DeviceProfileId::new)); deviceProfile.setCreatedTime(proto.getCreatedTime()); - deviceProfile.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::new)); + deviceProfile.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::fromUUID)); deviceProfile.setName(proto.getName()); deviceProfile.setDefault(proto.getIsDefault()); deviceProfile.setType(DeviceProfileType.valueOf(proto.getType())); @@ -1028,7 +1028,7 @@ public class ProtoUtils { } public static Tenant fromProto(TransportProtos.TenantProto proto) { - Tenant tenant = new Tenant(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::new)); + Tenant tenant = new Tenant(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::fromUUID)); tenant.setCreatedTime(proto.getCreatedTime()); tenant.setTenantProfileId(getEntityId(proto.getTenantProfileIdMSB(), proto.getTenantProfileIdLSB(), TenantProfileId::new)); tenant.setTitle(proto.getTitle()); @@ -1142,7 +1142,7 @@ public class ProtoUtils { public static TbResource fromProto(TransportProtos.TbResourceProto proto) { TbResource resource = new TbResource(getEntityId(proto.getResourceIdMSB(), proto.getResourceIdLSB(), TbResourceId::new)); - resource.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::new)); + resource.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::fromUUID)); resource.setCreatedTime(proto.getCreatedTime()); resource.setTitle(proto.getTitle()); resource.setResourceType(ResourceType.valueOf(proto.getResourceType())); @@ -1198,7 +1198,7 @@ public class ProtoUtils { public static ApiUsageState fromProto(TransportProtos.ApiUsageStateProto proto) { ApiUsageState apiUsageState = new ApiUsageState(getEntityId(proto.getApiUsageStateIdMSB(), proto.getApiUsageStateIdLSB(), ApiUsageStateId::new)); - apiUsageState.setTenantId(getEntityId(proto.getTenantProfileIdMSB(), proto.getTenantProfileIdLSB(), TenantId::new)); + apiUsageState.setTenantId(getEntityId(proto.getTenantProfileIdMSB(), proto.getTenantProfileIdLSB(), TenantId::fromUUID)); apiUsageState.setCreatedTime(proto.getCreatedTime()); apiUsageState.setEntityId(EntityIdFactory.getByTypeAndUuid(fromProto(proto.getEntityType()), new UUID(proto.getEntityIdMSB(), proto.getEntityIdLSB()))); apiUsageState.setTransportState(ApiUsageStateValue.valueOf(proto.getTransportState())); diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 2667838b60..2709a2bdc5 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -64,6 +64,7 @@ enum EntityTypeProto { CALCULATED_FIELD = 39; CALCULATED_FIELD_LINK = 40; JOB = 41; + ADMIN_SETTINGS = 42; } enum ApiUsageRecordKeyProto { diff --git a/dao/src/main/java/org/thingsboard/server/dao/oauth2/HybridClientRegistrationRepository.java b/dao/src/main/java/org/thingsboard/server/dao/oauth2/HybridClientRegistrationRepository.java index bc84a58cd9..84dc22b36b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/oauth2/HybridClientRegistrationRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/oauth2/HybridClientRegistrationRepository.java @@ -37,8 +37,10 @@ public class HybridClientRegistrationRepository implements ClientRegistrationRep @Override public ClientRegistration findByRegistrationId(String registrationId) { OAuth2Client oAuth2Client = oAuth2ClientService.findOAuth2ClientById(TenantId.SYS_TENANT_ID, new OAuth2ClientId(UUID.fromString(registrationId))); - return oAuth2Client == null ? - null : toSpringClientRegistration(oAuth2Client); + if (oAuth2Client == null) { + return null; + } + return toSpringClientRegistration(oAuth2Client); } private ClientRegistration toSpringClientRegistration(OAuth2Client oAuth2Client){ diff --git a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsDao.java index b330ce8ee3..8c184c8c35 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsDao.java @@ -23,20 +23,8 @@ import java.util.UUID; public interface AdminSettingsDao extends Dao { - /** - * Save or update admin settings object - * - * @param adminSettings the admin settings object - * @return saved admin settings object - */ AdminSettings save(TenantId tenantId, AdminSettings adminSettings); - - /** - * Find admin settings by key. - * - * @param key the key - * @return the admin settings object - */ + AdminSettings findByTenantIdAndKey(UUID tenantId, String key); boolean removeByTenantIdAndKey(UUID tenantId, String key); diff --git a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java index b1b3b25a19..604a37f4bb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java @@ -21,11 +21,16 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.AdminSettingsId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.Validator; +import java.util.Optional; + @Service @Slf4j public class AdminSettingsServiceImpl implements AdminSettingsService { @@ -87,10 +92,25 @@ public class AdminSettingsServiceImpl implements AdminSettingsService { } @Override - public void deleteAdminSettingsByTenantId(TenantId tenantId) { + public void deleteByTenantId(TenantId tenantId) { adminSettingsDao.removeByTenantId(tenantId.getId()); } + @Override + public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { + adminSettingsDao.removeById(tenantId, id.getId()); + } + + @Override + public Optional> findEntity(TenantId tenantId, EntityId entityId) { + return Optional.ofNullable(adminSettingsDao.findById(tenantId, entityId.getId())); + } + + @Override + public EntityType getEntityType() { + return EntityType.ADMIN_SETTINGS; + } + private void dropTokenIfProviderInfoChanged(JsonNode newJsonValue, JsonNode oldJsonValue) { if (newJsonValue.has("enableOauth2") && newJsonValue.get("enableOauth2").asBoolean()) { if (!newJsonValue.get("providerId").equals(oldJsonValue.get("providerId")) || diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/settings/AdminSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/settings/AdminSettingsRepository.java index c27602421d..889de7cb74 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/settings/AdminSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/settings/AdminSettingsRepository.java @@ -22,9 +22,6 @@ import org.thingsboard.server.dao.model.sql.AdminSettingsEntity; import java.util.UUID; -/** - * Created by Valerii Sosliuk on 5/6/2017. - */ public interface AdminSettingsRepository extends JpaRepository { AdminSettingsEntity findByTenantIdAndKey(UUID tenantId, String key); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java index 68ce5e9d22..3dafa79c3f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java @@ -15,12 +15,12 @@ */ package org.thingsboard.server.dao.sql.settings; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.RequiredArgsConstructor; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -35,21 +35,10 @@ import java.util.UUID; @Component @SqlDao -@Slf4j +@RequiredArgsConstructor public class JpaAdminSettingsDao extends JpaAbstractDao implements AdminSettingsDao, TenantEntityDao { - @Autowired - private AdminSettingsRepository adminSettingsRepository; - - @Override - protected Class getEntityClass() { - return AdminSettingsEntity.class; - } - - @Override - protected JpaRepository getRepository() { - return adminSettingsRepository; - } + private final AdminSettingsRepository adminSettingsRepository; @Override public AdminSettings findByTenantIdAndKey(UUID tenantId, String key) { @@ -77,4 +66,19 @@ public class JpaAdminSettingsDao extends JpaAbstractDao getEntityClass() { + return AdminSettingsEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return adminSettingsRepository; + } + + @Override + public EntityType getEntityType() { + return EntityType.ADMIN_SETTINGS; + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index b9d09e55ba..45cb832fef 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -43,7 +43,6 @@ import org.thingsboard.server.dao.notification.NotificationSettingsService; import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.service.Validator; import org.thingsboard.server.dao.service.validator.TenantDataValidator; -import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.dao.trendz.TrendzSettingsService; import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; import org.thingsboard.server.dao.user.UserService; @@ -76,8 +75,6 @@ public class TenantServiceImpl extends AbstractCachedEntityService tenantDao.existsById(tenantId, tenantId.getId()), false); } - private PaginatedRemover tenantsRemover = new PaginatedRemover<>() { + private final PaginatedRemover tenantsRemover = new PaginatedRemover<>() { @Override protected PageData findEntities(TenantId tenantId, TenantId id, PageLink pageLink) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java index 93fad4c0e7..f4a80937d7 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java @@ -146,6 +146,7 @@ public class TenantIdLoader { tenantEntity = ctx.getNotificationRequestService().findNotificationRequestById(ctxTenantId, new NotificationRequestId(id)); break; case NOTIFICATION: + case ADMIN_SETTINGS: return ctxTenantId; case NOTIFICATION_RULE: tenantEntity = ctx.getNotificationRuleService().findNotificationRuleById(ctxTenantId, new NotificationRuleId(id)); diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java index 4cbc091bdc..60d2bd500a 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java @@ -29,6 +29,7 @@ import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache; import org.thingsboard.rule.engine.api.RuleEngineDeviceProfileCache; import org.thingsboard.rule.engine.api.RuleEngineRpcService; import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Dashboard; @@ -167,7 +168,6 @@ public class TenantIdLoaderTest { private TenantId tenantId; private TenantProfileId tenantProfileId; - private NotificationId notificationId; private AbstractListeningExecutor dbExecutor; @BeforeEach @@ -179,9 +179,8 @@ public class TenantIdLoaderTest { } }; dbExecutor.init(); - this.tenantId = new TenantId(UUID.randomUUID()); + this.tenantId = TenantId.fromUUID(UUID.randomUUID()); this.tenantProfileId = new TenantProfileId(UUID.randomUUID()); - this.notificationId = new NotificationId(UUID.randomUUID()); when(ctx.getTenantId()).thenReturn(tenantId); @@ -199,6 +198,7 @@ public class TenantIdLoaderTest { switch (entityType) { case TENANT: case NOTIFICATION: + case ADMIN_SETTINGS: break; case CUSTOMER: Customer customer = new Customer(); @@ -465,7 +465,7 @@ public class TenantIdLoaderTest { @Test public void test_findEntityIdAsync_other_tenant() { - checkTenant(new TenantId(UUID.randomUUID()), false); + checkTenant(TenantId.fromUUID(UUID.randomUUID()), false); } } From 6ecb3a49f16db626b391432408f3833f26b16117 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Tue, 8 Jul 2025 12:53:27 +0300 Subject: [PATCH 102/249] UI: AI models --- .../thingsboard/rule/engine/ai/TbAiNode.java | 1 + ui-ngx/src/app/core/http/ai-model.service.ts | 54 ++++ ui-ngx/src/app/core/http/entity.service.ts | 9 + ui-ngx/src/app/core/http/public-api.ts | 1 + ui-ngx/src/app/core/services/menu.models.ts | 14 +- ui-ngx/src/app/modules/common/modules-map.ts | 6 + .../common/time-unit-input.component.html | 91 +++++-- .../common/time-unit-input.component.ts | 20 ++ .../external/ai-config.component.html | 115 ++++++++ .../rule-node/external/ai-config.component.ts | 115 ++++++++ .../external-rule-node-config.module.ts | 7 +- .../app/modules/home/models/services.map.ts | 4 +- .../pages/ai-model/ai-model-routing.module.ts | 49 ++++ .../ai-model/ai-model-table-config.resolve.ts | 124 +++++++++ .../ai-model-table-header.component.html | 21 ++ .../ai-model-table-header.component.scss | 18 ++ .../ai-model-table-header.component.ts | 33 +++ .../home/pages/ai-model/ai-model.module.ts | 33 +++ .../modules/home/pages/home-pages.module.ts | 4 +- .../ai-model/ai-model-dialog.component.html | 248 ++++++++++++++++++ .../ai-model/ai-model-dialog.component.scss | 23 ++ .../ai-model/ai-model-dialog.component.ts | 168 ++++++++++++ .../check-connectivity-dialog.component.html | 58 ++++ .../check-connectivity-dialog.component.scss | 71 +++++ .../check-connectivity-dialog.component.ts | 87 ++++++ .../models-list-autocomplete.component.html | 41 +++ .../models-list-autocomplete.component.ts | 161 ++++++++++++ .../entity/entity-autocomplete.component.ts | 17 +- .../src/app/shared/models/ai-model.models.ts | 159 +++++++++++ ui-ngx/src/app/shared/models/constants.ts | 1 + .../app/shared/models/entity-type.models.ts | 15 +- .../src/app/shared/models/id/ai-model-id.ts | 27 ++ ui-ngx/src/app/shared/models/id/public-api.ts | 1 + ui-ngx/src/app/shared/models/public-api.ts | 3 +- ui-ngx/src/app/shared/shared.module.ts | 9 + .../assets/locale/locale.constant-en_US.json | 97 ++++++- ui-ngx/src/form.scss | 30 +++ 37 files changed, 1892 insertions(+), 43 deletions(-) create mode 100644 ui-ngx/src/app/core/http/ai-model.service.ts create mode 100644 ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html create mode 100644 ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts create mode 100644 ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/ai-model/ai-model.module.ts create mode 100644 ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html create mode 100644 ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.scss create mode 100644 ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts create mode 100644 ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.html create mode 100644 ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.scss create mode 100644 ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts create mode 100644 ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.html create mode 100644 ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts create mode 100644 ui-ngx/src/app/shared/models/ai-model.models.ts create mode 100644 ui-ngx/src/app/shared/models/id/ai-model-id.ts 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 62d92ef2ce..8c9017829a 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 @@ -57,6 +57,7 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie nodeDescription = "Interact with AI", nodeDetails = "This node makes requests to AI based on a prompt and a input message and returns a response in a form of output message", configClazz = TbAiNodeConfiguration.class, + configDirective = "tbExternalNodeAiConfig", ruleChainTypes = RuleChainType.CORE ) public final class TbAiNode extends TbAbstractExternalNode implements TbNode { diff --git a/ui-ngx/src/app/core/http/ai-model.service.ts b/ui-ngx/src/app/core/http/ai-model.service.ts new file mode 100644 index 0000000000..b2f63ba583 --- /dev/null +++ b/ui-ngx/src/app/core/http/ai-model.service.ts @@ -0,0 +1,54 @@ +/// +/// 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 { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; +import { Observable } from 'rxjs'; +import { AiModel, AiModelWithUserMsg, CheckConnectivityResult } from '@shared/models/ai-model.models'; +import { PageLink } from '@shared/models/page/page-link'; +import { PageData } from '@shared/models/page/page-data'; + +@Injectable({ + providedIn: 'root' +}) +export class AiModelService { + + constructor( + private http: HttpClient + ) {} + + public saveAiModel(aiModel: AiModel, config?: RequestConfig): Observable { + return this.http.post('/api/ai/model/settings', aiModel, defaultHttpOptionsFromConfig(config)); + } + + public getAiModels(pageLink: PageLink, config?: RequestConfig): Observable> { + return this.http.get>(`/api/ai/model/settings${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + } + + public getAiModelById(aiModelId: string, config?: RequestConfig): Observable { + return this.http.get(`/api/ai/model/settings/${aiModelId}`, defaultHttpOptionsFromConfig(config)); + } + + public deleteAiModel(aiModelId: string, config?: RequestConfig) { + return this.http.delete(`/api/ai/model/settings/${aiModelId}`, defaultHttpOptionsFromConfig(config)); + } + + public checkConnectivity(aiModelWithUserMsg: AiModelWithUserMsg, config?: RequestConfig): Observable { + return this.http.post('/api/ai/model/chat', aiModelWithUserMsg, defaultHttpOptionsFromConfig(config)); + } + +} diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 01fed28bf3..17a755ad30 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -99,6 +99,7 @@ import { ResourceService } from '@core/http/resource.service'; 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'; @Injectable({ providedIn: 'root' @@ -131,6 +132,7 @@ export class EntityService { private resourceService: ResourceService, private oauth2Service: OAuth2Service, private mobileAppService: MobileAppService, + private aiModelService: AiModelService, ) { } private getEntityObservable(entityType: EntityType, entityId: string, @@ -183,6 +185,9 @@ export class EntityService { case EntityType.MOBILE_APP_BUNDLE: observable = this.mobileAppService.getMobileAppBundleInfoById(entityId, config); break; + case EntityType.AI_MODEL_SETTINGS: + observable = this.aiModelService.getAiModelById(entityId, config); + break; } return observable; } @@ -485,6 +490,10 @@ export class EntityService { pageLink.sortOrder.property = 'title'; entitiesObservable = this.mobileAppService.getTenantMobileAppBundleInfos(pageLink, config); break; + case EntityType.AI_MODEL_SETTINGS: + pageLink.sortOrder.property = 'name'; + entitiesObservable = this.aiModelService.getAiModels(pageLink, config); + break; } return entitiesObservable; } diff --git a/ui-ngx/src/app/core/http/public-api.ts b/ui-ngx/src/app/core/http/public-api.ts index c28d80d173..63cc393ce6 100644 --- a/ui-ngx/src/app/core/http/public-api.ts +++ b/ui-ngx/src/app/core/http/public-api.ts @@ -48,3 +48,4 @@ export * from './user-settings.service'; export * from './widget.service'; export * from './usage-info.service'; export * from './trendz-settings.service' +export * from './ai-model.service' diff --git a/ui-ngx/src/app/core/services/menu.models.ts b/ui-ngx/src/app/core/services/menu.models.ts index 607c5c6dff..dc568c148e 100644 --- a/ui-ngx/src/app/core/services/menu.models.ts +++ b/ui-ngx/src/app/core/services/menu.models.ts @@ -105,7 +105,8 @@ export enum MenuId { otaUpdates = 'otaUpdates', version_control = 'version_control', api_usage = 'api_usage', - trendz_settings = 'trendz_settings' + trendz_settings = 'trendz_settings', + ai_models = 'ai_models' } declare type MenuFilter = (authState: AuthState) => boolean; @@ -286,6 +287,16 @@ export const menuSectionMap = new Map([ icon: 'mdi:message-cog' } ], + [ + MenuId.ai_models, + { + id: MenuId.ai_models, + name: 'ai-models.ai-models', + type: 'link', + path: '/ai-models', + icon: 'auto_awesome' + } + ], [ MenuId.mobile_center, { @@ -841,6 +852,7 @@ const defaultUserMenuMap = new Map([ {id: MenuId.notification_rules} ] }, + {id: MenuId.ai_models}, { id: MenuId.mobile_center, pages: [ diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index 70432e8cdf..997af07462 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -336,6 +336,9 @@ import * as DatapointsLimitComponent from '@shared/components/time/datapoints-li import * as AggregationTypeSelectComponent from '@shared/components/time/aggregation/aggregation-type-select.component'; import * as AggregationOptionsConfigComponent from '@shared/components/time/aggregation/aggregation-options-config-panel.component'; import * as IntervalOptionsConfigPanelComponent from '@shared/components/time/interval-options-config-panel.component'; +import * as AIModelDialogComponent from '@shared/components/ai-model/ai-model-dialog.component'; +import * as CheckConnectivityDialogComponent from '@shared/components/ai-model/check-connectivity-dialog.component'; +import * as ModelsListAutocompleteComponent from '@shared/components/ai-model/models-list-autocomplete.component'; import { IModulesMap } from '@modules/common/modules-map.models'; import { Observable, of } from 'rxjs'; @@ -532,6 +535,9 @@ class ModulesMap implements IModulesMap { '@shared/components/image/gallery-image-input.component': GalleryImageInputComponent, '@shared/components/image/multiple-gallery-image-input.component': MultipleGalleryImageInputComponent, '@shared/components/popover.service': TbPopoverService, + '@shared/components/ai-model/ai-model-dialog.component': AIModelDialogComponent, + '@shared/components/ai-model/check-connectivity-dialog.component': CheckConnectivityDialogComponent, + '@shared/components/ai-model/models-list-autocomplete.component': ModelsListAutocompleteComponent, '@home/components/alarm/alarm-filter-config.component': AlarmFilterConfigComponent, diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html index 37b0a2983c..3c1d85ed73 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html @@ -16,29 +16,72 @@ -->
- - {{ labelText }} - -
- + @if (outlined) { +
+
{{ labelText }}
+
+ + + + warning + + + warning + + + warning + + + + + @for (timeUnit of timeUnits; track timeUnit) { + {{ timeUnitTranslations.get(timeUnit) | translate }} + } + + +
- - - {{ requiredText }} - - - {{ minErrorText }} - - - {{ maxErrorText }} - - - - rule-node-config.units - - @for (timeUnit of timeUnits; track timeUnit) { - {{ timeUnitTranslations.get(timeUnit) | translate }} - } - - + } @else { + + {{ labelText }} + +
+ +
+ + + {{ requiredText }} + + + {{ minErrorText }} + + + {{ maxErrorText }} + +
+ + rule-node-config.units + + @for (timeUnit of timeUnits; track timeUnit) { + {{ timeUnitTranslations.get(timeUnit) | translate }} + } + + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts index b0d0a97641..2cd8e7d879 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts @@ -55,6 +55,9 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, @Input() labelText: string; + @Input() + labelIconHintTooltipText: string; + @Input() @coerceBoolean() required: boolean; @@ -79,6 +82,14 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, @Input() subscriptSizing: SubscriptSizing = 'fixed'; + @Input() + @coerceBoolean() + filterTimeUnitsByMaxTime = false; + + @Input() + @coerceBoolean() + outlined = false; + timeUnits = Object.values(TimeUnit).filter(item => item !== TimeUnit.MILLISECONDS) as TimeUnit[]; timeUnitTranslations = timeUnitTranslations; @@ -104,6 +115,15 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, } ngOnInit() { + if (this.filterTimeUnitsByMaxTime && this.maxTime) { + if (this.maxTime < 60) { + this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.MINUTES && item !== TimeUnit.HOURS && item !== TimeUnit.DAYS); + } else if (this.maxTime < 3600) { + this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.HOURS && item !== TimeUnit.DAYS); + } else if (this.maxTime < 86400) { + this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.DAYS); + } + } if(this.required || this.maxTime) { const timeControl = this.timeInputForm.get('time'); const validators = [Validators.pattern(/^\d*$/)]; 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 new file mode 100644 index 0000000000..f085913d5c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html @@ -0,0 +1,115 @@ + +
+
+
+ {{ 'rule-node-config.ai.ai-model' | translate }} +
+
+ + +
+
+ +
+ + + + {{'rule-node-config.ai.prompt-settings' | translate}} + + +
+ + rule-node-config.ai.system-prompt + + + {{ 'rule-node-config.ai.system-prompt-max-length' | translate }} + + + {{ 'rule-node-config.ai.system-prompt-blank' | translate }} + + + + rule-node-config.ai.user-prompt + + + {{ 'rule-node-config.ai.user-prompt-required' | translate }} + + + {{ 'rule-node-config.ai.user-prompt-max-length' | translate }} + + + {{ 'rule-node-config.ai.user-prompt-blank' | translate }} + + +
+
+
+ +
+
+
+ {{ 'rule-node-config.ai.response-format' | translate }} +
+ + {{ 'rule-node-config.ai.response-text' | translate }} + {{ 'rule-node-config.ai.response-json' | translate }} + {{ 'rule-node-config.ai.response-json-schema' | translate }} + +
+ @if (aiConfigForm.get('responseFormat.type').value === responseFormat.JSON_SCHEMA) { + + + } +
+ +
+ + + rule-node-config.ai.advanced-settings + +
+ + +
+
+
+
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 new file mode 100644 index 0000000000..283abb636c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts @@ -0,0 +1,115 @@ +/// +/// 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 { Component } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { MatDialog } from '@angular/material/dialog'; +import { AIModelDialogComponent, AIModelDialogData } from '@shared/components/ai-model/ai-model-dialog.component'; +import { AiModel, AiProvider } from '@shared/models/ai-model.models'; +import { deepTrim } from '@core/utils'; + +enum ResponseFormat { + TEXT = 'TEXT', + JSON = 'JSON', + JSON_SCHEMA = 'JSON_SCHEMA' +} + +@Component({ + selector: 'tb-external-node-ai-config', + templateUrl: './ai-config.component.html', + styleUrls: [] +}) +export class AiConfigComponent extends RuleNodeConfigurationComponent { + + aiConfigForm: UntypedFormGroup; + + entityType = EntityType; + + responseFormat = ResponseFormat; + + constructor(private fb: UntypedFormBuilder, + private dialog: MatDialog) { + super(); + } + + protected configForm(): UntypedFormGroup { + return this.aiConfigForm; + } + + protected onConfigurationSet(configuration: RuleNodeConfiguration) { + this.aiConfigForm = this.fb.group({ + modelSettingsId: [configuration ? configuration.modelSettingsId : null, [Validators.required]], + systemPrompt: [configuration ? configuration.systemPrompt : '', [Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], + userPrompt: [configuration ? configuration.userPrompt : '', [Validators.required, Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], + responseFormat: this.fb.group({ + type: [configuration ? configuration.responseFormat.type : ResponseFormat.JSON, []], + schema: [configuration ? configuration.responseFormat.schema : null, []], + }), + timeoutSeconds: [configuration ? configuration.timeoutSeconds : 60, []] + }); + } + + protected validatorTriggers(): string[] { + return ['responseFormat.type']; + } + + protected updateValidators(emitEvent: boolean) { + const responseFormatType = this.aiConfigForm.get('responseFormat.type').value; + if (responseFormatType === ResponseFormat.JSON_SCHEMA) { + this.aiConfigForm.get('responseFormat.schema').setValidators([Validators.required]); + this.aiConfigForm.get('responseFormat.schema').enable(); + } else { + this.aiConfigForm.get('responseFormat.schema').setValidators([]); + this.aiConfigForm.get('responseFormat.schema').disable(); + } + this.aiConfigForm.get('responseFormat.schema').updateValueAndValidity({emitEvent}); + } + + protected prepareOutputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration { + return deepTrim(configuration); + } + + onEntityChange($event: AiModel) { + if ($event) { + if ($event.configuration.provider === AiProvider.AMAZON_BEDROCK || + $event.configuration.provider === AiProvider.ANTHROPIC || + $event.configuration.provider === AiProvider.GITHUB_MODELS) { + this.aiConfigForm.get('responseFormat.type').patchValue(ResponseFormat.TEXT, {emitEvent: false}); + this.aiConfigForm.get('responseFormat.type').disable({emitEvent: false}); + } + } else { + this.aiConfigForm.get('responseFormat.type').enable({emitEvent: false}); + } + } + + createModelAi(formControl: string,) { + this.dialog.open(AIModelDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd: true + } + }).afterClosed() + .subscribe((model) => { + if (model) { + this.aiConfigForm.get(formControl).patchValue(model.id); + this.aiConfigForm.get(formControl).markAsDirty(); + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts index d2ed9c4f26..955c555989 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts @@ -32,6 +32,7 @@ import { HomeComponentsModule } from '@home/components/public-api'; import { CommonRuleNodeConfigModule } from '../common/common-rule-node-config.module'; import { SlackConfigComponent } from './slack-config.component'; import { LambdaConfigComponent } from './lambda-config.component'; +import { AiConfigComponent } from '@home/components/rule-node/external/ai-config.component'; @NgModule({ declarations: [ @@ -47,7 +48,8 @@ import { LambdaConfigComponent } from './lambda-config.component'; SendEmailConfigComponent, AzureIotHubConfigComponent, SendSmsConfigComponent, - SlackConfigComponent + SlackConfigComponent, + AiConfigComponent ], imports: [ CommonModule, @@ -68,7 +70,8 @@ import { LambdaConfigComponent } from './lambda-config.component'; SendEmailConfigComponent, AzureIotHubConfigComponent, SendSmsConfigComponent, - SlackConfigComponent + SlackConfigComponent, + AiConfigComponent ] }) export class ExternalRuleNodeConfigModule { diff --git a/ui-ngx/src/app/modules/home/models/services.map.ts b/ui-ngx/src/app/modules/home/models/services.map.ts index e4217b726b..ebccba900c 100644 --- a/ui-ngx/src/app/modules/home/models/services.map.ts +++ b/ui-ngx/src/app/modules/home/models/services.map.ts @@ -54,6 +54,7 @@ import { EventService } from '@core/http/event.service'; import { UnitService } from '@core/services/unit.service'; import { AuditLogService } from '@core/http/audit-log.service'; import { TrendzSettingsService } from '@core/http/trendz-settings.service'; +import { AiModelService } from '@core/http/ai-model.service'; export const ServicesMap = new Map>( [ @@ -95,6 +96,7 @@ export const ServicesMap = new Map>( ['eventService', EventService], ['unitService', UnitService], ['auditLogService', AuditLogService], - ['trendzSettingsService', TrendzSettingsService] + ['trendzSettingsService', TrendzSettingsService], + ['aiModelService', AiModelService] ] ); diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts new file mode 100644 index 0000000000..82fff06270 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts @@ -0,0 +1,49 @@ +/// +/// 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 { RouterModule, Routes } from '@angular/router'; +import { EntitiesTableComponent } from '@home/components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { MenuId } from '@core/services/menu.models'; +import { AiModelsTableConfigResolver } from '@home/pages/ai-model/ai-model-table-config.resolve'; +import { NgModule } from '@angular/core'; + +export const aiModelRoutes: Routes = [ + { + path: 'ai-models', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'ai-models.ai-models', + breadcrumb: { + menuId: MenuId.ai_models + } + }, + resolve: { + entitiesTableConfig: AiModelsTableConfigResolver + } + } +]; + +@NgModule({ + providers: [ + AiModelsTableConfigResolver + ], + imports: [RouterModule.forChild(aiModelRoutes)], + exports: [RouterModule], +}) +export class AiModelRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts new file mode 100644 index 0000000000..9f135d002c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts @@ -0,0 +1,124 @@ +/// +/// 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 { Injectable } from '@angular/core'; +import { + CellActionDescriptor, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { ActivatedRouteSnapshot } from '@angular/router'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { Direction } from '@shared/models/page/sort-order'; +import { DatePipe } from '@angular/common'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Observable } from 'rxjs'; +import { AiModel, AiProviderTranslations } from '@shared/models/ai-model.models'; +import { AiModelService } from '@core/http/ai-model.service'; +import { AiModelTableHeaderComponent } from '@home/pages/ai-model/ai-model-table-header.component'; +import { AIModelDialogComponent, AIModelDialogData } from '@shared/components/ai-model/ai-model-dialog.component'; +import { map } from 'rxjs/operators'; + +@Injectable() +export class AiModelsTableConfigResolver { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + constructor( + private datePipe: DatePipe, + private aiModelService: AiModelService, + private translate : TranslateService, + private dialog: MatDialog + ) { + this.config.selectionEnabled = true; + this.config.entityType = EntityType.AI_MODEL_SETTINGS; + this.config.addAsTextButton = true; + this.config.detailsPanelEnabled = false; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.AI_MODEL_SETTINGS); + this.config.entityResources = entityTypeResources.get(EntityType.AI_MODEL_SETTINGS); + + this.config.headerComponent = AiModelTableHeaderComponent; + this.config.addDialogStyle = {width: '850px', maxHeight: '100vh'}; + this.config.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; + + this.config.addEntity = () => this.addModel(null, true); + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '170px'), + new EntityTableColumn('name', 'ai-models.name', '33%'), + new EntityTableColumn('provider', 'ai-models.ai-provider', '33%', + entity => this.translate.instant(AiProviderTranslations.get(entity.configuration.provider)) + ), + new EntityTableColumn('aiModel', 'ai-models.ai-model', '33%', + entity => entity.configuration.modelConfig.modelId, () => ({}), false + ) + ) + + this.config.deleteEntityTitle = model => this.translate.instant('ai-models.delete-model-title', {modelName: model.name}); + this.config.deleteEntityContent = () => this.translate.instant('ai-models.delete-model-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('ai-models.delete-models-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('ai-models.delete-models-text'); + + this.config.deleteEntity = id => this.aiModelService.deleteAiModel(id.id); + + this.config.entitiesFetchFunction = pageLink => this.aiModelService.getAiModels(pageLink); + + this.config.cellActionDescriptors = this.configureCellActions(); + } + + resolve(_route: ActivatedRouteSnapshot): EntityTableConfig { + return this.config; + } + + private configureCellActions(): Array> { + return [ + { + name: this.translate.instant('action.edit'), + icon: 'edit', + isEnabled: () => true, + onAction: ($event, entity) => this.editModel(entity) + } + ]; + } + + private editModel(AIModel: AiModel): void { + this.addModel(AIModel, false).subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + private addModel(AIModel: AiModel, isAdd = false): Observable { + return this.dialog.open(AIModelDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd, + AIModel + } + }).afterClosed().pipe(map(res => { + if (res) { + this.config.updateData(); + return res; + } else { + return null; + } + })); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.html b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.html new file mode 100644 index 0000000000..43f1dbf0db --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.html @@ -0,0 +1,21 @@ + +
+
ai-models.ai-models
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss new file mode 100644 index 0000000000..acb9e2dc87 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss @@ -0,0 +1,18 @@ +/** + * 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{ + width: 100%; +} diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts new file mode 100644 index 0000000000..4fbc906b80 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts @@ -0,0 +1,33 @@ +/// +/// 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 { Component } from '@angular/core'; +import { EntityTableHeaderComponent } from '@home/components/entity/entity-table-header.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { AiModel } from '@shared/models/ai-model.models'; + +@Component({ + selector: 'tb-ai-model-table-header', + templateUrl: './ai-model-table-header.component.html', + styleUrls: ['./ai-model-table-header.component.scss'] +}) +export class AiModelTableHeaderComponent extends EntityTableHeaderComponent { + + constructor(protected store: Store) { + super(store); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model.module.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model.module.ts new file mode 100644 index 0000000000..bd20612e8a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model.module.ts @@ -0,0 +1,33 @@ +/// +/// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { AiModelRoutingModule } from '@home/pages/ai-model/ai-model-routing.module'; +import { AiModelTableHeaderComponent } from '@home/pages/ai-model/ai-model-table-header.component'; + +@NgModule({ + declarations: [ + AiModelTableHeaderComponent + ], + imports: [ + CommonModule, + SharedModule, + AiModelRoutingModule + ] +}) +export class AiModelModule { } diff --git a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts index 39c442cc49..5bb3954cae 100644 --- a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts +++ b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts @@ -46,6 +46,7 @@ import { AccountModule } from '@home/pages/account/account.module'; import { ScadaSymbolModule } from '@home/pages/scada-symbol/scada-symbol.module'; import { GatewaysModule } from '@home/pages/gateways/gateways.module'; import { MobileModule } from '@home/pages/mobile/mobile.module'; +import { AiModelModule } from '@home/pages/ai-model/ai-model.module'; @NgModule({ exports: [ @@ -78,7 +79,8 @@ import { MobileModule } from '@home/pages/mobile/mobile.module'; UserModule, VcModule, AccountModule, - ScadaSymbolModule + ScadaSymbolModule, + AiModelModule, ] }) export class HomePagesModule { } diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html new file mode 100644 index 0000000000..1a8fae3b45 --- /dev/null +++ b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html @@ -0,0 +1,248 @@ + + +

{{ dialogTitle | translate }}

+ +
+ +
+ + +
+
+ +
+ + ai-models.name + + + + {{ 'ai-models.name-required' | translate }} + + + {{ 'ai-models.name-max-length' | translate }} + + +
+
+
+
ai-models.provider
+
+ + ai-models.ai-provider + + + {{providerTranslationMap.get(provider) | translate}} + + + +
+ @if (provider === aiProvider.GITHUB_MODELS) { + + ai-models.personal-access-token + + + + {{ 'ai-models.personal-access-token-required' | translate }} + + + } @else if (provider === aiProvider.GOOGLE_VERTEX_AI_GEMINI) { + + ai-models.project-id + + + {{ 'ai-models.project-id-required' | translate }} + + + + ai-models.location + + + {{ 'ai-models.location-required' | translate }} + + + + + } @else { + @if (provider === aiProvider.AZURE_OPENAI) { + + ai-models.endpoint + + + {{ 'ai-models.endpoint-required' | translate }} + + + + ai-models.service-version + + + } + + ai-models.api-key + + + + {{ 'ai-models.api-key-required' | translate }} + + + } +
+
+
+
+
ai-models.configuration
+
+
+ + +
+
+
+ {{ 'ai-models.temperature' | translate }} +
+ + + + warning + + +
+
+
+ {{ 'ai-models.top-p' | translate }} +
+ + + + warning + + +
+ + @if (provider === aiProvider.GOOGLE_AI_GEMINI || provider === aiProvider.GOOGLE_VERTEX_AI_GEMINI || provider === aiProvider.ANTHROPIC) { +
+
+ {{ 'ai-models.top-k' | translate }} +
+ + + + warning + + +
+ } + + @if (provider !== aiProvider.ANTHROPIC && provider !== aiProvider.AMAZON_BEDROCK) { +
+
+ {{ 'ai-models.presence-penalty' | translate }} +
+ + + +
+
+
+ {{ 'ai-models.frequency-penalty' | translate }} +
+ + + +
+ } + +
+
+ {{ 'ai-models.max-output-token' | translate }} +
+ + + + warning + + +
+
+
+
+ +
+
+ + + + +
diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.scss b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.scss new file mode 100644 index 0000000000..c55453c388 --- /dev/null +++ b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.scss @@ -0,0 +1,23 @@ +/** + * 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 { + width: 850px; + height: 100%; + max-width: 100%; + max-height: 100vh; + display: grid; +} diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts new file mode 100644 index 0000000000..5dd63d3f5b --- /dev/null +++ b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts @@ -0,0 +1,168 @@ +/// +/// 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 { Component, Inject } 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, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Observable } from 'rxjs'; +import { StepperOrientation } from '@angular/cdk/stepper'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { EntityType } from '@shared/models/entity-type.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AiModel, AiProvider, AiProviderTranslations, AiProviderWithApiKey } from '@shared/models/ai-model.models'; +import { AiModelService } from '@core/http/ai-model.service'; +import { CheckConnectivityDialogComponent } from '@shared/components/ai-model/check-connectivity-dialog.component'; + +export interface AIModelDialogData { + AIModel?: AiModel; + isAdd?: boolean; +} + +@Component({ + selector: 'tb-ai-model-dialog', + templateUrl: './ai-model-dialog.component.html', + styleUrls: ['./ai-model-dialog.component.scss'] +}) +export class AIModelDialogComponent extends DialogComponent { + + readonly entityType = EntityType; + + selectedIndex = 0; + + dialogTitle = 'ai-models.ai-model'; + + stepperOrientation: Observable; + + aiProvider = AiProvider; + providerMap: AiProvider[] = Object.keys(AiProvider) as AiProvider[]; + providerTranslationMap = AiProviderTranslations; + + aiProviderWithApiKey: AiProvider[] = AiProviderWithApiKey; + + provider: AiProvider = AiProvider.OPENAI; + + aiModelForms: FormGroup; + + isAdd = false; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: AIModelDialogData, + private fb: FormBuilder, + private aiModelService: AiModelService, + private dialog: MatDialog) { + super(store, router, dialogRef); + + if (this.data.isAdd) { + this.isAdd = true; + } + + this.provider = this.data.AIModel ? this.data.AIModel.configuration.provider : AiProvider.OPENAI; + + this.aiModelForms = this.fb.group({ + name: [this.data.AIModel ? this.data.AIModel.name : '', [Validators.required, Validators.maxLength(255)]], + modelType: ['CHAT'], + configuration: this.fb.group({ + provider: [this.provider, [Validators.required]], + providerConfig: this.fb.group({ + apiKey: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.apiKey : '', [Validators.required]], + personalAccessToken: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.personalAccessToken : '', [Validators.required]], + endpoint: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.endpoint : '', [Validators.required]], + serviceVersion: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.serviceVersion : ''], + projectId: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.projectId : '', [Validators.required]], + location: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.location : '', [Validators.required]], + serviceAccountKey: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.serviceAccountKey : '', [Validators.required]], + serviceAccountKeyFileName: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.serviceAccountKeyFileName : '', [Validators.required]], + }), + modelConfig: this.fb.group({ + modelId: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.modelId : '', [Validators.required]], + temperature: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.temperature : null, [Validators.min(0)]], + topP: this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.topP : [null, [Validators.min(0.1), Validators.max(1)]], + topK: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.topK : null, [Validators.min(0)]], + frequencyPenalty: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.frequencyPenalty : null], + presencePenalty: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.presencePenalty : null], + maxOutputTokens: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.maxOutputTokens : null, [Validators.min(1)]] + }) + }) + }); + + this.aiModelForms.get('configuration.provider').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((provider: AiProvider) => { + this.provider = provider; + this.aiModelForms.get('configuration.modelConfig').reset({}); + this.aiModelForms.get('configuration.providerConfig').reset({}); + this.updateValidation(provider); + }) + + this.updateValidation(this.provider); + } + + private updateValidation(provider: AiProvider) { + const providerConfig = this.aiModelForms.get('configuration.providerConfig'); + if (this.aiProviderWithApiKey.includes(provider)) { + providerConfig.get('apiKey').enable(); + } else { + providerConfig.get('apiKey').disable(); + } + if (provider === AiProvider.GITHUB_MODELS) { + providerConfig.get('personalAccessToken').enable(); + } else { + providerConfig.get('personalAccessToken').disable(); + } + if (provider === AiProvider.GOOGLE_VERTEX_AI_GEMINI) { + providerConfig.get('projectId').enable(); + providerConfig.get('location').enable(); + providerConfig.get('serviceAccountKey').enable(); + providerConfig.get('serviceAccountKeyFileName').enable(); + } else { + providerConfig.get('projectId').disable(); + providerConfig.get('location').disable(); + providerConfig.get('serviceAccountKey').disable(); + providerConfig.get('serviceAccountKeyFileName').disable(); + } + if (provider === AiProvider.AZURE_OPENAI) { + providerConfig.get('endpoint').enable(); + providerConfig.get('serviceVersion').enable(); + } else { + providerConfig.get('endpoint').disable(); + providerConfig.get('serviceVersion').disable(); + } + } + + cancel(): void { + this.dialogRef.close(null); + } + + checkConnectivity() { + return this.dialog.open(CheckConnectivityDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + AIModel: this.aiModelForms.value + } + }).afterClosed(); + } + + add(): void { + const aiModel = {...this.data.AIModel, ...this.aiModelForms.value} as AiModel; + this.aiModelService.saveAiModel(aiModel).subscribe(aiModel => this.dialogRef.close(aiModel)); + } +} diff --git a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.html b/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.html new file mode 100644 index 0000000000..9ead874275 --- /dev/null +++ b/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.html @@ -0,0 +1,58 @@ + +
+

ai-models.check-connectivity

+ + +
+
+
+ + +
+
+ check_circle +
+ {{ "ai-models.check-connectivity-success" | translate }} +
+
+
+ cancel +
+
{{ "ai-models.check-connectivity-failed" | translate }}
+ + +
+
+
+
+ + +
diff --git a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.scss b/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.scss new file mode 100644 index 0000000000..1c6195e246 --- /dev/null +++ b/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.scss @@ -0,0 +1,71 @@ +/** + * 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 { + width: 560px; + height: 100%; + max-width: 100%; + max-height: 100vh; + display: grid; + + .connectivity-title { + font-size: 18px; + font-weight: 500; + margin: 0; + padding-left: 16px; + } + + .connection-status { + font-weight: 500; + letter-spacing: 0.25px; + text-align: center; + } + + .connection-icon { + height: 32px; + font-size: 32px; + width: 32px; + margin-bottom: 4px; + } + + .error_msg { + text-align: center; + margin-top: 8px; + font-size: 14px; + line-height: 130%; + letter-spacing: 0.25px; + opacity: 0.9; + } + + .success { + color: #198038; + } + + .error { + color: #D12730; + } + + ::ng-deep { + .json-editor { + .tb-json-object-toolbar { + display: none; + } + .tb-json-panel { + margin: 0; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts b/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts new file mode 100644 index 0000000000..70896cdc6d --- /dev/null +++ b/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts @@ -0,0 +1,87 @@ +/// +/// 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 { Component, Inject } 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 { AiModel, AiModelWithUserMsg } from '@shared/models/ai-model.models'; +import { AiModelService } from '@core/http/ai-model.service'; + +export interface AIModelDialogData { + AIModel?: AiModel; +} + +@Component({ + selector: 'tb-check-connectivity-dialog', + templateUrl: './check-connectivity-dialog.component.html', + styleUrls: ['./check-connectivity-dialog.component.scss'] +}) +export class CheckConnectivityDialogComponent extends DialogComponent { + + showCheckSuccess = false; + checkErrMsg = ''; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: AIModelDialogData, + private aiModelService: AiModelService) { + super(store, router, dialogRef); + + if (this.data.AIModel) { + const aiModelWithMsg: AiModelWithUserMsg = { + userMessage: { + contents: [ + { + contentType: "TEXT", + text: "What is the capital of Ukraine?" + } + ] + }, + chatModel: { + modelType: "CHAT", + provider: this.data.AIModel.configuration.provider, + providerConfig: {...this.data.AIModel.configuration.providerConfig}, + modelConfig: { + modelId: this.data.AIModel.configuration.modelConfig.modelId, + maxRetries: 0, + timeoutSeconds: 20 + } + } + } + this.aiModelService.checkConnectivity(aiModelWithMsg, { + ignoreErrors: true, + ignoreLoading: true + }).subscribe({ + next: (result) => { + if (result.status === 'SUCCESS') { + this.showCheckSuccess = true; + } else { + this.checkErrMsg = JSON.parse(result.errorDetails); + } + }, + error: err => this.checkErrMsg = err.error.message + }); + } + } + + cancel(): void { + this.dialogRef.close(null); + } +} diff --git a/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.html b/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.html new file mode 100644 index 0000000000..d204013805 --- /dev/null +++ b/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.html @@ -0,0 +1,41 @@ + + + {{label}} + + + + {{errorText}} + + + + + + + diff --git a/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts b/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts new file mode 100644 index 0000000000..a5cbdb332d --- /dev/null +++ b/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts @@ -0,0 +1,161 @@ +/// +/// 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 { Component, ElementRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormControl, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { Observable } from 'rxjs'; +import { map, startWith, tap } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; +import { AiModelMap, AiProvider } from '@shared/models/ai-model.models'; + +@Component({ + selector: 'tb-models-list-autocomplete', + templateUrl: './models-list-autocomplete.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ModelsListAutocompleteComponent), + multi: true + } + ] +}) +export class ModelsListAutocompleteComponent implements ControlValueAccessor, OnInit, OnChanges { + + @ViewChild('nameInput', {static: true}) nameInput: ElementRef; + + @Input() + disabled: boolean; + + @Input() + @coerceBoolean() + required = false; + + @Input() + provider: AiProvider; + + @Input() + placeholderText: string = this.translate.instant('widget-config.set'); + + @Input() + subscriptSizing: SubscriptSizing = 'dynamic'; + + @Input() + appearance: MatFormFieldAppearance = 'outline'; + + @Input() + label: string; + + @Input() + errorText: string; + + selectionFormControl: FormControl; + modelValue: string | null; + + filteredOptions$: Observable>; + + searchText = ''; + + private dirty = false; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: FormBuilder, + private translate: TranslateService) { + } + + ngOnInit() { + this.selectionFormControl = this.fb.control('', this.required ? [Validators.required] : []); + this.setupFilteredOptions(); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.provider && !changes.provider.isFirstChange()) { + this.setupFilteredOptions(); + this.selectionFormControl.setValue(null, {emitEvent: false}); + this.modelValue = null; + this.propagateChange(null); + } + } + + private setupFilteredOptions() { + this.filteredOptions$ = this.selectionFormControl.valueChanges.pipe( + startWith(''), + tap(value => this.updateView(value)), + map(value => { + const search = value ? value.toLowerCase() : ''; + const options = this.provider ? AiModelMap.get(this.provider) || [] : []; + return search ? options.filter(option => option.toLowerCase().includes(search)) : options; + }) + ); + } + + writeValue(option?: string): void { + this.searchText = ''; + this.modelValue = option ? option : null; + + if (option) { + this.selectionFormControl.patchValue(option, { emitEvent: false }); + this.dirty = true; + } else { + this.selectionFormControl.patchValue(null, { emitEvent: false }); + this.dirty = true; + } + } + + onFocus() { + if (this.dirty) { + this.selectionFormControl.updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + updateView(value: string) { + this.searchText = value ? value : ''; + if (this.modelValue !== value && value) { + this.modelValue = value; + this.propagateChange(this.modelValue); + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.selectionFormControl.disable({emitEvent: false}); + } else { + this.selectionFormControl.enable({emitEvent: false}); + } + } + + clear() { + this.selectionFormControl.patchValue(null, {emitEvent: false}); + this.propagateChange(null); + this.modelValue = null; + setTimeout(() => { + this.nameInput.nativeElement.blur(); + this.nameInput.nativeElement.focus(); + }, 0); + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts index ac8054283a..403502d56a 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts @@ -14,16 +14,7 @@ /// limitations under the License. /// -import { - Component, - ElementRef, - EventEmitter, - forwardRef, - Input, - OnInit, - Output, - ViewChild -} from '@angular/core'; +import { Component, ElementRef, EventEmitter, forwardRef, Input, OnInit, Output, ViewChild } from '@angular/core'; import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { firstValueFrom, merge, Observable, of, Subject } from 'rxjs'; @@ -300,6 +291,12 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit this.entityRequiredText = 'notification.notification-recipient-required'; this.notFoundEntities = 'notification.no-recipients-text'; break; + case EntityType.AI_MODEL_SETTINGS: + this.entityText = 'ai-models.ai-model'; + this.noEntitiesMatchingText = 'ai-models.no-model-matching'; + this.entityRequiredText = 'ai-models.model-required'; + this.notFoundEntities = 'ai-models.no-model-text'; + break; case AliasEntityType.CURRENT_CUSTOMER: this.entityText = 'customer.default-customer'; this.noEntitiesMatchingText = 'customer.no-customers-matching'; diff --git a/ui-ngx/src/app/shared/models/ai-model.models.ts b/ui-ngx/src/app/shared/models/ai-model.models.ts new file mode 100644 index 0000000000..5f0d845cac --- /dev/null +++ b/ui-ngx/src/app/shared/models/ai-model.models.ts @@ -0,0 +1,159 @@ +/// +/// 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 { BaseData } from '@shared/models/base-data'; +import { HasTenantId } from '@shared/models/entity.models'; +import { AiModelId } from '@shared/models/id/ai-model-id'; + +export interface AiModel extends Omit, 'label'>, HasTenantId { + modelType: string; + configuration: { + provider: AiProvider + providerConfig: { + apiKey?: string; + personalAccessToken?: string; + endpoint?: string; + serviceVersion?: string; + projectId?: string; + location?: string; + serviceAccountKey?: string; + serviceAccountKeyFileName?: string + }; + modelConfig: { + modelId: string; + temperature?: number | null; + topP?: number; + topK?: number; + frequencyPenalty?: number; + presencePenalty?: number; + maxOutputTokens?: number; + } + } +} + +export enum AiProvider { + OPENAI = 'OPENAI', + AZURE_OPENAI = 'AZURE_OPENAI', + GOOGLE_AI_GEMINI = 'GOOGLE_AI_GEMINI', + GOOGLE_VERTEX_AI_GEMINI = 'GOOGLE_VERTEX_AI_GEMINI', + MISTRAL_AI = 'MISTRAL_AI', + ANTHROPIC = 'ANTHROPIC', + AMAZON_BEDROCK = 'AMAZON_BEDROCK', + GITHUB_MODELS = 'GITHUB_MODELS' +} + +export const AiProviderWithApiKey: AiProvider[] = [ + AiProvider.OPENAI, + AiProvider.AZURE_OPENAI, + AiProvider.GOOGLE_AI_GEMINI, + AiProvider.MISTRAL_AI, + AiProvider.ANTHROPIC, + AiProvider.AMAZON_BEDROCK +] + +export const AiProviderTranslations = new Map( + [ + [AiProvider.OPENAI , 'ai-models.ai-providers.openai'], + [AiProvider.AZURE_OPENAI , 'ai-models.ai-providers.azure-openai'], + [AiProvider.GOOGLE_AI_GEMINI , 'ai-models.ai-providers.google-ai-gemini'], + [AiProvider.GOOGLE_VERTEX_AI_GEMINI , 'ai-models.ai-providers.google-vertex-ai-gemini'], + [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'] + ] +); + +export const AiModelMap = new Map( + [ + [AiProvider.OPENAI , [ + 'o4-mini', + 'o3-pro', + 'o3', + 'o3-mini', + 'o1', + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'gpt-4o', + 'gpt-4o-mini' + ]], + [AiProvider.AZURE_OPENAI , []], + [AiProvider.GOOGLE_AI_GEMINI , [ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-2.0-flash-lite', + ]], + [AiProvider.GOOGLE_VERTEX_AI_GEMINI , [ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-2.0-flash-lite', + ]], + [AiProvider.MISTRAL_AI , [ + 'magistral-medium-latest', + 'magistral-small-latest', + 'mistral-large-latest', + 'mistral-medium-latest', + 'mistral-small-latest', + 'pixtral-large-latest', + 'ministral-8b-latest', + 'ministral-3b-latest', + 'open-mistral-nemo' + ]], + [AiProvider.ANTHROPIC , [ + 'claude-opus-4-0', + 'claude-sonnet-4-0', + 'claude-3-7-sonnet-latest', + 'claude-3-5-sonnet-latest', + 'claude-3-5-haiku-latest' + ]], + [AiProvider.AMAZON_BEDROCK , []], + [AiProvider.GITHUB_MODELS , []] + ] +); + +export interface AiModelWithUserMsg { + userMessage: { + contents: Array<{contentType: string; text: string}>; + } + chatModel: { + modelType: string; + provider: AiProvider + providerConfig: { + apiKey?: string; + personalAccessToken?: string; + endpoint?: string; + serviceVersion?: string; + projectId?: string; + location?: string; + serviceAccountKey?: string; + serviceAccountKeyFileName?: string + }; + modelConfig: { + modelId: string; + maxRetries: number; + timeoutSeconds: number; + } + } +} + + +export interface CheckConnectivityResult { + status: string; + errorDetails: string; +} diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index fbb3e73216..3406412324 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -200,6 +200,7 @@ export const HelpLinks = { mobileBundle: `${helpBaseUrl}/docs${docPlatformPrefix}/mobile-center/mobile-center/`, mobileQrCode: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/mobile-qr-code/`, calculatedField: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/calculated-fields/`, + aiModels: `${helpBaseUrl}/docs${docPlatformPrefix}/ai-models`, timewindowSettings: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/dashboards/#time-window`, trendzSettings: `${helpBaseUrl}/docs/trendz/` } diff --git a/ui-ngx/src/app/shared/models/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts index 02bae1576f..fa94c74041 100644 --- a/ui-ngx/src/app/shared/models/entity-type.models.ts +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -496,8 +496,13 @@ export const entityTypeTranslations = new MapAre you sure you want to leave this page?", @@ -2555,7 +2625,8 @@ "type-current-user-owner": "Current User Owner", "type-calculated-field": "Calculated field", "type-calculated-fields": "Calculated fields", - "type-ai-model-settings": "AI model settings", + "type-ai-model": "AI model", + "type-ai-models": "AI models", "type-widgets-bundle": "Widgets bundle", "type-widgets-bundles": "Widgets bundles", "list-of-widgets-bundles": "{ count, plural, =1 {One widgets bundle} other {List of # widget bundles} }", @@ -5356,6 +5427,30 @@ "html-text-description": "Allows you to use HTML tags for formatting, links and images in your mai body.", "dynamic-text-description": "Allows to use Plain Text or HTML body type dynamically based on templatization feature.", "after-template-evaluation-hint": "After template evaluation value should be true for HTML, and false for Plain text." + }, + "ai": { + "ai-model": "AI model", + "ai-model-hint": "Select the pre-configured AI model to process requests sent by this rule node, or use \"Create new\" to configure a new one.", + "prompt-settings": "Prompt settings", + "prompt-settings-hint": "Configure the instructions for the AI. The optional system prompt sets the AI's general role and constraints, while the user prompt defines the specific task to perform. Both fields also support rule node templates to include dynamic data (e.g., use $[*] to access the entire message body or ${*} to access all metadata).", + "system-prompt": "System prompt", + "system-prompt-max-length": "System prompt must be 10000 characters or less.", + "system-prompt-blank": "System prompt must not be blank.", + "user-prompt": "User prompt", + "user-prompt-required": "User prompt is required.", + "user-prompt-max-length": "User prompt must be 10000 characters or less.", + "user-prompt-blank": "User prompt must not be blank.", + "response-format": "Response format", + "response-format-hint": "The model is required to generate a JSON that matches the specific structure and data types defined in the provided schema. If the output is not a valid JSON object, it will be automatically wrapped within a JSON object under the \"response\" key.", + "response-text": "Text", + "response-json": "JSON", + "response-json-schema": "JSON Schema", + "response-json-schema-required": "JSON Schema is required", + "advanced-settings": "Advanced settings", + "timeout": "Timeout", + "timeout-hint": "Maximum time to wait for a response \nfrom the AI model before the request is terminated.", + "timeout-required": "Timeout is required", + "timeout-validation": "Must be from 1 second to 10 minutes." } }, "timezone": { diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index e516810895..9896e16013 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -819,4 +819,34 @@ } } } + + .tb-form-panel.outlined { + --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + --mdc-outlined-text-field-container-shape: 6px; + + box-shadow: none; + gap: 0; + padding-bottom: 0; + + &:not(.stroked) { + border-radius: 0; + } + + &:not(.mat-padding,.padding) { + padding: 0; + } + + .tb-form-panel-title { + margin-bottom: 16px; + } + + .tb-form-row { + height: 56px; + margin-bottom: 22px; + &.disabled { + border-color: var(--mdc-outlined-text-field-disabled-outline-color); + color: var(--mdc-outlined-text-field-disabled-input-text-color); + } + } + } } From 7c29d2a5d7a2a50f9e8daeb9ed29dca6758a3632 Mon Sep 17 00:00:00 2001 From: LeoMorgan113 Date: Tue, 8 Jul 2025 13:23:06 +0300 Subject: [PATCH 103/249] Added borders in a preview mode --- .../action/custom-action-pretty-editor.component.scss | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss index 6db246723f..2562d6c994 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss @@ -19,8 +19,10 @@ padding: 8px; background-color: #fff; - .tb-custom-action-editor-container { - border: none; + .css-panel, .html-panel{ + border-color: #c0c0c0; + border-width: 0 1px 1px 1px; + border-style: solid; } .tb-js-func-toolbar{ @@ -35,8 +37,7 @@ } .tb-js-func-panel { - border: none; - border-top: 1px solid #c0c0c0; + border: 1px solid #c0c0c0; } } @@ -72,7 +73,7 @@ padding: 8px 0 25px; } - .tb-custom-action-editor-container { + .css-panel, .html-panel{ border: none; border-bottom: 1px solid #c0c0c0; } From c6497281c36a1f07c96c8eaf2010bea2e7a4e46a Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Tue, 8 Jul 2025 13:24:27 +0300 Subject: [PATCH 104/249] Fix upgrade for ota_package --- application/src/main/data/upgrade/basic/schema_update.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index c959cfd6c1..160217fc4f 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -19,7 +19,7 @@ ALTER TABLE ota_package ADD COLUMN IF NOT EXISTS external_id uuid; ALTER TABLE ota_package - ADD CONSTRAINT ota_package_external_id_unq_key UNIQUE (tenant_id, external_id); + ADD CONSTRAINT IF NOT EXISTS ota_package_external_id_unq_key UNIQUE (tenant_id, external_id); -- UPDATE OTA PACKAGE EXTERNAL ID END From a691ff126236d2bc3110f235d39b2e85701552e8 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Tue, 8 Jul 2025 14:28:42 +0300 Subject: [PATCH 105/249] Fix upgrade for ota_package --- .../src/main/data/upgrade/basic/schema_update.sql | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 160217fc4f..2c6f6a9ca0 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -18,8 +18,16 @@ ALTER TABLE ota_package ADD COLUMN IF NOT EXISTS external_id uuid; -ALTER TABLE ota_package - ADD CONSTRAINT IF NOT EXISTS ota_package_external_id_unq_key UNIQUE (tenant_id, external_id); + +DO +$$ + BEGIN + IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'ota_package_external_id_unq_key') THEN + ALTER TABLE ota_package + ADD CONSTRAINT ota_package_external_id_unq_key (tenant_id, external_id); + END IF; + END; +$$; -- UPDATE OTA PACKAGE EXTERNAL ID END From 2055dc83befd08c0ec4384f2bc80a6817d8db26d Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 8 Jul 2025 14:29:55 +0300 Subject: [PATCH 106/249] Cleanup script engine classes --- .../script/RuleNodeJsScriptEngine.java | 100 ++++++++-------- .../service/script/RuleNodeScriptEngine.java | 66 ++++------- .../script/RuleNodeTbelScriptEngine.java | 112 +++++++++--------- 3 files changed, 123 insertions(+), 155 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java index b7490af487..e0b8351ff6 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java @@ -17,18 +17,16 @@ package org.thingsboard.server.service.script; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; -import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.RuleNodeScriptFactory; +import org.thingsboard.script.api.TbScriptException; import org.thingsboard.script.api.js.JsInvokeService; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; -import javax.script.ScriptException; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; @@ -36,8 +34,6 @@ import java.util.List; import java.util.Map; import java.util.Set; - -@Slf4j public class RuleNodeJsScriptEngine extends RuleNodeScriptEngine { public RuleNodeJsScriptEngine(TenantId tenantId, JsInvokeService scriptInvokeService, String script, String... argNames) { @@ -45,87 +41,81 @@ public class RuleNodeJsScriptEngine extends RuleNodeScriptEngine executeJsonAsync(TbMsg msg) { - return executeScriptAsync(msg); + protected Object[] prepareArgs(TbMsg msg) { + String[] args = new String[3]; + if (msg.getData() != null) { + args[0] = msg.getData(); + } else { + args[0] = ""; + } + args[1] = JacksonUtil.toString(msg.getMetaData().getData()); + args[2] = msg.getType(); + return args; } @Override - protected ListenableFuture> executeUpdateTransform(TbMsg msg, JsonNode json) { + protected List executeUpdateTransform(TbMsg msg, JsonNode json) { if (json.isObject()) { - return Futures.immediateFuture(Collections.singletonList(unbindMsg(json, msg))); + return Collections.singletonList(unbindMsg(json, msg)); } else if (json.isArray()) { List res = new ArrayList<>(json.size()); json.forEach(jsonObject -> res.add(unbindMsg(jsonObject, msg))); - return Futures.immediateFuture(res); + return res; } - log.warn("Wrong result type: {}", json.getNodeType()); - return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + json.getNodeType())); + throw wrongResultType(json); } @Override - protected ListenableFuture executeGenerateTransform(TbMsg prevMsg, JsonNode result) { + protected TbMsg executeGenerateTransform(TbMsg prevMsg, JsonNode result) { if (!result.isObject()) { - log.warn("Wrong result type: {}", result.getNodeType()); - Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + result.getNodeType())); - } - return Futures.immediateFuture(unbindMsg(result, prevMsg)); - } - - @Override - protected JsonNode convertResult(Object result) { - return JacksonUtil.toJsonNode(result != null ? result.toString() : null); - } - - @Override - protected ListenableFuture executeToStringTransform(JsonNode result) { - if (result.isTextual()) { - return Futures.immediateFuture(result.asText()); + throw wrongResultType(result); } - log.warn("Wrong result type: {}", result.getNodeType()); - return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + result.getNodeType())); + return unbindMsg(result, prevMsg); } @Override - protected ListenableFuture executeFilterTransform(JsonNode json) { + protected boolean executeFilterTransform(JsonNode json) { if (json.isBoolean()) { - return Futures.immediateFuture(json.asBoolean()); + return json.asBoolean(); } - log.warn("Wrong result type: {}", json.getNodeType()); - return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + json.getNodeType())); + throw wrongResultType(json); } @Override - protected ListenableFuture> executeSwitchTransform(JsonNode result) { + protected Set executeSwitchTransform(JsonNode result) { if (result.isTextual()) { - return Futures.immediateFuture(Collections.singleton(result.asText())); + return Collections.singleton(result.asText()); } if (result.isArray()) { Set nextStates = new HashSet<>(); for (JsonNode val : result) { if (!val.isTextual()) { - log.warn("Wrong result type: {}", val.getNodeType()); - return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + val.getNodeType())); + throw wrongResultType(val); } else { nextStates.add(val.asText()); } } - return Futures.immediateFuture(nextStates); + return nextStates; } - log.warn("Wrong result type: {}", result.getNodeType()); - return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + result.getNodeType())); + throw wrongResultType(result); } @Override - protected Object[] prepareArgs(TbMsg msg) { - String[] args = new String[3]; - if (msg.getData() != null) { - args[0] = msg.getData(); - } else { - args[0] = ""; + public ListenableFuture executeJsonAsync(TbMsg msg) { + return executeScriptAsync(msg); + } + + @Override + protected String executeToStringTransform(JsonNode result) { + if (result.isTextual()) { + return result.asText(); } - args[1] = JacksonUtil.toString(msg.getMetaData().getData()); - args[2] = msg.getType(); - return args; + throw wrongResultType(result); + } + + @Override + protected JsonNode convertResult(Object result) { + return JacksonUtil.toJsonNode(result != null ? result.toString() : null); } private static TbMsg unbindMsg(JsonNode msgData, TbMsg msg) { @@ -138,19 +128,23 @@ public class RuleNodeJsScriptEngine extends RuleNodeScriptEngine() { - }); + metadata = JacksonUtil.convertValue(msgMetadata, new TypeReference<>() {}); } if (msgData.has(RuleNodeScriptFactory.MSG_TYPE)) { messageType = msgData.get(RuleNodeScriptFactory.MSG_TYPE).asText(); } String newData = data != null ? data : msg.getData(); TbMsgMetaData newMetadata = metadata != null ? new TbMsgMetaData(metadata) : msg.getMetaData().copy(); - String newMessageType = !StringUtils.isEmpty(messageType) ? messageType : msg.getType(); + String newMessageType = StringUtils.isNotEmpty(messageType) ? messageType : msg.getType(); return msg.transform() .type(newMessageType) .metaData(newMetadata) .data(newData) .build(); } + + private TbScriptException wrongResultType(JsonNode result) { + return new TbScriptException(scriptId, TbScriptException.ErrorCode.RUNTIME, null, new ClassCastException("Wrong result type: " + result.getNodeType())); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptEngine.java index d99f1654f3..ec9c2fd983 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptEngine.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptEngine.java @@ -17,7 +17,6 @@ package org.thingsboard.server.service.script; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.MoreExecutors; import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.ScriptEngine; import org.thingsboard.script.api.ScriptInvokeService; @@ -27,25 +26,26 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.TbMsg; -import javax.script.ScriptException; import java.util.List; import java.util.Set; import java.util.UUID; import java.util.concurrent.ExecutionException; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; + @Slf4j public abstract class RuleNodeScriptEngine implements ScriptEngine { private final T scriptInvokeService; - private final UUID scriptId; + protected final UUID scriptId; private final TenantId tenantId; public RuleNodeScriptEngine(TenantId tenantId, T scriptInvokeService, String script, String... argNames) { this.tenantId = tenantId; this.scriptInvokeService = scriptInvokeService; try { - this.scriptId = this.scriptInvokeService.eval(tenantId, ScriptType.RULE_NODE_SCRIPT, script, argNames).get(); + scriptId = this.scriptInvokeService.eval(tenantId, ScriptType.RULE_NODE_SCRIPT, script, argNames).get(); } catch (Exception e) { Throwable t = e; if (e instanceof ExecutionException) { @@ -63,73 +63,53 @@ public abstract class RuleNodeScriptEngine imp @Override public ListenableFuture> executeUpdateAsync(TbMsg msg) { ListenableFuture result = executeScriptAsync(msg); - return Futures.transformAsync(result, - json -> executeUpdateTransform(msg, json), - MoreExecutors.directExecutor()); + return Futures.transform(result, json -> executeUpdateTransform(msg, json), directExecutor()); } - protected abstract ListenableFuture> executeUpdateTransform(TbMsg msg, R result); + protected abstract List executeUpdateTransform(TbMsg msg, R result); @Override public ListenableFuture executeGenerateAsync(TbMsg prevMsg) { - return Futures.transformAsync(executeScriptAsync(prevMsg), - result -> executeGenerateTransform(prevMsg, result), - MoreExecutors.directExecutor()); + return Futures.transform(executeScriptAsync(prevMsg), result -> executeGenerateTransform(prevMsg, result), directExecutor()); } - protected abstract ListenableFuture executeGenerateTransform(TbMsg prevMsg, R result); - - @Override - public ListenableFuture executeToStringAsync(TbMsg msg) { - return Futures.transformAsync(executeScriptAsync(msg), this::executeToStringTransform, MoreExecutors.directExecutor()); - } + protected abstract TbMsg executeGenerateTransform(TbMsg prevMsg, R result); @Override public ListenableFuture executeFilterAsync(TbMsg msg) { - return Futures.transformAsync(executeScriptAsync(msg), - this::executeFilterTransform, - MoreExecutors.directExecutor()); + return Futures.transform(executeScriptAsync(msg), this::executeFilterTransform, directExecutor()); } - protected abstract ListenableFuture executeToStringTransform(R result); + protected abstract boolean executeFilterTransform(R result); - protected abstract ListenableFuture executeFilterTransform(R result); + @Override + public ListenableFuture> executeSwitchAsync(TbMsg msg) { + return Futures.transform(executeScriptAsync(msg), this::executeSwitchTransform, directExecutor()); // usually runs on a callbackExecutor + } - protected abstract ListenableFuture> executeSwitchTransform(R result); + protected abstract Set executeSwitchTransform(R result); @Override - public ListenableFuture> executeSwitchAsync(TbMsg msg) { - return Futures.transformAsync(executeScriptAsync(msg), - this::executeSwitchTransform, - MoreExecutors.directExecutor()); //usually runs in a callbackExecutor + public ListenableFuture executeToStringAsync(TbMsg msg) { + return Futures.transform(executeScriptAsync(msg), this::executeToStringTransform, directExecutor()); } + protected abstract String executeToStringTransform(R result); + ListenableFuture executeScriptAsync(TbMsg msg) { log.trace("execute script async, msg {}", msg); Object[] inArgs = prepareArgs(msg); return executeScriptAsync(msg.getCustomerId(), inArgs[0], inArgs[1], inArgs[2]); } - ListenableFuture executeScriptAsync(CustomerId customerId, Object... args) { - return Futures.transformAsync(scriptInvokeService.invokeScript(tenantId, customerId, this.scriptId, args), - o -> { - try { - return Futures.immediateFuture(convertResult(o)); - } catch (Exception e) { - if (e.getCause() instanceof ScriptException) { - return Futures.immediateFailedFuture(e.getCause()); - } else if (e.getCause() instanceof RuntimeException) { - return Futures.immediateFailedFuture(new ScriptException(e.getCause().getMessage())); - } else { - return Futures.immediateFailedFuture(new ScriptException(e)); - } - } - }, MoreExecutors.directExecutor()); + private ListenableFuture executeScriptAsync(CustomerId customerId, Object... args) { + return Futures.transform(scriptInvokeService.invokeScript(tenantId, customerId, scriptId, args), this::convertResult, directExecutor()); } public void destroy() { - scriptInvokeService.release(this.scriptId); + scriptInvokeService.release(scriptId); } protected abstract R convertResult(Object result); + } diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeTbelScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeTbelScriptEngine.java index 5e197d0e5d..991be63f81 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeTbelScriptEngine.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeTbelScriptEngine.java @@ -19,17 +19,15 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.MoreExecutors; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.RuleNodeScriptFactory; +import org.thingsboard.script.api.TbScriptException; import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; -import javax.script.ScriptException; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -40,8 +38,8 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; -@Slf4j public class RuleNodeTbelScriptEngine extends RuleNodeScriptEngine { public RuleNodeTbelScriptEngine(TenantId tenantId, TbelInvokeService scriptInvokeService, String script, String... argNames) { @@ -49,70 +47,74 @@ public class RuleNodeTbelScriptEngine extends RuleNodeScriptEngine executeFilterTransform(Object result) { - if (result instanceof Boolean) { - return Futures.immediateFuture((Boolean) result); + protected Object[] prepareArgs(TbMsg msg) { + Object[] args = new Object[3]; + if (msg.getData() != null) { + args[0] = JacksonUtil.fromString(msg.getData(), Object.class); + } else { + args[0] = new HashMap<>(); } - return wrongResultType(result); + args[1] = new HashMap<>(msg.getMetaData().getData()); + args[2] = msg.getType(); + return args; } @Override - protected ListenableFuture> executeUpdateTransform(TbMsg msg, Object result) { - if (result instanceof Map) { - return Futures.immediateFuture(Collections.singletonList(unbindMsg((Map) result, msg))); - } else if (result instanceof Collection) { - List res = new ArrayList<>(); - for (Object resObject : (Collection) result) { - if (resObject instanceof Map) { - res.add(unbindMsg((Map) resObject, msg)); + protected List executeUpdateTransform(TbMsg msg, Object result) { + if (result instanceof Map msgData) { + return Collections.singletonList(unbindMsg(msgData, msg)); + } else if (result instanceof Collection resultCollection) { + List res = new ArrayList<>(resultCollection.size()); + for (Object resObject : resultCollection) { + if (resObject instanceof Map msgData) { + res.add(unbindMsg(msgData, msg)); } else { - return wrongResultType(resObject); + throw wrongResultType(resObject); } } - return Futures.immediateFuture(res); + return res; } - return wrongResultType(result); + throw wrongResultType(result); } @Override - protected ListenableFuture executeGenerateTransform(TbMsg prevMsg, Object result) { - if (result instanceof Map) { - return Futures.immediateFuture(unbindMsg((Map) result, prevMsg)); + protected TbMsg executeGenerateTransform(TbMsg prevMsg, Object result) { + if (result instanceof Map msgData) { + return unbindMsg(msgData, prevMsg); } - return wrongResultType(result); + throw wrongResultType(result); } @Override - protected ListenableFuture executeToStringTransform(Object result) { - if (result instanceof String) { - return Futures.immediateFuture((String) result); - } else { - return Futures.immediateFuture(JacksonUtil.toString(result)); + protected boolean executeFilterTransform(Object result) { + if (result instanceof Boolean b) { + return b; } + throw wrongResultType(result); } @Override - protected ListenableFuture> executeSwitchTransform(Object result) { - if (result instanceof String) { - return Futures.immediateFuture(Collections.singleton((String) result)); - } else if (result instanceof Collection) { - Set res = new HashSet<>(); - for (Object resObject : (Collection) result) { - if (resObject instanceof String) { - res.add((String) resObject); + protected Set executeSwitchTransform(Object result) { + if (result instanceof String str) { + return Collections.singleton(str); + } + if (result instanceof Collection resultCollection) { + Set res = new HashSet<>(resultCollection.size()); + for (Object resObject : resultCollection) { + if (resObject instanceof String str) { + res.add(str); } else { - return wrongResultType(resObject); + throw wrongResultType(resObject); } } - return Futures.immediateFuture(res); + return res; } - return wrongResultType(result); + throw wrongResultType(result); } @Override public ListenableFuture executeJsonAsync(TbMsg msg) { - return Futures.transform(executeScriptAsync(msg), JacksonUtil::valueToTree, MoreExecutors.directExecutor()); - + return Futures.transform(executeScriptAsync(msg), JacksonUtil::valueToTree, directExecutor()); } @Override @@ -121,16 +123,8 @@ public class RuleNodeTbelScriptEngine extends RuleNodeScriptEngine(); - } - args[1] = new HashMap<>(msg.getMetaData().getData()); - args[2] = msg.getType(); - return args; + protected String executeToStringTransform(Object result) { + return result instanceof String str ? str : JacksonUtil.toString(result); } private static TbMsg unbindMsg(Map msgData, TbMsg msg) { @@ -142,12 +136,12 @@ public class RuleNodeTbelScriptEngine extends RuleNodeScriptEngine) msgMetadataObj).entrySet().stream().filter(e -> e.getValue() != null) + if (msgMetadataObj instanceof Map msgMetadataObjAsMap) { + metadata = msgMetadataObjAsMap.entrySet().stream() + .filter(e -> e.getValue() != null) .collect(Collectors.toMap(e -> e.getKey().toString(), e -> e.getValue().toString())); } else { - metadata = JacksonUtil.convertValue(msgMetadataObj, new TypeReference<>() { - }); + metadata = JacksonUtil.convertValue(msgMetadataObj, new TypeReference<>() {}); } } if (msgData.containsKey(RuleNodeScriptFactory.MSG_TYPE)) { @@ -155,7 +149,7 @@ public class RuleNodeTbelScriptEngine extends RuleNodeScriptEngine ListenableFuture wrongResultType(Object result) { + private TbScriptException wrongResultType(Object result) { String className = toClassName(result); - log.warn("Wrong result type: {}", className); - return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + className)); + return new TbScriptException(scriptId, TbScriptException.ErrorCode.RUNTIME, null, new ClassCastException("Wrong result type: " + className)); } private static String toClassName(Object result) { return result != null ? result.getClass().getSimpleName() : "null"; } + } From 349e7402a47c78ead512aa59f1c6fcd37f528460 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Tue, 8 Jul 2025 14:33:25 +0300 Subject: [PATCH 107/249] Small refactoring --- application/src/main/data/upgrade/basic/schema_update.sql | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 2c6f6a9ca0..22cac14829 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -23,8 +23,7 @@ DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'ota_package_external_id_unq_key') THEN - ALTER TABLE ota_package - ADD CONSTRAINT ota_package_external_id_unq_key (tenant_id, external_id); + ALTER TABLE ota_package ADD CONSTRAINT ota_package_external_id_unq_key UNIQUE (tenant_id, external_id); END IF; END; $$; From a0e8b014297c0569644ba3bef67ee794731c225f Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 8 Jul 2025 15:32:24 +0300 Subject: [PATCH 108/249] Add tests for compilation errors when evaluating scripts --- .../script/NashornJsInvokeServiceTest.java | 22 +++++++++ .../script/RemoteJsInvokeServiceTest.java | 45 ++++++++++++++--- .../service/script/TbelInvokeServiceTest.java | 22 +++++++++ .../script/api/TbScriptExceptionTest.java | 49 +++++++++++++++++++ 4 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 common/script/script-api/src/test/java/org/thingsboard/script/api/TbScriptExceptionTest.java diff --git a/application/src/test/java/org/thingsboard/server/service/script/NashornJsInvokeServiceTest.java b/application/src/test/java/org/thingsboard/server/service/script/NashornJsInvokeServiceTest.java index 0942cef75d..b8ab48b38d 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/NashornJsInvokeServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/NashornJsInvokeServiceTest.java @@ -25,11 +25,13 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.test.context.TestPropertySource; import org.thingsboard.common.util.TbStopWatch; import org.thingsboard.script.api.ScriptType; +import org.thingsboard.script.api.TbScriptException; import org.thingsboard.script.api.js.NashornJsInvokeService; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.controller.AbstractControllerTest; import org.thingsboard.server.dao.service.DaoSqlTest; +import javax.script.ScriptException; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -39,6 +41,7 @@ import java.util.concurrent.TimeoutException; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.type; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_REQUEST; @@ -59,6 +62,25 @@ class NashornJsInvokeServiceTest extends AbstractControllerTest { @Value("${js.local.max_errors}") private int maxJsErrors; + @Test + void givenUncompilableScript_whenEvaluating_thenThrowsErrorWithCompilationErrorCode() { + // GIVEN + var uncompilableScript = "return msg.temperature?.value;"; + + // WHEN-THEN + assertThatThrownBy(() -> evalScript(uncompilableScript)) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getScriptId()).isNotNull(); + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.COMPILATION); + assertThat(ex.getBody()).contains(uncompilableScript); + assertThat(ex.getCause()).isInstanceOf(ScriptException.class); + }); + } + @Test void givenSimpleScriptTestPerformance() throws ExecutionException, InterruptedException { int iterations = 1000; diff --git a/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java b/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java index 363f21fa10..36990d9768 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java @@ -23,9 +23,9 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.script.api.ScriptType; +import org.thingsboard.script.api.TbScriptException; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.stats.DefaultStatsFactory; import org.thingsboard.server.common.stats.StatsCounter; import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.common.stats.TbApiUsageReportClient; @@ -42,8 +42,11 @@ import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.concurrent.ExecutionException; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.type; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.doAnswer; @@ -60,7 +63,6 @@ class RemoteJsInvokeServiceTest { private RemoteJsInvokeService remoteJsInvokeService; private TbQueueRequestTemplate, TbProtoQueueMsg> jsRequestTemplate; - @BeforeEach public void beforeEach() { TbApiUsageStateClient apiUsageStateClient = mock(TbApiUsageStateClient.class); @@ -74,7 +76,7 @@ class RemoteJsInvokeServiceTest { remoteJsInvokeService.requestTemplate = jsRequestTemplate; StatsFactory statsFactory = mock(StatsFactory.class); when(statsFactory.createStatsCounter(any(), any())).thenReturn(mock(StatsCounter.class)); - ReflectionTestUtils.setField(remoteJsInvokeService, "statsFactory",statsFactory); + ReflectionTestUtils.setField(remoteJsInvokeService, "statsFactory", statsFactory); remoteJsInvokeService.init(); } @@ -84,7 +86,36 @@ class RemoteJsInvokeServiceTest { } @Test - public void whenInvokingFunction_thenDoNotSendScriptBody() throws Exception { + void givenUncompilableScript_whenEvaluating_thenThrowsErrorWithCompilationErrorCode() { + // GIVEN + doAnswer(methodCall -> Futures.immediateFuture(new TbProtoJsQueueMsg<>(UUID.randomUUID(), RemoteJsResponse.newBuilder() + .setCompileResponse(JsInvokeProtos.JsCompileResponse.newBuilder() + .setSuccess(false) + .setErrorCode(JsInvokeProtos.JsInvokeErrorCode.COMPILATION_ERROR) + .setErrorDetails("SyntaxError: Unexpected token 'const'") + .setScriptHash(methodCall.>getArgument(0).getValue().getCompileRequest().getScriptHash()) + .build()) + .build()))) + .when(jsRequestTemplate).send(argThat(jsQueueMsg -> jsQueueMsg.getValue().hasCompileRequest())); + + var uncompilableScript = "let const = 'this is not allowed';"; + + // WHEN-THEN + assertThatThrownBy(() -> remoteJsInvokeService.eval(TenantId.SYS_TENANT_ID, ScriptType.RULE_NODE_SCRIPT, uncompilableScript).get()) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getScriptId()).isNotNull(); + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.COMPILATION); + assertThat(ex.getBody()).contains(uncompilableScript); + assertThat(ex.getCause()).isInstanceOf(RuntimeException.class).hasMessage("SyntaxError: Unexpected token 'const'"); + }); + } + + @Test + void whenInvokingFunction_thenDoNotSendScriptBody() throws Exception { mockJsEvalResponse(); String scriptBody = "return { a: 'b'};"; UUID scriptId = remoteJsInvokeService.eval(TenantId.SYS_TENANT_ID, ScriptType.RULE_NODE_SCRIPT, scriptBody).get(); @@ -110,7 +141,7 @@ class RemoteJsInvokeServiceTest { } @Test - public void whenInvokingFunctionAndRemoteJsExecutorRemovedScript_thenHandleNotFoundErrorAndMakeInvokeRequestWithScriptBody() throws Exception { + void whenInvokingFunctionAndRemoteJsExecutorRemovedScript_thenHandleNotFoundErrorAndMakeInvokeRequestWithScriptBody() throws Exception { mockJsEvalResponse(); String scriptBody = "return { a: 'b'};"; UUID scriptId = remoteJsInvokeService.eval(TenantId.SYS_TENANT_ID, ScriptType.RULE_NODE_SCRIPT, scriptBody).get(); @@ -156,7 +187,7 @@ class RemoteJsInvokeServiceTest { } @Test - public void whenDoingEval_thenSaveScriptByHashOfTenantIdAndScriptBody() throws Exception { + void whenDoingEval_thenSaveScriptByHashOfTenantIdAndScriptBody() throws Exception { mockJsEvalResponse(); TenantId tenantId1 = TenantId.fromUUID(UUID.randomUUID()); @@ -187,7 +218,7 @@ class RemoteJsInvokeServiceTest { } @Test - public void whenReleasingScript_thenCheckForHashUsages() throws Exception { + void whenReleasingScript_thenCheckForHashUsages() throws Exception { mockJsEvalResponse(); String scriptBody = "return { a: 'b'};"; UUID scriptId1 = remoteJsInvokeService.eval(TenantId.SYS_TENANT_ID, ScriptType.RULE_NODE_SCRIPT, scriptBody).get(); diff --git a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java index 732f31f044..fc66f806d7 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java @@ -20,10 +20,12 @@ import com.github.benmanes.caffeine.cache.Cache; import org.junit.Assert; import org.junit.Ignore; import org.junit.jupiter.api.Test; +import org.mvel2.CompileException; import org.springframework.beans.factory.annotation.Value; import org.springframework.test.context.TestPropertySource; import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.script.api.TbScriptException; import org.thingsboard.script.api.tbel.TbelScript; import java.io.Serializable; @@ -37,6 +39,7 @@ import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.type; @TestPropertySource(properties = { "tbel.max_script_body_size=100", @@ -50,6 +53,25 @@ class TbelInvokeServiceTest extends AbstractTbelInvokeTest { @Value("${tbel.max_errors}") private int maxJsErrors; + @Test + void givenUncompilableScript_whenEvaluating_thenThrowsErrorWithCompilationErrorCode() { + // GIVEN + var uncompilableScript = "return msg.property !== undefined;"; + + // WHEN-THEN + assertThatThrownBy(() -> evalScript(uncompilableScript)) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getScriptId()).isNotNull(); + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.COMPILATION); + assertThat(ex.getBody()).isEqualTo(uncompilableScript); + assertThat(ex.getCause()).isInstanceOf(CompileException.class); + }); + } + @Test void givenSimpleScriptTestPerformance() throws ExecutionException, InterruptedException { int iterations = 100000; diff --git a/common/script/script-api/src/test/java/org/thingsboard/script/api/TbScriptExceptionTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/TbScriptExceptionTest.java new file mode 100644 index 0000000000..330b895504 --- /dev/null +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/TbScriptExceptionTest.java @@ -0,0 +1,49 @@ +/** + * 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.script.api; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class TbScriptExceptionTest { + + @Test + void givenCompilationError_whenCheckingIsUnrecoverable_thenReturnsTrue() { + // GIVEN + var exception = new TbScriptException(null, TbScriptException.ErrorCode.COMPILATION, null, null); + + // WHEN-THEN + assertThat(exception.isUnrecoverable()).isTrue(); + } + + @ParameterizedTest + @EnumSource( + value = TbScriptException.ErrorCode.class, + mode = EnumSource.Mode.EXCLUDE, + names = "COMPILATION" + ) + void givenRecoverableErrorCodes_whenCheckingIsUnrecoverable_thenReturnsFalse(TbScriptException.ErrorCode errorCode) { + // GIVEN + var exception = new TbScriptException(null, errorCode, null, null); + + // WHEN-THEN + assertThat(exception.isUnrecoverable()).isFalse(); + } + +} From 7860f4575f4668cc81c072c3bfcfc4cacbcff289 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Tue, 8 Jul 2025 17:46:18 +0300 Subject: [PATCH 109/249] UI: Add VC support --- ui-ngx/src/app/shared/models/ai-model.models.ts | 4 ++-- ui-ngx/src/app/shared/models/vc.models.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/ui-ngx/src/app/shared/models/ai-model.models.ts b/ui-ngx/src/app/shared/models/ai-model.models.ts index 5f0d845cac..1f9c81bb1e 100644 --- a/ui-ngx/src/app/shared/models/ai-model.models.ts +++ b/ui-ngx/src/app/shared/models/ai-model.models.ts @@ -14,11 +14,11 @@ /// limitations under the License. /// -import { BaseData } from '@shared/models/base-data'; +import { BaseData, ExportableEntity } from '@shared/models/base-data'; import { HasTenantId } from '@shared/models/entity.models'; import { AiModelId } from '@shared/models/id/ai-model-id'; -export interface AiModel extends Omit, 'label'>, HasTenantId { +export interface AiModel extends Omit, 'label'>, HasTenantId, ExportableEntity { modelType: string; configuration: { provider: AiProvider diff --git a/ui-ngx/src/app/shared/models/vc.models.ts b/ui-ngx/src/app/shared/models/vc.models.ts index 3795518ffc..3ce5b6ae3f 100644 --- a/ui-ngx/src/app/shared/models/vc.models.ts +++ b/ui-ngx/src/app/shared/models/vc.models.ts @@ -35,14 +35,16 @@ export const exportableEntityTypes: Array = [ EntityType.TB_RESOURCE, EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, - EntityType.NOTIFICATION_RULE + EntityType.NOTIFICATION_RULE, + EntityType.AI_MODEL_SETTINGS ]; export const entityTypesWithoutRelatedData: Set = new Set([ EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, - EntityType.TB_RESOURCE + EntityType.TB_RESOURCE, + EntityType.AI_MODEL_SETTINGS ]); export interface VersionCreateConfig { From 000c42963a53e89d4633f08ad2f13e909c274889 Mon Sep 17 00:00:00 2001 From: LeoMorgan113 Date: Wed, 9 Jul 2025 11:19:44 +0300 Subject: [PATCH 110/249] Blocks and gutter aligned, removed extra borders --- ...custom-action-pretty-editor.component.html | 2 +- ...custom-action-pretty-editor.component.scss | 73 +++++++------------ 2 files changed, 27 insertions(+), 48 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.html index 3d2e69e210..3b2c14c539 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.html @@ -43,7 +43,7 @@
-
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss index 2562d6c994..c9173bbc1a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss @@ -43,57 +43,48 @@ .tb-fullscreen-panel { .tb-custom-action-editor-container { - height: calc(100% - 40px); - } - - .right-panel { - padding-top: 8px; - padding-left: 3px; - } - - tb-js-func .tb-js-func-panel { - box-sizing: border-box; + height: 100%; } - .mat-mdc-tab-group { - .mat-mdc-tab-body-wrapper { - height: 100%; - - .mat-mdc-tab-body { - height: 100%; - - & > div { - height: 100%; - } - } - } + .css-panel, .html-panel{ + border: none; } - .right-panel{ - padding: 8px 0 25px; + .left-panel, .right-panel{ + height: calc(100% - 40px); } - .css-panel, .html-panel{ - border: none; - border-bottom: 1px solid #c0c0c0; + .right-panel { + padding: 8px 0 0; } .tb-js-func { + .tb-js-func-panel { + box-sizing: border-box; + } &.fill-height { .tb-js-func-toolbar{ padding: 0 5px; } - &:not(.tb-fullscreen) { - &.tb-hide-brackets { - padding-bottom: 15px; - } - } &.tb-hide-brackets { .tb-js-func-panel { border: none; border-top: 1px solid #c0c0c0; - border-bottom: 1px solid #c0c0c0; + } + } + } + } + + .mat-mdc-tab-group { + .mat-mdc-tab-body-wrapper { + height: 100%; + + .mat-mdc-tab-body { + height: 100%; + + & > div { + height: 100%; } } } @@ -124,7 +115,7 @@ .tb-split.tb-split-horizontal, .gutter.gutter-horizontal { float: left; - height: 100%; + height: calc(100% - 40px); } .tb-action-expand-button { @@ -135,20 +126,7 @@ &.tb-fullscreen-editor { position: relative; right: 0; - /* .mat-mdc-button { - .mat-icon { - margin-right: 5px; - } - } */ } - - /* .mat-mdc-button { - min-width: 36px; - padding: 0; - .mat-icon { - margin-right: 0; - } - } */ } .tb-custom-action-editor { @@ -159,3 +137,4 @@ } + From aaea558e79d6bb39e2b8f86b8a7704016b139787 Mon Sep 17 00:00:00 2001 From: LeoMorgan113 Date: Wed, 9 Jul 2025 11:33:47 +0300 Subject: [PATCH 111/249] Removed extra style --- .../common/action/custom-action-pretty-editor.component.scss | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss index c9173bbc1a..f0e09589ea 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss @@ -35,10 +35,6 @@ padding-bottom: 0; } } - - .tb-js-func-panel { - border: 1px solid #c0c0c0; - } } .tb-fullscreen-panel { From b6b30dbba9d50d372aa60adf35f1b2038caa3f03 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Wed, 9 Jul 2025 15:37:52 +0300 Subject: [PATCH 112/249] AI rule node: rename AI model settings to AI model --- .../server/actors/ActorSystemContext.java | 8 +- .../actors/ruleChain/DefaultTbContext.java | 12 +- .../server/controller/AiModelController.java | 122 +++++++++- .../controller/AiModelSettingsController.java | 143 ------------ .../server/controller/BaseController.java | 22 +- .../controller/ControllerConstants.java | 2 +- ...elService.java => AiChatModelService.java} | 4 +- ...eImpl.java => AiChatModelServiceImpl.java} | 9 +- .../Langchain4jChatModelConfigurerImpl.java | 214 +++++++++--------- .../edge/EdgeEventSourcingListener.java | 4 +- .../edge/RelatedEdgesSourcingListener.java | 2 +- ...vice.java => DefaultTbAiModelService.java} | 36 +-- ...ingsService.java => TbAiModelService.java} | 8 +- .../service/security/permission/Resource.java | 2 +- .../permission/TenantAdminPermissions.java | 10 +- .../DefaultEntitiesExportImportService.java | 2 +- ...Service.java => AiModelExportService.java} | 8 +- ...Service.java => AiModelImportService.java} | 44 ++-- .../src/main/resources/thingsboard.yml | 8 +- ...ttingsService.java => AiModelService.java} | 18 +- .../server/common/data/CacheConstants.java | 4 +- .../server/common/data/EntityType.java | 4 +- .../ai/{AiModelSettings.java => AiModel.java} | 38 ++-- .../common/data/ai/dto/TbChatRequest.java | 4 +- .../server/common/data/ai/model/AiModel.java | 84 ------- .../common/data/ai/model/AiModelConfig.java | 60 ++++- .../data/ai/model/chat/AiChatModel.java | 41 ---- .../data/ai/model/chat/AiChatModelConfig.java | 15 +- ...java => AmazonBedrockChatModelConfig.java} | 21 +- ...del.java => AnthropicChatModelConfig.java} | 23 +- ...l.java => AzureOpenAiChatModelConfig.java} | 25 +- ....java => GitHubModelsChatModelConfig.java} | 25 +- ...ava => GoogleAiGeminiChatModelConfig.java} | 27 +-- ... GoogleVertexAiGeminiChatModelConfig.java} | 27 +-- .../chat/Langchain4jChatModelConfigurer.java | 16 +- ...del.java => MistralAiChatModelConfig.java} | 25 +- ...tModel.java => OpenAiChatModelConfig.java} | 25 +- ...{AiModelSettingsId.java => AiModelId.java} | 16 +- .../common/data/id/EntityIdFactory.java | 2 +- .../server/common/data/sync/JsonTbEntity.java | 4 +- common/proto/src/main/proto/queue.proto | 2 +- ...Event.java => AiModelCacheEvictEvent.java} | 16 +- ...ingsCacheKey.java => AiModelCacheKey.java} | 18 +- ...neCache.java => AiModelCaffeineCache.java} | 10 +- ...iModelSettingsDao.java => AiModelDao.java} | 14 +- ...RedisCache.java => AiModelRedisCache.java} | 10 +- .../server/dao/ai/AiModelServiceImpl.java | 149 ++++++++++++ .../dao/ai/AiModelSettingsServiceImpl.java | 149 ------------ .../dao/housekeeper/CleanUpService.java | 2 +- .../server/dao/model/ModelConstants.java | 10 +- ...SettingsEntity.java => AiModelEntity.java} | 54 ++--- ...lidator.java => AiModelDataValidator.java} | 36 +-- ...Repository.java => AiModelRepository.java} | 40 ++-- ...delSettingsDao.java => JpaAiModelDao.java} | 66 +++--- .../server/dao/tenant/TenantServiceImpl.java | 2 +- .../resources/sql/schema-entities-idx.sql | 2 +- .../main/resources/sql/schema-entities.sql | 6 +- ...java => RuleEngineAiChatModelService.java} | 5 +- .../rule/engine/api/TbContext.java | 6 +- .../thingsboard/rule/engine/ai/TbAiNode.java | 43 ++-- .../rule/engine/ai/TbAiNodeConfiguration.java | 4 +- .../rule/engine/util/TenantIdLoader.java | 6 +- .../rule/engine/util/TenantIdLoaderTest.java | 16 +- 63 files changed, 848 insertions(+), 982 deletions(-) delete mode 100644 application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java rename application/src/main/java/org/thingsboard/server/service/ai/{AiModelService.java => AiChatModelService.java} (82%) rename application/src/main/java/org/thingsboard/server/service/ai/{AiModelServiceImpl.java => AiChatModelServiceImpl.java} (79%) rename application/src/main/java/org/thingsboard/server/service/entitiy/ai/{DefaultTbAiModelSettingsService.java => DefaultTbAiModelService.java} (54%) rename application/src/main/java/org/thingsboard/server/service/entitiy/ai/{TbAiModelSettingsService.java => TbAiModelService.java} (76%) rename application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/{AiModelSettingsExportService.java => AiModelExportService.java} (76%) rename application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/{AiModelSettingsImportService.java => AiModelImportService.java} (50%) rename common/dao-api/src/main/java/org/thingsboard/server/dao/ai/{AiModelSettingsService.java => AiModelService.java} (55%) rename common/data/src/main/java/org/thingsboard/server/common/data/ai/{AiModelSettings.java => AiModel.java} (67%) delete mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java delete mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java rename common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/{AmazonBedrockChatModel.java => AmazonBedrockChatModelConfig.java} (75%) rename common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/{AnthropicChatModel.java => AnthropicChatModelConfig.java} (74%) rename common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/{AzureOpenAiChatModel.java => AzureOpenAiChatModelConfig.java} (72%) rename common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/{GitHubModelsChatModel.java => GitHubModelsChatModelConfig.java} (72%) rename common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/{GoogleAiGeminiChatModel.java => GoogleAiGeminiChatModelConfig.java} (71%) rename common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/{GoogleVertexAiGeminiChatModel.java => GoogleVertexAiGeminiChatModelConfig.java} (71%) rename common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/{MistralAiChatModel.java => MistralAiChatModelConfig.java} (72%) rename common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/{OpenAiChatModel.java => OpenAiChatModelConfig.java} (73%) rename common/data/src/main/java/org/thingsboard/server/common/data/id/{AiModelSettingsId.java => AiModelId.java} (72%) rename dao/src/main/java/org/thingsboard/server/dao/ai/{AiModelSettingsCacheEvictEvent.java => AiModelCacheEvictEvent.java} (58%) rename dao/src/main/java/org/thingsboard/server/dao/ai/{AiModelSettingsCacheKey.java => AiModelCacheKey.java} (68%) rename dao/src/main/java/org/thingsboard/server/dao/ai/{AiModelSettingsCaffeineCache.java => AiModelCaffeineCache.java} (75%) rename dao/src/main/java/org/thingsboard/server/dao/ai/{AiModelSettingsDao.java => AiModelDao.java} (60%) rename dao/src/main/java/org/thingsboard/server/dao/ai/{AiModelSettingsRedisCache.java => AiModelRedisCache.java} (71%) create mode 100644 dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java delete mode 100644 dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java rename dao/src/main/java/org/thingsboard/server/dao/model/sql/{AiModelSettingsEntity.java => AiModelEntity.java} (62%) rename dao/src/main/java/org/thingsboard/server/dao/service/validator/{AiModelSettingsDataValidator.java => AiModelDataValidator.java} (51%) rename dao/src/main/java/org/thingsboard/server/dao/sql/ai/{AiModelSettingsRepository.java => AiModelRepository.java} (55%) rename dao/src/main/java/org/thingsboard/server/dao/sql/ai/{JpaAiModelSettingsDao.java => JpaAiModelDao.java} (50%) rename rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/{RuleEngineAiModelService.java => RuleEngineAiChatModelService.java} (83%) 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 cd4a88314b..ea46ce86eb 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -35,7 +35,7 @@ import org.thingsboard.rule.engine.api.JobManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; -import org.thingsboard.rule.engine.api.RuleEngineAiModelService; +import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.rule.engine.api.notification.SlackService; import org.thingsboard.rule.engine.api.sms.SmsSenderFactory; @@ -63,7 +63,7 @@ import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.common.msg.tools.TbRateLimits; import org.thingsboard.server.common.stats.TbApiUsageReportClient; -import org.thingsboard.server.dao.ai.AiModelSettingsService; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -315,11 +315,11 @@ public class ActorSystemContext { @Autowired @Getter - private RuleEngineAiModelService aiModelService; + private RuleEngineAiChatModelService aiChatModelService; @Autowired @Getter - private AiModelSettingsService aiModelSettingsService; + private AiModelService aiModelService; @Autowired @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 b4235c7262..6374e4016d 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 @@ -28,7 +28,7 @@ import org.thingsboard.rule.engine.api.JobManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; -import org.thingsboard.rule.engine.api.RuleEngineAiModelService; +import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; import org.thingsboard.rule.engine.api.RuleEngineAlarmService; import org.thingsboard.rule.engine.api.RuleEngineApiUsageStateService; import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache; @@ -77,7 +77,7 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.TbMsgProcessingStackItem; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; -import org.thingsboard.server.dao.ai.AiModelSettingsService; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -1027,13 +1027,13 @@ public class DefaultTbContext implements TbContext { } @Override - public RuleEngineAiModelService getAiModelService() { - return mainCtx.getAiModelService(); + public RuleEngineAiChatModelService getAiChatModelService() { + return mainCtx.getAiChatModelService(); } @Override - public AiModelSettingsService getAiModelSettingsService() { - return mainCtx.getAiModelSettingsService(); + public AiModelService getAiModelService() { + return mainCtx.getAiModelService(); } @Override diff --git a/application/src/main/java/org/thingsboard/server/controller/AiModelController.java b/application/src/main/java/org/thingsboard/server/controller/AiModelController.java index c7dfce90bc..5a00e26f17 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AiModelController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AiModelController.java @@ -17,31 +17,141 @@ package org.thingsboard.server.controller; import com.google.common.util.concurrent.ListenableFuture; import dev.langchain4j.model.chat.request.ChatRequest; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.ai.dto.TbChatRequest; import org.thingsboard.server.common.data.ai.dto.TbChatResponse; -import org.thingsboard.server.common.data.ai.model.chat.AiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.config.annotations.ApiOperation; -import org.thingsboard.server.service.ai.AiModelService; +import org.thingsboard.server.service.ai.AiChatModelService; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; import java.time.Duration; +import java.util.Optional; +import java.util.UUID; import static com.google.common.util.concurrent.MoreExecutors.directExecutor; +import static org.thingsboard.server.controller.ControllerConstants.AI_MODEL_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +@Validated @RestController @RequiredArgsConstructor @RequestMapping("/api/ai/model") class AiModelController extends BaseController { - private final AiModelService aiModelService; + private final AiChatModelService aiChatModelService; + + @ApiOperation( + value = "Create or update AI model (saveAiModel)", + notes = "Creates or updates an AI model record.\n\n" + + "• **Create:** Omit the `id` to create a new record. The platform assigns a UUID to the new record and returns it in the `id` field of the response.\n\n" + + "• **Update:** Include an existing `id` to modify that record. If no matching record exists, the API responds with **404 Not Found**.\n\n" + + "Tenant ID for the AI model will be taken from the authenticated user making the request, regardless of any value provided in the request body." + + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping + public AiModel saveAiModel(@RequestBody @Valid AiModel model) throws ThingsboardException { + var user = getCurrentUser(); + model.setTenantId(user.getTenantId()); + checkEntity(model.getId(), model, Resource.AI_MODEL); + return tbAiModelService.save(model, user); + } + + @ApiOperation( + value = "Get AI model by ID (getAiModelById)", + notes = "Fetches an AI model record by its `id`." + + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/{modelUuid}") + public AiModel getAiModelById( + @Parameter( + description = "ID of the AI model record", + required = true, + example = "de7900d4-30e2-11f0-9cd2-0242ac120002" + ) + @PathVariable UUID modelUuid + ) throws ThingsboardException { + return checkAiModelId(new AiModelId(modelUuid), Operation.READ); + } + + @ApiOperation( + value = "Get AI models (getAiModels)", + notes = "Returns a page of AI models. " + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping + public PageData getAiModels( + @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @Parameter(description = AI_MODEL_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name", "provider", "modelId"})) + @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) + @RequestParam(required = false) String sortOrder + ) throws ThingsboardException { + var user = getCurrentUser(); + accessControlService.checkPermission(user, Resource.AI_MODEL, Operation.READ); + var pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return aiModelService.findAiModelsByTenantId(user.getTenantId(), pageLink); + } + + @ApiOperation( + value = "Delete AI model by ID (deleteAiModelById)", + notes = "Deletes the AI model record by its `id`. " + + "If a record with the specified `id` exists, the record is deleted and the endpoint returns `true`. " + + "If no such record exists, the endpoint returns `false`." + + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @DeleteMapping("/{modelUuid}") + public boolean deleteAiModelById( + @Parameter( + description = "ID of the AI model record", + required = true, + example = "de7900d4-30e2-11f0-9cd2-0242ac120002" + ) + @PathVariable UUID modelUuid + ) throws ThingsboardException { + var user = getCurrentUser(); + var modelId = new AiModelId(modelUuid); + accessControlService.checkPermission(user, Resource.AI_MODEL, Operation.DELETE); + Optional toDelete = aiModelService.findAiModelByTenantIdAndId(user.getTenantId(), modelId); + if (toDelete.isEmpty()) { + return false; + } + accessControlService.checkPermission(user, Resource.AI_MODEL, Operation.DELETE, modelId, toDelete.get()); + return tbAiModelService.delete(toDelete.get(), user); + } @ApiOperation( value = "Send request to AI chat model (sendChatRequest)", @@ -53,13 +163,13 @@ class AiModelController extends BaseController { @PostMapping("/chat") public DeferredResult sendChatRequest(@Valid @RequestBody TbChatRequest tbChatRequest) { ChatRequest langChainChatRequest = tbChatRequest.toLangChainChatRequest(); - AiChatModel chatModel = tbChatRequest.chatModel(); + AiChatModelConfig chatModelConfig = tbChatRequest.chatModelConfig(); - ListenableFuture future = aiModelService.sendChatRequestAsync(chatModel, langChainChatRequest) + ListenableFuture future = aiChatModelService.sendChatRequestAsync(chatModelConfig, langChainChatRequest) .transform(chatResponse -> (TbChatResponse) new TbChatResponse.Success(chatResponse.aiMessage().text()), directExecutor()) .catching(Throwable.class, ex -> new TbChatResponse.Failure(ex.getMessage()), directExecutor()); - Integer requestTimeoutSeconds = chatModel.modelConfig().timeoutSeconds(); + Integer requestTimeoutSeconds = chatModelConfig.timeoutSeconds(); return requestTimeoutSeconds != null ? wrapFuture(future, Duration.ofSeconds(requestTimeoutSeconds).toMillis()) : wrapFuture(future); } diff --git a/application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java b/application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java deleted file mode 100644 index b729d9f11e..0000000000 --- a/application/src/main/java/org/thingsboard/server/controller/AiModelSettingsController.java +++ /dev/null @@ -1,143 +0,0 @@ -/** - * 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.controller; - -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.thingsboard.server.common.data.ai.AiModelSettings; -import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.common.data.id.AiModelSettingsId; -import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.config.annotations.ApiOperation; -import org.thingsboard.server.service.security.permission.Operation; -import org.thingsboard.server.service.security.permission.Resource; - -import java.util.Optional; -import java.util.UUID; - -import static org.thingsboard.server.controller.ControllerConstants.AI_MODEL_SETTINGS_TEXT_SEARCH_DESCRIPTION; -import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; -import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; -import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; -import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; -import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; -import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; - -@Validated -@RestController -@RequestMapping("/api/ai/model/settings") -class AiModelSettingsController extends BaseController { - - @ApiOperation( - value = "Create or update AI model settings (saveAiModelSettings)", - notes = "Creates or updates an AI model settings record.\n\n" + - "• **Create:** Omit the `id` to create a new record. The platform assigns a UUID to the new settings and returns it in the `id` field of the response.\n\n" + - "• **Update:** Include an existing `id` to modify that record. If no matching record exists, the API responds with **404 Not Found**.\n\n" + - "Tenant ID for the AI model settings will be taken from the authenticated user making the request, regardless of any value provided in the request body." + - TENANT_AUTHORITY_PARAGRAPH - ) - @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @PostMapping - public AiModelSettings saveAiModelSettings(@RequestBody @Valid AiModelSettings settings) throws ThingsboardException { - var user = getCurrentUser(); - settings.setTenantId(user.getTenantId()); - checkEntity(settings.getId(), settings, Resource.AI_MODEL_SETTINGS); - return tbAiModelSettingsService.save(settings, user); - } - - @ApiOperation( - value = "Get AI model settings by ID (getAiModelSettingsById)", - notes = "Fetches an AI model settings record by its `id`." + - TENANT_AUTHORITY_PARAGRAPH - ) - @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping("/{settingsUuid}") - public AiModelSettings getAiModelSettingsById( - @Parameter( - description = "ID of the AI model settings record", - required = true, - example = "de7900d4-30e2-11f0-9cd2-0242ac120002" - ) - @PathVariable UUID settingsUuid - ) throws ThingsboardException { - return checkAiModelSettingsId(new AiModelSettingsId(settingsUuid), Operation.READ); - } - - @ApiOperation( - value = "Get AI model settings (getAiModelSettings)", - notes = "Returns a page of AI model settings. " + - PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH - ) - @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping - public PageData getAiModelSettings( - @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) - @RequestParam int pageSize, - @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) - @RequestParam int page, - @Parameter(description = AI_MODEL_SETTINGS_TEXT_SEARCH_DESCRIPTION) - @RequestParam(required = false) String textSearch, - @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name", "provider", "modelId"})) - @RequestParam(required = false) String sortProperty, - @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) - @RequestParam(required = false) String sortOrder - ) throws ThingsboardException { - var user = getCurrentUser(); - accessControlService.checkPermission(user, Resource.AI_MODEL_SETTINGS, Operation.READ); - var pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); - return aiModelSettingsService.findAiModelSettingsByTenantId(user.getTenantId(), pageLink); - } - - @ApiOperation( - value = "Delete AI model settings by ID (deleteAiModelSettingsById)", - notes = "Deletes the AI model settings record by its `id`. " + - "If a record with the specified `id` exists, the record is deleted and the endpoint returns `true`. " + - "If no such record exists, the endpoint returns `false`." + - TENANT_AUTHORITY_PARAGRAPH - ) - @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @DeleteMapping("/{settingsUuid}") - public boolean deleteAiModelSettingsById( - @Parameter( - description = "ID of the AI model settings record", - required = true, - example = "de7900d4-30e2-11f0-9cd2-0242ac120002" - ) - @PathVariable UUID settingsUuid - ) throws ThingsboardException { - var user = getCurrentUser(); - var settingsId = new AiModelSettingsId(settingsUuid); - accessControlService.checkPermission(user, Resource.AI_MODEL_SETTINGS, Operation.DELETE); - Optional toDelete = aiModelSettingsService.findAiModelSettingsByTenantIdAndId(user.getTenantId(), settingsId); - if (toDelete.isEmpty()) { - return false; - } - accessControlService.checkPermission(user, Resource.AI_MODEL_SETTINGS, Operation.DELETE, settingsId, toDelete.get()); - return tbAiModelSettingsService.delete(toDelete.get(), user); - } - -} diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 0ccd01bb4f..61dcd76b32 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -61,7 +61,7 @@ import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantInfo; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmInfo; @@ -76,7 +76,7 @@ import org.thingsboard.server.common.data.edge.EdgeInfo; import org.thingsboard.server.common.data.exception.EntityVersionMismatchException; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.common.data.id.AiModelSettingsId; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.AssetId; @@ -131,7 +131,7 @@ import org.thingsboard.server.common.data.util.ThrowingBiFunction; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.common.data.widget.WidgetsBundle; -import org.thingsboard.server.dao.ai.AiModelSettingsService; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -178,7 +178,7 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.action.EntityActionService; import org.thingsboard.server.service.component.ComponentDiscoveryService; import org.thingsboard.server.service.entitiy.TbLogEntityActionService; -import org.thingsboard.server.service.entitiy.ai.TbAiModelSettingsService; +import org.thingsboard.server.service.entitiy.ai.TbAiModelService; import org.thingsboard.server.service.entitiy.user.TbUserSettingsService; import org.thingsboard.server.service.ota.OtaPackageStateService; import org.thingsboard.server.service.profile.TbAssetProfileCache; @@ -383,10 +383,10 @@ public abstract class BaseController { protected CalculatedFieldService calculatedFieldService; @Autowired - protected AiModelSettingsService aiModelSettingsService; + protected AiModelService aiModelService; @Autowired - protected TbAiModelSettingsService tbAiModelSettingsService; + protected TbAiModelService tbAiModelService; @Value("${server.log_controller_error_stack_trace}") @Getter @@ -400,7 +400,7 @@ public abstract class BaseController { public void handleControllerException(Exception e, HttpServletResponse response) { ThingsboardException thingsboardException = handleException(e); if (thingsboardException.getErrorCode() == ThingsboardErrorCode.GENERAL && thingsboardException.getCause() instanceof Exception - && StringUtils.equals(thingsboardException.getCause().getMessage(), thingsboardException.getMessage())) { + && StringUtils.equals(thingsboardException.getCause().getMessage(), thingsboardException.getMessage())) { e = (Exception) thingsboardException.getCause(); } else { e = thingsboardException; @@ -448,7 +448,7 @@ public abstract class BaseController { if (exception instanceof ThingsboardException) { return (ThingsboardException) exception; } else if (exception instanceof IllegalArgumentException || exception instanceof IncorrectParameterException - || exception instanceof DataValidationException || cause instanceof IncorrectParameterException) { + || exception instanceof DataValidationException || cause instanceof IncorrectParameterException) { return new ThingsboardException(exception.getMessage(), ThingsboardErrorCode.BAD_REQUEST_PARAMS); } else if (exception instanceof MessagingException) { return new ThingsboardException("Unable to send mail", ThingsboardErrorCode.GENERAL); @@ -644,7 +644,7 @@ public abstract class BaseController { case MOBILE_APP -> checkMobileAppId(new MobileAppId(entityId.getId()), operation); case MOBILE_APP_BUNDLE -> checkMobileAppBundleId(new MobileAppBundleId(entityId.getId()), operation); case CALCULATED_FIELD -> checkCalculatedFieldId(new CalculatedFieldId(entityId.getId()), operation); - case AI_MODEL_SETTINGS -> checkAiModelSettingsId(new AiModelSettingsId(entityId.getId()), operation); + case AI_MODEL -> checkAiModelId(new AiModelId(entityId.getId()), operation); default -> (HasId) checkEntityId(entityId, entitiesService::findEntityByTenantIdAndId, operation); }; } catch (Exception e) { @@ -848,8 +848,8 @@ public abstract class BaseController { return checkEntityId(jobId, jobService::findJobById, operation); } - AiModelSettings checkAiModelSettingsId(AiModelSettingsId settingsId, Operation operation) throws ThingsboardException { - return checkEntityId(settingsId, (tenantId, id) -> aiModelSettingsService.findAiModelSettingsByTenantIdAndId(tenantId, id).orElse(null), operation); + AiModel checkAiModelId(AiModelId settingsId, Operation operation) throws ThingsboardException { + return checkEntityId(settingsId, (tenantId, id) -> aiModelService.findAiModelByTenantIdAndId(tenantId, id).orElse(null), operation); } protected I emptyId(EntityType entityType) { diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index 20e350f0b8..a87864726b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -90,7 +90,7 @@ public class ControllerConstants { protected static final String TENANT_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the tenant profile name."; protected static final String RULE_CHAIN_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the rule chain name."; protected static final String DEVICE_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the device profile name."; - protected static final String AI_MODEL_SETTINGS_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the AI model settings name, provider and model ID."; + protected static final String AI_MODEL_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the AI model name, provider and model ID."; protected static final String ASSET_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the asset profile name."; protected static final String CUSTOMER_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the customer title."; diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiModelService.java b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelService.java similarity index 82% rename from application/src/main/java/org/thingsboard/server/service/ai/AiModelService.java rename to application/src/main/java/org/thingsboard/server/service/ai/AiChatModelService.java index 196a53ce3a..9e00c8ddfd 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/AiModelService.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelService.java @@ -15,6 +15,6 @@ */ package org.thingsboard.server.service.ai; -import org.thingsboard.rule.engine.api.RuleEngineAiModelService; +import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; -public interface AiModelService extends RuleEngineAiModelService {} +public interface AiChatModelService extends RuleEngineAiChatModelService {} diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiModelServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java similarity index 79% rename from application/src/main/java/org/thingsboard/server/service/ai/AiModelServiceImpl.java rename to application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java index 877f80fb26..d6252f57a6 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/AiModelServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java @@ -21,21 +21,20 @@ import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.response.ChatResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.thingsboard.server.common.data.ai.model.chat.AiChatModel; import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; @Service @RequiredArgsConstructor -class AiModelServiceImpl implements AiModelService { +class AiChatModelServiceImpl implements AiChatModelService { private final Langchain4jChatModelConfigurer chatModelConfigurer; private final AiRequestsExecutor aiRequestsExecutor; @Override - public > FluentFuture sendChatRequestAsync(AiChatModel chatModel, ChatRequest chatRequest) { - ChatModel lc4jChatModel = chatModel.configure(chatModelConfigurer); - return aiRequestsExecutor.sendChatRequestAsync(lc4jChatModel, chatRequest); + public > FluentFuture sendChatRequestAsync(AiChatModelConfig chatModelConfig, ChatRequest chatRequest) { + ChatModel langChainChatModel = chatModelConfig.configure(chatModelConfigurer); + return aiRequestsExecutor.sendChatRequestAsync(langChainChatModel, chatRequest); } } 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 10e327b48c..69dd98f47f 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 @@ -24,20 +24,26 @@ import com.google.cloud.vertexai.api.GenerationConfig; import com.google.cloud.vertexai.api.PredictionServiceClient; import com.google.cloud.vertexai.api.PredictionServiceSettings; import com.google.cloud.vertexai.generativeai.GenerativeModel; +import dev.langchain4j.model.anthropic.AnthropicChatModel; +import dev.langchain4j.model.azure.AzureOpenAiChatModel; import dev.langchain4j.model.bedrock.BedrockChatModel; import dev.langchain4j.model.chat.ChatModel; 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.openai.OpenAiChatModel; import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModel; -import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModel; -import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModel; -import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModel; -import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModel; -import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelConfig; +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.Langchain4jChatModelConfigurer; -import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModel; -import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig; +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; @@ -54,61 +60,57 @@ import java.time.Duration; class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigurer { @Override - public ChatModel configureChatModel(OpenAiChatModel chatModel) { - OpenAiChatModel.Config modelConfig = chatModel.modelConfig(); - return dev.langchain4j.model.openai.OpenAiChatModel.builder() - .apiKey(chatModel.providerConfig().apiKey()) - .modelName(modelConfig.modelId()) - .temperature(modelConfig.temperature()) - .topP(modelConfig.topP()) - .frequencyPenalty(modelConfig.frequencyPenalty()) - .presencePenalty(modelConfig.presencePenalty()) - .maxTokens(modelConfig.maxOutputTokens()) - .timeout(toDuration(modelConfig.timeoutSeconds())) - .maxRetries(modelConfig.maxRetries()) + public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) { + return OpenAiChatModel.builder() + .apiKey(chatModelConfig.providerConfig().apiKey()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) .build(); } @Override - public ChatModel configureChatModel(AzureOpenAiChatModel chatModel) { - AzureOpenAiProviderConfig providerConfig = chatModel.providerConfig(); - AzureOpenAiChatModel.Config modelConfig = chatModel.modelConfig(); - return dev.langchain4j.model.azure.AzureOpenAiChatModel.builder() + public ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig) { + AzureOpenAiProviderConfig providerConfig = chatModelConfig.providerConfig(); + return AzureOpenAiChatModel.builder() .endpoint(providerConfig.endpoint()) .serviceVersion(providerConfig.serviceVersion()) .apiKey(providerConfig.apiKey()) - .deploymentName(modelConfig.modelId()) - .temperature(modelConfig.temperature()) - .topP(modelConfig.topP()) - .frequencyPenalty(modelConfig.frequencyPenalty()) - .presencePenalty(modelConfig.presencePenalty()) - .maxTokens(modelConfig.maxOutputTokens()) - .timeout(toDuration(modelConfig.timeoutSeconds())) - .maxRetries(modelConfig.maxRetries()) + .deploymentName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) .build(); } @Override - public ChatModel configureChatModel(GoogleAiGeminiChatModel chatModel) { - GoogleAiGeminiChatModel.Config modelConfig = chatModel.modelConfig(); - return dev.langchain4j.model.googleai.GoogleAiGeminiChatModel.builder() - .apiKey(chatModel.providerConfig().apiKey()) - .modelName(modelConfig.modelId()) - .temperature(modelConfig.temperature()) - .topP(modelConfig.topP()) - .topK(modelConfig.topK()) - .frequencyPenalty(modelConfig.frequencyPenalty()) - .presencePenalty(modelConfig.presencePenalty()) - .maxOutputTokens(modelConfig.maxOutputTokens()) - .timeout(toDuration(modelConfig.timeoutSeconds())) - .maxRetries(modelConfig.maxRetries()) + public ChatModel configureChatModel(GoogleAiGeminiChatModelConfig chatModelConfig) { + return GoogleAiGeminiChatModel.builder() + .apiKey(chatModelConfig.providerConfig().apiKey()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .topK(chatModelConfig.topK()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxOutputTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) .build(); } @Override - public ChatModel configureChatModel(GoogleVertexAiGeminiChatModel chatModel) { - GoogleVertexAiGeminiProviderConfig providerConfig = chatModel.providerConfig(); - GoogleVertexAiGeminiChatModel.Config modelConfig = chatModel.modelConfig(); + public ChatModel configureChatModel(GoogleVertexAiGeminiChatModelConfig chatModelConfig) { + GoogleVertexAiGeminiProviderConfig providerConfig = chatModelConfig.providerConfig(); // construct service account credentials using service account key JSON ServiceAccountCredentials serviceAccountCredentials; @@ -131,8 +133,8 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .toBuilder(); // set request timeout from model config - if (modelConfig.timeoutSeconds() != null) { - retrySettings.setTotalTimeout(org.threeten.bp.Duration.ofSeconds(modelConfig.timeoutSeconds())); + if (chatModelConfig.timeoutSeconds() != null) { + retrySettings.setTotalTimeout(org.threeten.bp.Duration.ofSeconds(chatModelConfig.timeoutSeconds())); } // set updated retry settings @@ -154,30 +156,30 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur // map model config to generation config var generationConfigBuilder = GenerationConfig.newBuilder(); - if (modelConfig.temperature() != null) { - generationConfigBuilder.setTemperature(modelConfig.temperature().floatValue()); + if (chatModelConfig.temperature() != null) { + generationConfigBuilder.setTemperature(chatModelConfig.temperature().floatValue()); } - if (modelConfig.topP() != null) { - generationConfigBuilder.setTopP(modelConfig.topP().floatValue()); + if (chatModelConfig.topP() != null) { + generationConfigBuilder.setTopP(chatModelConfig.topP().floatValue()); } - if (modelConfig.topK() != null) { - generationConfigBuilder.setTopK(modelConfig.topK()); + if (chatModelConfig.topK() != null) { + generationConfigBuilder.setTopK(chatModelConfig.topK()); } - if (modelConfig.frequencyPenalty() != null) { - generationConfigBuilder.setFrequencyPenalty(modelConfig.frequencyPenalty().floatValue()); + if (chatModelConfig.frequencyPenalty() != null) { + generationConfigBuilder.setFrequencyPenalty(chatModelConfig.frequencyPenalty().floatValue()); } - if (modelConfig.frequencyPenalty() != null) { - generationConfigBuilder.setPresencePenalty(modelConfig.frequencyPenalty().floatValue()); + if (chatModelConfig.frequencyPenalty() != null) { + generationConfigBuilder.setPresencePenalty(chatModelConfig.frequencyPenalty().floatValue()); } - if (modelConfig.maxOutputTokens() != null) { - generationConfigBuilder.setMaxOutputTokens(modelConfig.maxOutputTokens()); + if (chatModelConfig.maxOutputTokens() != null) { + generationConfigBuilder.setMaxOutputTokens(chatModelConfig.maxOutputTokens()); } var generationConfig = generationConfigBuilder.build(); // construct generative model instance - var generativeModel = new GenerativeModel(modelConfig.modelId(), vertexAI).withGenerationConfig(generationConfig); + var generativeModel = new GenerativeModel(chatModelConfig.modelId(), vertexAI).withGenerationConfig(generationConfig); - return new VertexAiGeminiChatModel(generativeModel, generationConfig, modelConfig.maxRetries()); + return new VertexAiGeminiChatModel(generativeModel, generationConfig, chatModelConfig.maxRetries()); } private static PredictionServiceClient createPredictionServiceClient(PredictionServiceSettings settings) { @@ -189,40 +191,37 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur } @Override - public ChatModel configureChatModel(MistralAiChatModel chatModel) { - MistralAiChatModel.Config modelConfig = chatModel.modelConfig(); - return dev.langchain4j.model.mistralai.MistralAiChatModel.builder() - .apiKey(chatModel.providerConfig().apiKey()) - .modelName(modelConfig.modelId()) - .temperature(modelConfig.temperature()) - .topP(modelConfig.topP()) - .frequencyPenalty(modelConfig.frequencyPenalty()) - .presencePenalty(modelConfig.presencePenalty()) - .maxTokens(modelConfig.maxOutputTokens()) - .timeout(toDuration(modelConfig.timeoutSeconds())) - .maxRetries(modelConfig.maxRetries()) + public ChatModel configureChatModel(MistralAiChatModelConfig chatModelConfig) { + return MistralAiChatModel.builder() + .apiKey(chatModelConfig.providerConfig().apiKey()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) .build(); } @Override - public ChatModel configureChatModel(AnthropicChatModel chatModel) { - AnthropicChatModel.Config modelConfig = chatModel.modelConfig(); - return dev.langchain4j.model.anthropic.AnthropicChatModel.builder() - .apiKey(chatModel.providerConfig().apiKey()) - .modelName(modelConfig.modelId()) - .temperature(modelConfig.temperature()) - .topP(modelConfig.topP()) - .topK(modelConfig.topK()) - .maxTokens(modelConfig.maxOutputTokens()) - .timeout(toDuration(modelConfig.timeoutSeconds())) - .maxRetries(modelConfig.maxRetries()) + public ChatModel configureChatModel(AnthropicChatModelConfig chatModelConfig) { + return AnthropicChatModel.builder() + .apiKey(chatModelConfig.providerConfig().apiKey()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .topK(chatModelConfig.topK()) + .maxTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) .build(); } @Override - public ChatModel configureChatModel(AmazonBedrockChatModel chatModel) { - AmazonBedrockProviderConfig providerConfig = chatModel.providerConfig(); - AmazonBedrockChatModel.Config modelConfig = chatModel.modelConfig(); + public ChatModel configureChatModel(AmazonBedrockChatModelConfig chatModelConfig) { + AmazonBedrockProviderConfig providerConfig = chatModelConfig.providerConfig(); var credentialsProvider = StaticCredentialsProvider.create( AwsBasicCredentials.create(providerConfig.accessKeyId(), providerConfig.secretAccessKey()) @@ -234,33 +233,32 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .build(); var defaultChatRequestParams = ChatRequestParameters.builder() - .temperature(modelConfig.temperature()) - .topP(modelConfig.topP()) - .maxOutputTokens(modelConfig.maxOutputTokens()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .maxOutputTokens(chatModelConfig.maxOutputTokens()) .build(); return BedrockChatModel.builder() .client(bedrockClient) - .modelId(modelConfig.modelId()) + .modelId(chatModelConfig.modelId()) .defaultRequestParameters(defaultChatRequestParams) - .timeout(toDuration(modelConfig.timeoutSeconds())) - .maxRetries(modelConfig.maxRetries()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) .build(); } @Override - public ChatModel configureChatModel(GitHubModelsChatModel chatModel) { - GitHubModelsChatModel.Config modelConfig = chatModel.modelConfig(); - return dev.langchain4j.model.github.GitHubModelsChatModel.builder() - .gitHubToken(chatModel.providerConfig().personalAccessToken()) - .modelName(modelConfig.modelId()) - .temperature(modelConfig.temperature()) - .topP(modelConfig.topP()) - .frequencyPenalty(modelConfig.frequencyPenalty()) - .presencePenalty(modelConfig.presencePenalty()) - .maxTokens(modelConfig.maxOutputTokens()) - .timeout(toDuration(modelConfig.timeoutSeconds())) - .maxRetries(modelConfig.maxRetries()) + public ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig) { + return GitHubModelsChatModel.builder() + .gitHubToken(chatModelConfig.providerConfig().personalAccessToken()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) .build(); } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java index 8bbed875fe..e31bbe21ac 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java @@ -112,7 +112,7 @@ public class EdgeEventSourcingListener { return; } try { - if (EntityType.TENANT == entityType || EntityType.EDGE == entityType || EntityType.AI_MODEL_SETTINGS == entityType) { + if (EntityType.TENANT == entityType || EntityType.EDGE == entityType || EntityType.AI_MODEL == entityType) { return; } log.trace("[{}] DeleteEntityEvent called: {}", tenantId, event); @@ -226,7 +226,7 @@ public class EdgeEventSourcingListener { break; case TENANT: return !event.getCreated(); - case API_USAGE_STATE, EDGE, AI_MODEL_SETTINGS: + case API_USAGE_STATE, EDGE, AI_MODEL: return false; case DOMAIN: if (entity instanceof Domain domain) { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java index 5fca13380c..8a111e4d9d 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java @@ -68,7 +68,7 @@ public class RelatedEdgesSourcingListener { @TransactionalEventListener( fallbackExecution = true, - condition = "#event.entityId.getEntityType() != T(org.thingsboard.server.common.data.EntityType).AI_MODEL_SETTINGS" + condition = "#event.entityId.getEntityType() != T(org.thingsboard.server.common.data.EntityType).AI_MODEL" ) public void handleEvent(DeleteEntityEvent event) { executorService.submit(() -> { diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelSettingsService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelService.java similarity index 54% rename from application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelSettingsService.java rename to application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelService.java index 69f130461c..264b82dd33 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelSettingsService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelService.java @@ -19,9 +19,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.audit.ActionType; -import org.thingsboard.server.dao.ai.AiModelSettingsService; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; @@ -30,48 +30,48 @@ import static java.util.Objects.requireNonNullElseGet; @Service @TbCoreComponent @RequiredArgsConstructor -class DefaultTbAiModelSettingsService extends AbstractTbEntityService implements TbAiModelSettingsService { +class DefaultTbAiModelService extends AbstractTbEntityService implements TbAiModelService { - private final AiModelSettingsService aiModelSettingsService; + private final AiModelService aiModelService; @Override - public AiModelSettings save(AiModelSettings settings, User user) { - var actionType = settings.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + public AiModel save(AiModel model, User user) { + var actionType = model.getId() == null ? ActionType.ADDED : ActionType.UPDATED; var tenantId = user.getTenantId(); - settings.setTenantId(tenantId); + model.setTenantId(tenantId); - AiModelSettings savedSettings; + AiModel savedModel; try { - savedSettings = aiModelSettingsService.save(settings); - autoCommit(user, savedSettings.getId()); + savedModel = aiModelService.save(model); + autoCommit(user, savedModel.getId()); } catch (Exception e) { - logEntityActionService.logEntityAction(tenantId, requireNonNullElseGet(settings.getId(), () -> emptyId(EntityType.AI_MODEL_SETTINGS)), settings, actionType, user, e); + logEntityActionService.logEntityAction(tenantId, requireNonNullElseGet(model.getId(), () -> emptyId(EntityType.AI_MODEL)), model, actionType, user, e); throw e; } - logEntityActionService.logEntityAction(tenantId, savedSettings.getId(), savedSettings, actionType, user); + logEntityActionService.logEntityAction(tenantId, savedModel.getId(), savedModel, actionType, user); - return savedSettings; + return savedModel; } @Override - public boolean delete(AiModelSettings settings, User user) { + public boolean delete(AiModel model, User user) { var actionType = ActionType.DELETED; var tenantId = user.getTenantId(); - var settingsId = settings.getId(); + var modelId = model.getId(); boolean deleted; try { - deleted = aiModelSettingsService.deleteByTenantIdAndId(tenantId, settingsId); + deleted = aiModelService.deleteByTenantIdAndId(tenantId, modelId); } catch (Exception e) { - logEntityActionService.logEntityAction(tenantId, settingsId, settings, actionType, user, e, settingsId.toString()); + logEntityActionService.logEntityAction(tenantId, modelId, model, actionType, user, e, modelId.toString()); throw e; } if (deleted) { - logEntityActionService.logEntityAction(tenantId, settingsId, settings, actionType, user, settingsId.toString()); + logEntityActionService.logEntityAction(tenantId, modelId, model, actionType, user, modelId.toString()); } return deleted; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiModelSettingsService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiModelService.java similarity index 76% rename from application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiModelSettingsService.java rename to application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiModelService.java index 0d66c171a7..0b09423ffa 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiModelSettingsService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiModelService.java @@ -16,12 +16,12 @@ package org.thingsboard.server.service.entitiy.ai; import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.ai.AiModel; -public interface TbAiModelSettingsService { +public interface TbAiModelService { - AiModelSettings save(AiModelSettings settings, User user); + AiModel save(AiModel model, User user); - boolean delete(AiModelSettings settings, User user); + boolean delete(AiModel model, User user); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java index ba7b810c7d..701fed952b 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java @@ -53,7 +53,7 @@ public enum Resource { EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE), MOBILE_APP_SETTINGS, JOB(EntityType.JOB), - AI_MODEL_SETTINGS(EntityType.AI_MODEL_SETTINGS); + AI_MODEL(EntityType.AI_MODEL); private final Set entityTypes; diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java index 6b2f6f8ccf..7a824ca735 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java @@ -18,8 +18,8 @@ package org.thingsboard.server.service.security.permission; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.ai.AiModelSettings; -import org.thingsboard.server.common.data.id.AiModelSettingsId; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; @@ -58,7 +58,7 @@ public class TenantAdminPermissions extends AbstractPermissions { put(Resource.MOBILE_APP, tenantEntityPermissionChecker); put(Resource.MOBILE_APP_BUNDLE, tenantEntityPermissionChecker); put(Resource.JOB, tenantEntityPermissionChecker); - put(Resource.AI_MODEL_SETTINGS, aiModelSettingsPermissionChecker); + put(Resource.AI_MODEL, aiModelPermissionChecker); } public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() { @@ -149,7 +149,7 @@ public class TenantAdminPermissions extends AbstractPermissions { }; - private static final PermissionChecker aiModelSettingsPermissionChecker = new PermissionChecker<>() { + private static final PermissionChecker aiModelPermissionChecker = new PermissionChecker<>() { @Override public boolean hasPermission(SecurityUser user, Operation operation) { @@ -157,7 +157,7 @@ public class TenantAdminPermissions extends AbstractPermissions { } @Override - public boolean hasPermission(SecurityUser user, Operation operation, AiModelSettingsId entityId, AiModelSettings entity) { + public boolean hasPermission(SecurityUser user, Operation operation, AiModelId entityId, AiModel entity) { return user.getTenantId().equals(entity.getTenantId()); } diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java index 82f855b7c2..b000041a26 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java @@ -70,7 +70,7 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS EntityType.DEVICE_PROFILE, EntityType.DEVICE, EntityType.ENTITY_VIEW, EntityType.WIDGET_TYPE, EntityType.WIDGETS_BUNDLE, EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, - EntityType.AI_MODEL_SETTINGS + EntityType.AI_MODEL ); @Override diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelSettingsExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelExportService.java similarity index 76% rename from application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelSettingsExportService.java rename to application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelExportService.java index a5ba16e9b1..8d6097b726 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelSettingsExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelExportService.java @@ -17,8 +17,8 @@ package org.thingsboard.server.service.sync.ie.exporting.impl; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.ai.AiModelSettings; -import org.thingsboard.server.common.data.id.AiModelSettingsId; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.sync.ie.EntityExportData; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -26,11 +26,11 @@ import java.util.Set; @Service @TbCoreComponent -class AiModelSettingsExportService extends BaseEntityExportService> { +class AiModelExportService extends BaseEntityExportService> { @Override public Set getSupportedEntityTypes() { - return Set.of(EntityType.AI_MODEL_SETTINGS); + return Set.of(EntityType.AI_MODEL); } } diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelSettingsImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelImportService.java similarity index 50% rename from application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelSettingsImportService.java rename to application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelImportService.java index e5666e5003..34e70adb11 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelSettingsImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelImportService.java @@ -18,60 +18,60 @@ package org.thingsboard.server.service.sync.ie.importing.impl; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.ai.AiModelSettings; -import org.thingsboard.server.common.data.id.AiModelSettingsId; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.sync.ie.EntityExportData; -import org.thingsboard.server.dao.ai.AiModelSettingsService; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; @Service @TbCoreComponent @RequiredArgsConstructor -class AiModelSettingsImportService extends BaseEntityImportService> { +class AiModelImportService extends BaseEntityImportService> { - private final AiModelSettingsService aiModelSettingsService; + private final AiModelService aiModelService; @Override protected void setOwner( TenantId tenantId, - AiModelSettings settings, - BaseEntityImportService>.IdProvider idProvider + AiModel model, + BaseEntityImportService>.IdProvider idProvider ) { - settings.setTenantId(tenantId); + model.setTenantId(tenantId); } @Override - protected AiModelSettings prepare( + protected AiModel prepare( EntitiesImportCtx ctx, - AiModelSettings settings, - AiModelSettings oldEntity, - EntityExportData exportData, - BaseEntityImportService>.IdProvider idProvider + AiModel model, + AiModel oldModel, + EntityExportData exportData, + BaseEntityImportService>.IdProvider idProvider ) { - return settings; + return model; } @Override - protected AiModelSettings deepCopy(AiModelSettings settings) { - return new AiModelSettings(settings); + protected AiModel deepCopy(AiModel model) { + return new AiModel(model); } @Override - protected AiModelSettings saveOrUpdate( + protected AiModel saveOrUpdate( EntitiesImportCtx ctx, - AiModelSettings settings, - EntityExportData exportData, - BaseEntityImportService>.IdProvider idProvider, + AiModel model, + EntityExportData exportData, + BaseEntityImportService>.IdProvider idProvider, CompareResult compareResult ) { - return aiModelSettingsService.save(settings); + return aiModelService.save(model); } @Override public EntityType getEntityType() { - return EntityType.AI_MODEL_SETTINGS; + return EntityType.AI_MODEL; } } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 429bbd4a29..c4bf6bf3c8 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -656,9 +656,9 @@ cache: trendzSettings: timeToLiveInMinutes: "${CACHE_SPECS_TRENDZ_SETTINGS_TTL:1440}" # Trendz settings cache TTL maxSize: "${CACHE_SPECS_TRENDZ_SETTINGS_MAX_SIZE:10000}" # 0 means the cache is disabled - aiModelSettings: - timeToLiveInMinutes: "${CACHE_SPECS_AI_MODEL_SETTINGS_TTL:1440}" # AI model settings cache TTL - maxSize: "${CACHE_SPECS_AI_MODEL_SETTINGS_MAX_SIZE:10000}" # 0 means the cache is disabled + aiModel: + timeToLiveInMinutes: "${CACHE_SPECS_AI_MODEL_TTL:1440}" # AI model cache TTL + maxSize: "${CACHE_SPECS_AI_MODEL_MAX_SIZE:10000}" # 0 means the cache is disabled # Deliberately placed outside the 'specs' group above notificationRules: @@ -874,7 +874,7 @@ audit-log: "tb_resource": "${AUDIT_LOG_MASK_RESOURCE:W}" # TB resource logging levels. "ota_package": "${AUDIT_LOG_MASK_OTA_PACKAGE:W}" # Ota package logging levels. "calculated_field": "${AUDIT_LOG_MASK_CALCULATED_FIELD:W}" # Calculated field logging levels. - "ai_model_settings": "${AUDIT_LOG_MASK_AI_MODEL_SETTINGS:W}" # AI model settings logging levels. + "ai_model": "${AUDIT_LOG_MASK_AI_MODEL:W}" # AI model logging levels. sink: # Type of external sink. possible options: none, elasticsearch type: "${AUDIT_LOG_SINK_TYPE:none}" diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java similarity index 55% rename from common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsService.java rename to common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java index 09219f238e..3ad12048cf 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java @@ -16,8 +16,8 @@ package org.thingsboard.server.dao.ai; import com.google.common.util.concurrent.FluentFuture; -import org.thingsboard.server.common.data.ai.AiModelSettings; -import org.thingsboard.server.common.data.id.AiModelSettingsId; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -25,18 +25,18 @@ import org.thingsboard.server.dao.entity.EntityDaoService; import java.util.Optional; -public interface AiModelSettingsService extends EntityDaoService { +public interface AiModelService extends EntityDaoService { - AiModelSettings save(AiModelSettings settings); + AiModel save(AiModel model); - Optional findAiModelSettingsById(TenantId tenantId, AiModelSettingsId settingsId); + Optional findAiModelById(TenantId tenantId, AiModelId modelId); - PageData findAiModelSettingsByTenantId(TenantId tenantId, PageLink pageLink); + PageData findAiModelsByTenantId(TenantId tenantId, PageLink pageLink); - Optional findAiModelSettingsByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId); + Optional findAiModelByTenantIdAndId(TenantId tenantId, AiModelId modelId); - FluentFuture> findAiModelSettingsByTenantIdAndIdAsync(TenantId tenantId, AiModelSettingsId settingsId); + FluentFuture> findAiModelByTenantIdAndIdAsync(TenantId tenantId, AiModelId modelId); - boolean deleteByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId); + boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index c5df7c10c0..b55453f393 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -17,6 +17,8 @@ package org.thingsboard.server.common.data; public final class CacheConstants { + private CacheConstants() {} + public static final String DEVICE_CREDENTIALS_CACHE = "deviceCredentials"; public static final String RELATIONS_CACHE = "relations"; public static final String DEVICE_CACHE = "devices"; @@ -37,7 +39,7 @@ public final class CacheConstants { public static final String NOTIFICATION_SETTINGS_CACHE = "notificationSettings"; public static final String SENT_NOTIFICATIONS_CACHE = "sentNotifications"; public static final String TRENDZ_SETTINGS_CACHE = "trendzSettings"; - public static final String AI_MODEL_SETTINGS_CACHE = "aiModelSettings"; + public static final String AI_MODEL_CACHE = "aiModel"; public static final String ASSET_PROFILE_CACHE = "assetProfiles"; public static final String ATTRIBUTES_CACHE = "attributes"; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java index dddafe05bc..8c132a3eba 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java @@ -66,10 +66,10 @@ public enum EntityType { CALCULATED_FIELD(39), CALCULATED_FIELD_LINK(40), JOB(41), - AI_MODEL_SETTINGS(42, "ai_model_settings") { + AI_MODEL(42, "ai_model") { @Override public String getNormalName() { - return "AI model settings"; + return "AI model"; } }; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModelSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java similarity index 67% rename from common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModelSettings.java rename to common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java index 63e94f1974..4d7bb21930 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModelSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java @@ -27,8 +27,8 @@ import org.thingsboard.server.common.data.BaseData; import org.thingsboard.server.common.data.ExportableEntity; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.HasVersion; -import org.thingsboard.server.common.data.ai.model.AiModel; -import org.thingsboard.server.common.data.id.AiModelSettingsId; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoNullChar; @@ -39,7 +39,7 @@ import java.io.Serial; @Builder @AllArgsConstructor @EqualsAndHashCode(callSuper = true) -public final class AiModelSettings extends BaseData implements HasTenantId, HasVersion, ExportableEntity { +public final class AiModel extends BaseData implements HasTenantId, HasVersion, ExportableEntity { @Serial private static final long serialVersionUID = 9017108678716011604L; @@ -47,7 +47,7 @@ public final class AiModelSettings extends BaseData implement @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, - description = "JSON object representing the ID of the tenant associated with these AI model settings", + description = "JSON object representing the ID of the tenant associated with this AI model", example = "e3c4b7d2-5678-4a9b-0c1d-2e3f4a5b6c7d" ) private TenantId tenantId; @@ -55,7 +55,7 @@ public final class AiModelSettings extends BaseData implement @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, - description = "Version of the AI model settings; increments automatically whenever the settings are changed", + description = "Version of the AI model record; increments automatically whenever the record is changed", example = "7", defaultValue = "1" ) @@ -67,8 +67,8 @@ public final class AiModelSettings extends BaseData implement @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, - description = "Human-readable name of the AI model settings; must be unique within the scope of the tenant", - example = "Rule node assistant" + description = "Display name for this AI model configuration; not the technical model identifier", + example = "Fast and cost-efficient model" ) private String name; @@ -79,24 +79,24 @@ public final class AiModelSettings extends BaseData implement accessMode = Schema.AccessMode.READ_WRITE, description = "Configuration of the AI model" ) - private AiModel configuration; + private AiModelConfig configuration; - private AiModelSettingsId externalId; + private AiModelId externalId; - public AiModelSettings() {} + public AiModel() {} - public AiModelSettings(AiModelSettingsId id) { + public AiModel(AiModelId id) { super(id); } - public AiModelSettings(AiModelSettings settings) { - super(settings.getId()); - createdTime = settings.getCreatedTime(); - tenantId = settings.getTenantId(); - version = settings.getVersion(); - name = settings.getName(); - configuration = settings.getConfiguration(); - externalId = settings.getExternalId() == null ? null : new AiModelSettingsId(settings.getExternalId().getId()); + public AiModel(AiModel model) { + super(model.getId()); + createdTime = model.getCreatedTime(); + tenantId = model.getTenantId(); + version = model.getVersion(); + name = model.getName(); + configuration = model.getConfiguration(); + externalId = model.getExternalId() == null ? null : new AiModelId(model.getExternalId().getId()); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatRequest.java index aa737b27d3..7e43520b79 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatRequest.java @@ -23,7 +23,7 @@ import dev.langchain4j.model.chat.request.ChatRequest; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; -import org.thingsboard.server.common.data.ai.model.chat.AiChatModel; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; import java.util.ArrayList; import java.util.List; @@ -51,7 +51,7 @@ public record TbChatRequest( description = "Configuration of the AI chat model that should execute the request" ) @NotNull @Valid - AiChatModel chatModel + AiChatModelConfig chatModelConfig ) { public ChatRequest toLangChainChatRequest() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java deleted file mode 100644 index affa159e06..0000000000 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModel.java +++ /dev/null @@ -1,84 +0,0 @@ -/** - * 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; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonSubTypes; -import com.fasterxml.jackson.annotation.JsonTypeInfo; -import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModel; -import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModel; -import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModel; -import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModel; -import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModel; -import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModel; -import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModel; -import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModel; -import org.thingsboard.server.common.data.ai.provider.AiProvider; -import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; -import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; -import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; -import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; -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.OpenAiProviderConfig; - -@JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.PROPERTY, - property = "provider", - visible = true -) -@JsonSubTypes({ - @JsonSubTypes.Type(value = OpenAiChatModel.class, name = "OPENAI"), - @JsonSubTypes.Type(value = AzureOpenAiChatModel.class, name = "AZURE_OPENAI"), - @JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "GOOGLE_AI_GEMINI"), - @JsonSubTypes.Type(value = GoogleVertexAiGeminiChatModel.class, name = "GOOGLE_VERTEX_AI_GEMINI"), - @JsonSubTypes.Type(value = MistralAiChatModel.class, name = "MISTRAL_AI"), - @JsonSubTypes.Type(value = AnthropicChatModel.class, name = "ANTHROPIC"), - @JsonSubTypes.Type(value = AmazonBedrockChatModel.class, name = "AMAZON_BEDROCK"), - @JsonSubTypes.Type(value = GitHubModelsChatModel.class, name = "GITHUB_MODELS") -}) -public interface AiModel { - - AiProvider provider(); - - @JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.EXTERNAL_PROPERTY, - property = "provider" - ) - @JsonSubTypes({ - @JsonSubTypes.Type(value = OpenAiProviderConfig.class, name = "OPENAI"), - @JsonSubTypes.Type(value = AzureOpenAiProviderConfig.class, name = "AZURE_OPENAI"), - @JsonSubTypes.Type(value = GoogleAiGeminiProviderConfig.class, name = "GOOGLE_AI_GEMINI"), - @JsonSubTypes.Type(value = GoogleVertexAiGeminiProviderConfig.class, name = "GOOGLE_VERTEX_AI_GEMINI"), - @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") - }) - AiProviderConfig providerConfig(); - - @JsonProperty("modelType") - AiModelType modelType(); - - C modelConfig(); - - AiModel withModelConfig(C config); - -} 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 1b0ab7921f..d9e6a1753e 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 @@ -15,8 +15,66 @@ */ package org.thingsboard.server.common.data.ai.model; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelConfig; +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.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; +import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; +import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +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.OpenAiProviderConfig; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "provider", + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = OpenAiChatModelConfig.class, name = "OPENAI"), + @JsonSubTypes.Type(value = AzureOpenAiChatModelConfig.class, name = "AZURE_OPENAI"), + @JsonSubTypes.Type(value = GoogleAiGeminiChatModelConfig.class, name = "GOOGLE_AI_GEMINI"), + @JsonSubTypes.Type(value = GoogleVertexAiGeminiChatModelConfig.class, name = "GOOGLE_VERTEX_AI_GEMINI"), + @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") +}) public interface AiModelConfig { - String modelId(); + AiProvider provider(); + + @JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXTERNAL_PROPERTY, + property = "provider" + ) + @JsonSubTypes({ + @JsonSubTypes.Type(value = OpenAiProviderConfig.class, name = "OPENAI"), + @JsonSubTypes.Type(value = AzureOpenAiProviderConfig.class, name = "AZURE_OPENAI"), + @JsonSubTypes.Type(value = GoogleAiGeminiProviderConfig.class, name = "GOOGLE_AI_GEMINI"), + @JsonSubTypes.Type(value = GoogleVertexAiGeminiProviderConfig.class, name = "GOOGLE_VERTEX_AI_GEMINI"), + @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") + }) + AiProviderConfig providerConfig(); + + @JsonProperty("modelType") + AiModelType modelType(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java deleted file mode 100644 index b9a2737b20..0000000000 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModel.java +++ /dev/null @@ -1,41 +0,0 @@ -/** - * 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 org.thingsboard.server.common.data.ai.model.AiModel; -import org.thingsboard.server.common.data.ai.model.AiModelType; - -public sealed interface AiChatModel> extends AiModel - permits - OpenAiChatModel, AzureOpenAiChatModel, GoogleAiGeminiChatModel, - GoogleVertexAiGeminiChatModel, MistralAiChatModel, AnthropicChatModel, - AmazonBedrockChatModel, GitHubModelsChatModel { - - ChatModel configure(Langchain4jChatModelConfigurer configurer); - - @Override - default AiModelType modelType() { - return AiModelType.CHAT; - } - - @Override - C modelConfig(); - - @Override - AiChatModel withModelConfig(C config); - -} 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 67d9827cc6..2284d8b485 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 @@ -15,13 +15,22 @@ */ package org.thingsboard.server.common.data.ai.model.chat; +import dev.langchain4j.model.chat.ChatModel; import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.ai.model.AiModelType; public sealed interface AiChatModelConfig> extends AiModelConfig permits - OpenAiChatModel.Config, AzureOpenAiChatModel.Config, GoogleAiGeminiChatModel.Config, - GoogleVertexAiGeminiChatModel.Config, MistralAiChatModel.Config, AnthropicChatModel.Config, - AmazonBedrockChatModel.Config, GitHubModelsChatModel.Config { + OpenAiChatModelConfig, AzureOpenAiChatModelConfig, GoogleAiGeminiChatModelConfig, + GoogleVertexAiGeminiChatModelConfig, MistralAiChatModelConfig, AnthropicChatModelConfig, + AmazonBedrockChatModelConfig, GitHubModelsChatModelConfig { + + ChatModel configure(Langchain4jChatModelConfigurer configurer); + + @Override + default AiModelType modelType() { + return AiModelType.CHAT; + } Integer timeoutSeconds(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java similarity index 75% rename from common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java rename to common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java index 356bdd5c42..1f4e4645dd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java @@ -27,27 +27,22 @@ import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; -public record AmazonBedrockChatModel( +public record AmazonBedrockChatModelConfig( AiModelType modelType, @NotNull @Valid AmazonBedrockProviderConfig providerConfig, - @With @NotNull @Valid Config modelConfig -) implements AiChatModel { + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { @Override public AiProvider provider() { return AiProvider.AMAZON_BEDROCK; } - @With - public record Config( - @NotBlank String modelId, - @PositiveOrZero Double temperature, - @Positive @Max(1) Double topP, - @Positive Integer maxOutputTokens, - @Positive Integer timeoutSeconds, - @PositiveOrZero Integer maxRetries - ) implements AiChatModelConfig {} - @Override public ChatModel configure(Langchain4jChatModelConfigurer configurer) { return configurer.configureChatModel(this); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java similarity index 74% rename from common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java rename to common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java index 9ab43799fb..69af0f8c2b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java @@ -27,28 +27,23 @@ import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; -public record AnthropicChatModel( +public record AnthropicChatModelConfig( AiModelType modelType, @NotNull @Valid AnthropicProviderConfig providerConfig, - @With @NotNull @Valid Config modelConfig -) implements AiChatModel { + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + @Positive Integer topK, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { @Override public AiProvider provider() { return AiProvider.ANTHROPIC; } - @With - public record Config( - @NotBlank String modelId, - @PositiveOrZero Double temperature, - @Positive @Max(1) Double topP, - @Positive Integer topK, - @Positive Integer maxOutputTokens, - @Positive Integer timeoutSeconds, - @PositiveOrZero Integer maxRetries - ) implements AiChatModelConfig {} - @Override public ChatModel configure(Langchain4jChatModelConfigurer configurer) { return configurer.configureChatModel(this); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java similarity index 72% rename from common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java rename to common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java index f2cb47190b..afe5f6c563 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java @@ -27,29 +27,24 @@ import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; -public record AzureOpenAiChatModel( +public record AzureOpenAiChatModelConfig( AiModelType modelType, @NotNull @Valid AzureOpenAiProviderConfig providerConfig, - @With @NotNull @Valid Config modelConfig -) implements AiChatModel { + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { @Override public AiProvider provider() { return AiProvider.AZURE_OPENAI; } - @With - public record Config( - @NotBlank String modelId, - @PositiveOrZero Double temperature, - @Positive @Max(1) Double topP, - Double frequencyPenalty, - Double presencePenalty, - @Positive Integer maxOutputTokens, - @Positive Integer timeoutSeconds, - @PositiveOrZero Integer maxRetries - ) implements AiChatModelConfig {} - @Override public ChatModel configure(Langchain4jChatModelConfigurer configurer) { return configurer.configureChatModel(this); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java similarity index 72% rename from common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java rename to common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java index 723f69299d..ecadb7d453 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java @@ -27,29 +27,24 @@ import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig; -public record GitHubModelsChatModel( +public record GitHubModelsChatModelConfig( AiModelType modelType, @NotNull @Valid GitHubModelsProviderConfig providerConfig, - @With @NotNull @Valid Config modelConfig -) implements AiChatModel { + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { @Override public AiProvider provider() { return AiProvider.GITHUB_MODELS; } - @With - public record Config( - @NotBlank String modelId, - @PositiveOrZero Double temperature, - @Positive @Max(1) Double topP, - Double frequencyPenalty, - Double presencePenalty, - @Positive Integer maxOutputTokens, - @Positive Integer timeoutSeconds, - @PositiveOrZero Integer maxRetries - ) implements AiChatModelConfig {} - @Override public ChatModel configure(Langchain4jChatModelConfigurer configurer) { return configurer.configureChatModel(this); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java similarity index 71% rename from common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java rename to common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java index b9cb65d3d9..1922dea6a1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java @@ -27,30 +27,25 @@ import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; -public record GoogleAiGeminiChatModel( +public record GoogleAiGeminiChatModelConfig( AiModelType modelType, @NotNull @Valid GoogleAiGeminiProviderConfig providerConfig, - @With @NotNull @Valid Config modelConfig -) implements AiChatModel { + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + @Positive Integer topK, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { @Override public AiProvider provider() { return AiProvider.GOOGLE_AI_GEMINI; } - @With - public record Config( - @NotBlank String modelId, - @PositiveOrZero Double temperature, - @Positive @Max(1) Double topP, - @Positive Integer topK, - Double frequencyPenalty, - Double presencePenalty, - @Positive Integer maxOutputTokens, - @Positive Integer timeoutSeconds, - @PositiveOrZero Integer maxRetries - ) implements AiChatModelConfig {} - @Override public ChatModel configure(Langchain4jChatModelConfigurer configurer) { return configurer.configureChatModel(this); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java similarity index 71% rename from common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java rename to common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java index 83b7506e11..e8691c0926 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java @@ -27,30 +27,25 @@ import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; -public record GoogleVertexAiGeminiChatModel( +public record GoogleVertexAiGeminiChatModelConfig( AiModelType modelType, @NotNull @Valid GoogleVertexAiGeminiProviderConfig providerConfig, - @With @NotNull @Valid Config modelConfig -) implements AiChatModel { + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + @Positive Integer topK, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { @Override public AiProvider provider() { return AiProvider.GOOGLE_VERTEX_AI_GEMINI; } - @With - public record Config( - @NotBlank String modelId, - @PositiveOrZero Double temperature, - @Positive @Max(1) Double topP, - @Positive Integer topK, - Double frequencyPenalty, - Double presencePenalty, - @Positive Integer maxOutputTokens, - @Positive Integer timeoutSeconds, - @PositiveOrZero Integer maxRetries - ) implements AiChatModelConfig {} - @Override public ChatModel configure(Langchain4jChatModelConfigurer configurer) { return configurer.configureChatModel(this); 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 ea9fb80e73..c9c1bc3173 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 @@ -19,20 +19,20 @@ import dev.langchain4j.model.chat.ChatModel; public interface Langchain4jChatModelConfigurer { - ChatModel configureChatModel(OpenAiChatModel chatModel); + ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig); - ChatModel configureChatModel(AzureOpenAiChatModel chatModel); + ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig); - ChatModel configureChatModel(GoogleAiGeminiChatModel chatModel); + ChatModel configureChatModel(GoogleAiGeminiChatModelConfig chatModelConfig); - ChatModel configureChatModel(GoogleVertexAiGeminiChatModel chatModel); + ChatModel configureChatModel(GoogleVertexAiGeminiChatModelConfig chatModelConfig); - ChatModel configureChatModel(MistralAiChatModel chatModel); + ChatModel configureChatModel(MistralAiChatModelConfig chatModelConfig); - ChatModel configureChatModel(AnthropicChatModel chatModel); + ChatModel configureChatModel(AnthropicChatModelConfig chatModelConfig); - ChatModel configureChatModel(AmazonBedrockChatModel chatModel); + ChatModel configureChatModel(AmazonBedrockChatModelConfig chatModelConfig); - ChatModel configureChatModel(GitHubModelsChatModel chatModel); + ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java similarity index 72% rename from common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java rename to common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java index 58ed807819..ea2409ba35 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java @@ -27,29 +27,24 @@ import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; -public record MistralAiChatModel( +public record MistralAiChatModelConfig( AiModelType modelType, @NotNull @Valid MistralAiProviderConfig providerConfig, - @With @NotNull @Valid Config modelConfig -) implements AiChatModel { + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { @Override public AiProvider provider() { return AiProvider.MISTRAL_AI; } - @With - public record Config( - @NotBlank String modelId, - @PositiveOrZero Double temperature, - @Positive @Max(1) Double topP, - Double frequencyPenalty, - Double presencePenalty, - @Positive Integer maxOutputTokens, - @Positive Integer timeoutSeconds, - @PositiveOrZero Integer maxRetries - ) implements AiChatModelConfig {} - @Override public ChatModel configure(Langchain4jChatModelConfigurer configurer) { return configurer.configureChatModel(this); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java similarity index 73% rename from common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java rename to common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java index 4250291c32..95f6f1cdc3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java @@ -27,29 +27,24 @@ import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; -public record OpenAiChatModel( +public record OpenAiChatModelConfig( AiModelType modelType, @NotNull @Valid OpenAiProviderConfig providerConfig, - @With @NotNull @Valid Config modelConfig -) implements AiChatModel { + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { @Override public AiProvider provider() { return AiProvider.OPENAI; } - @With - public record Config( - @NotBlank String modelId, - @PositiveOrZero Double temperature, - @Positive @Max(1) Double topP, - Double frequencyPenalty, - Double presencePenalty, - @Positive Integer maxOutputTokens, - @Positive Integer timeoutSeconds, - @PositiveOrZero Integer maxRetries - ) implements AiChatModelConfig {} - @Override public ChatModel configure(Langchain4jChatModelConfigurer configurer) { return configurer.configureChatModel(this); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelSettingsId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java similarity index 72% rename from common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelSettingsId.java rename to common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java index 83b4fefab2..cac9e8200c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelSettingsId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java @@ -23,29 +23,29 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; -public final class AiModelSettingsId extends UUIDBased implements EntityId { +public final class AiModelId extends UUIDBased implements EntityId { @Serial private static final long serialVersionUID = 3021036138554389754L; @JsonCreator - public AiModelSettingsId(@JsonProperty("id") UUID id) { + public AiModelId(@JsonProperty("id") UUID id) { super(id); } @Override @Schema( requiredMode = Schema.RequiredMode.REQUIRED, - description = "Entity type of the AI model settings", - example = "AI_MODEL_SETTINGS", - allowableValues = "AI_MODEL_SETTINGS" + description = "Entity type of the AI model", + example = "AI_MODEL", + allowableValues = "AI_MODEL" ) public EntityType getEntityType() { - return EntityType.AI_MODEL_SETTINGS; + return EntityType.AI_MODEL; } - public static AiModelSettingsId fromString(String uuid) { - return new AiModelSettingsId(UUID.fromString(uuid)); + public static AiModelId fromString(String uuid) { + return new AiModelId(UUID.fromString(uuid)); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java index 64504545d4..3638ed1535 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java @@ -85,7 +85,7 @@ public class EntityIdFactory { case CALCULATED_FIELD -> new CalculatedFieldId(uuid); case CALCULATED_FIELD_LINK -> new CalculatedFieldLinkId(uuid); case JOB -> new JobId(uuid); - case AI_MODEL_SETTINGS -> new AiModelSettingsId(uuid); + case AI_MODEL -> new AiModelId(uuid); default -> throw new IllegalArgumentException("EntityType " + type + " is not supported!"); }; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java index 6914b75636..a89fc0b670 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java @@ -26,7 +26,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.TbResource; -import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.notification.rule.NotificationRule; @@ -60,7 +60,7 @@ import java.lang.annotation.Target; @Type(name = "NOTIFICATION_TARGET", value = NotificationTarget.class), @Type(name = "NOTIFICATION_RULE", value = NotificationRule.class), @Type(name = "TB_RESOURCE", value = TbResource.class), - @Type(name = "AI_MODEL_SETTINGS", value = AiModelSettings.class) + @Type(name = "AI_MODEL", value = AiModel.class) }) @JsonIgnoreProperties(value = {"tenantId", "createdTime", "version"}, ignoreUnknown = true) public @interface JsonTbEntity {} diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index f3cb8a5eea..9d213c7fdb 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -64,7 +64,7 @@ enum EntityTypeProto { CALCULATED_FIELD = 39; CALCULATED_FIELD_LINK = 40; JOB = 41; - AI_MODEL_SETTINGS = 42; + AI_MODEL = 42; } enum ApiUsageRecordKeyProto { diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheEvictEvent.java similarity index 58% rename from dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheEvictEvent.java rename to dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheEvictEvent.java index 3bf7dce9ba..b0d4b6fdb6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheEvictEvent.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheEvictEvent.java @@ -15,26 +15,26 @@ */ package org.thingsboard.server.dao.ai; -import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.ai.AiModel; import static java.util.Objects.requireNonNull; -import static org.thingsboard.server.dao.ai.AiModelSettingsCacheEvictEvent.Deleted; -import static org.thingsboard.server.dao.ai.AiModelSettingsCacheEvictEvent.Saved; +import static org.thingsboard.server.dao.ai.AiModelCacheEvictEvent.Deleted; +import static org.thingsboard.server.dao.ai.AiModelCacheEvictEvent.Saved; -sealed interface AiModelSettingsCacheEvictEvent permits Saved, Deleted { +sealed interface AiModelCacheEvictEvent permits Saved, Deleted { - AiModelSettingsCacheKey cacheKey(); + AiModelCacheKey cacheKey(); - record Saved(AiModelSettingsCacheKey cacheKey, AiModelSettings savedSettings) implements AiModelSettingsCacheEvictEvent { + record Saved(AiModelCacheKey cacheKey, AiModel savedModel) implements AiModelCacheEvictEvent { public Saved { requireNonNull(cacheKey); - requireNonNull(savedSettings); + requireNonNull(savedModel); } } - record Deleted(AiModelSettingsCacheKey cacheKey) implements AiModelSettingsCacheEvictEvent { + record Deleted(AiModelCacheKey cacheKey) implements AiModelCacheEvictEvent { public Deleted { requireNonNull(cacheKey); diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheKey.java similarity index 68% rename from dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheKey.java rename to dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheKey.java index 60d0ccaee9..6b73ad7b28 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCacheKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheKey.java @@ -17,7 +17,7 @@ package org.thingsboard.server.dao.ai; import org.checkerframework.checker.nullness.qual.NonNull; import org.thingsboard.server.cache.VersionedCacheKey; -import org.thingsboard.server.common.data.id.AiModelSettingsId; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; @@ -25,22 +25,22 @@ import java.util.UUID; import static java.util.Objects.requireNonNull; -record AiModelSettingsCacheKey(UUID tenantId, UUID settingsId) implements VersionedCacheKey { +record AiModelCacheKey(UUID tenantId, UUID modelId) implements VersionedCacheKey { - AiModelSettingsCacheKey { + AiModelCacheKey { requireNonNull(tenantId); - requireNonNull(settingsId); + requireNonNull(modelId); if (TenantId.SYS_TENANT_ID.getId().equals(tenantId)) { throw new IllegalArgumentException("Tenant ID must not be the system tenant ID"); } - if (EntityId.NULL_UUID.equals(settingsId)) { - throw new IllegalArgumentException("Settings ID must not be reserved null UUID"); + if (EntityId.NULL_UUID.equals(modelId)) { + throw new IllegalArgumentException("Model ID must not be reserved null UUID"); } } - static AiModelSettingsCacheKey of(TenantId tenantId, AiModelSettingsId settingsId) { - return new AiModelSettingsCacheKey(tenantId.getId(), settingsId.getId()); + static AiModelCacheKey of(TenantId tenantId, AiModelId modelId) { + return new AiModelCacheKey(tenantId.getId(), modelId.getId()); } @Override @@ -51,7 +51,7 @@ record AiModelSettingsCacheKey(UUID tenantId, UUID settingsId) implements Versio @NonNull @Override public String toString() { - return /* cache name */ "_" + tenantId + "_" + settingsId; + return /* cache name */ "_" + tenantId + "_" + modelId; } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCaffeineCache.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCaffeineCache.java similarity index 75% rename from dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCaffeineCache.java rename to dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCaffeineCache.java index d758cb21c5..165efcd4e2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsCaffeineCache.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCaffeineCache.java @@ -20,14 +20,14 @@ import org.springframework.cache.CacheManager; import org.springframework.stereotype.Component; import org.thingsboard.server.cache.VersionedCaffeineTbCache; import org.thingsboard.server.common.data.CacheConstants; -import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.ai.AiModel; -@Component("AiModelSettingsCache") +@Component("AiModelCache") @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) -class AiModelSettingsCaffeineCache extends VersionedCaffeineTbCache { +class AiModelCaffeineCache extends VersionedCaffeineTbCache { - AiModelSettingsCaffeineCache(CacheManager cacheManager) { - super(cacheManager, CacheConstants.AI_MODEL_SETTINGS_CACHE); + AiModelCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.AI_MODEL_CACHE); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelDao.java similarity index 60% rename from dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsDao.java rename to dao/src/main/java/org/thingsboard/server/dao/ai/AiModelDao.java index c8d6b65369..e788685bfa 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelDao.java @@ -15,8 +15,8 @@ */ package org.thingsboard.server.dao.ai; -import org.thingsboard.server.common.data.ai.AiModelSettings; -import org.thingsboard.server.common.data.id.AiModelSettingsId; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.ExportableEntityDao; import org.thingsboard.server.dao.TenantEntityDao; @@ -24,14 +24,14 @@ import org.thingsboard.server.dao.TenantEntityDao; import java.util.Optional; import java.util.Set; -public interface AiModelSettingsDao extends TenantEntityDao, ExportableEntityDao { +public interface AiModelDao extends TenantEntityDao, ExportableEntityDao { - Optional findByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId); + Optional findByTenantIdAndId(TenantId tenantId, AiModelId modelId); - boolean deleteById(TenantId tenantId, AiModelSettingsId settingsId); + boolean deleteById(TenantId tenantId, AiModelId modelId); - Set deleteByTenantId(TenantId tenantId); + Set deleteByTenantId(TenantId tenantId); - boolean deleteByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId); + boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelRedisCache.java similarity index 71% rename from dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsRedisCache.java rename to dao/src/main/java/org/thingsboard/server/dao/ai/AiModelRedisCache.java index 8674c522ff..7bec37875f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsRedisCache.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelRedisCache.java @@ -23,14 +23,14 @@ import org.thingsboard.server.cache.TBRedisCacheConfiguration; import org.thingsboard.server.cache.TbJsonRedisSerializer; import org.thingsboard.server.cache.VersionedRedisTbCache; import org.thingsboard.server.common.data.CacheConstants; -import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.ai.AiModel; -@Component("AiSettingsCache") +@Component("AiModelCache") @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") -class AiModelSettingsRedisCache extends VersionedRedisTbCache { +class AiModelRedisCache extends VersionedRedisTbCache { - AiModelSettingsRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { - super(CacheConstants.AI_MODEL_SETTINGS_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(AiModelSettings.class)); + AiModelRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.AI_MODEL_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(AiModel.class)); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java new file mode 100644 index 0000000000..b091a29247 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java @@ -0,0 +1,149 @@ +/** + * 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.ai; + +import com.google.common.util.concurrent.FluentFuture; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; +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.dao.entity.CachedVersionedEntityService; +import org.thingsboard.server.dao.model.sql.AiModelEntity; +import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.dao.sql.JpaExecutorService; + +import java.util.Optional; +import java.util.Set; + +import static org.thingsboard.server.dao.service.Validator.validatePageLink; + +@Service +@RequiredArgsConstructor +class AiModelServiceImpl extends CachedVersionedEntityService implements AiModelService { + + private final DataValidator aiModelValidator; + + private final JpaExecutorService jpaExecutor; + private final AiModelDao aiModelDao; + + @Override + @TransactionalEventListener + public void handleEvictEvent(AiModelCacheEvictEvent event) { + var cacheKey = event.cacheKey(); + if (event instanceof AiModelCacheEvictEvent.Saved savedEvent) { + cache.put(cacheKey, savedEvent.savedModel()); + } else if (event instanceof AiModelCacheEvictEvent.Deleted) { + cache.evict(cacheKey); + } else { + throw new UnsupportedOperationException("Unsupported event type: " + event.getClass().getSimpleName()); + } + } + + @Override + @Transactional + public AiModel save(AiModel model) { + aiModelValidator.validate(model, AiModel::getTenantId); + + AiModel savedModel; + try { + savedModel = aiModelDao.saveAndFlush(model.getTenantId(), model); + } catch (Exception e) { + checkConstraintViolation(e, + "ai_model_name_unq_key", "AI model with such name already exist!", + "ai_model_external_id_unq_key", "AI model with such external ID already exists!"); + throw e; + } + + var cacheKey = AiModelCacheKey.of(savedModel.getTenantId(), savedModel.getId()); + publishEvictEvent(new AiModelCacheEvictEvent.Saved(cacheKey, savedModel)); + + return savedModel; + } + + @Override + public Optional findAiModelById(TenantId tenantId, AiModelId modelId) { + return Optional.ofNullable(aiModelDao.findById(tenantId, modelId.getId())); + } + + @Override + public PageData findAiModelsByTenantId(TenantId tenantId, PageLink pageLink) { + validatePageLink(pageLink, AiModelEntity.ALLOWED_SORT_PROPERTIES); + return aiModelDao.findAllByTenantId(tenantId, pageLink); + } + + @Override + public Optional findAiModelByTenantIdAndId(TenantId tenantId, AiModelId modelId) { + var cacheKey = AiModelCacheKey.of(tenantId, modelId); + return Optional.ofNullable(cache.get(cacheKey, () -> aiModelDao.findByTenantIdAndId(tenantId, modelId).orElse(null))); + } + + @Override + public FluentFuture> findAiModelByTenantIdAndIdAsync(TenantId tenantId, AiModelId modelId) { + return FluentFuture.from(jpaExecutor.submit(() -> findAiModelByTenantIdAndId(tenantId, modelId))); + } + + @Override + @Transactional + public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId) { + return deleteByTenantIdAndIdInternal(tenantId, modelId); + } + + @Override + public Optional> findEntity(TenantId tenantId, EntityId entityId) { + return findAiModelByTenantIdAndId(tenantId, (AiModelId) entityId) + .map(model -> model); // necessary to cast to HasId + } + + @Override + public long countByTenantId(TenantId tenantId) { + return aiModelDao.countByTenantId(tenantId); + } + + @Override + @Transactional + public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { + deleteByTenantIdAndIdInternal(tenantId, new AiModelId(id.getId())); + } + + private boolean deleteByTenantIdAndIdInternal(TenantId tenantId, AiModelId modelId) { + boolean deleted = aiModelDao.deleteByTenantIdAndId(tenantId, modelId); + if (deleted) { + publishEvictEvent(new AiModelCacheEvictEvent.Deleted(AiModelCacheKey.of(tenantId, modelId))); + } + return deleted; + } + + @Override + @Transactional + public void deleteByTenantId(TenantId tenantId) { + Set deleted = aiModelDao.deleteByTenantId(tenantId); + deleted.forEach(id -> publishEvictEvent(new AiModelCacheEvictEvent.Deleted(AiModelCacheKey.of(tenantId, id)))); + } + + @Override + public EntityType getEntityType() { + return EntityType.AI_MODEL; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java deleted file mode 100644 index 2494ab6a15..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelSettingsServiceImpl.java +++ /dev/null @@ -1,149 +0,0 @@ -/** - * 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.ai; - -import com.google.common.util.concurrent.FluentFuture; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.event.TransactionalEventListener; -import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.ai.AiModelSettings; -import org.thingsboard.server.common.data.id.AiModelSettingsId; -import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.id.HasId; -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.dao.entity.CachedVersionedEntityService; -import org.thingsboard.server.dao.model.sql.AiModelSettingsEntity; -import org.thingsboard.server.dao.service.DataValidator; -import org.thingsboard.server.dao.sql.JpaExecutorService; - -import java.util.Optional; -import java.util.Set; - -import static org.thingsboard.server.dao.service.Validator.validatePageLink; - -@Service -@RequiredArgsConstructor -class AiModelSettingsServiceImpl extends CachedVersionedEntityService implements AiModelSettingsService { - - private final DataValidator aiModelSettingsValidator; - - private final JpaExecutorService jpaExecutor; - private final AiModelSettingsDao aiModelSettingsDao; - - @Override - @TransactionalEventListener - public void handleEvictEvent(AiModelSettingsCacheEvictEvent event) { - var cacheKey = event.cacheKey(); - if (event instanceof AiModelSettingsCacheEvictEvent.Saved savedEvent) { - cache.put(cacheKey, savedEvent.savedSettings()); - } else if (event instanceof AiModelSettingsCacheEvictEvent.Deleted) { - cache.evict(cacheKey); - } else { - throw new UnsupportedOperationException("Unsupported event type: " + event.getClass().getSimpleName()); - } - } - - @Override - @Transactional - public AiModelSettings save(AiModelSettings settings) { - aiModelSettingsValidator.validate(settings, AiModelSettings::getTenantId); - - AiModelSettings savedSettings; - try { - savedSettings = aiModelSettingsDao.saveAndFlush(settings.getTenantId(), settings); - } catch (Exception e) { - checkConstraintViolation(e, - "ai_model_settings_name_unq_key", "AI model settings with such name already exist!", - "ai_model_settings_external_id_unq_key", "AI model settings with such external ID already exist!"); - throw e; - } - - var cacheKey = AiModelSettingsCacheKey.of(savedSettings.getTenantId(), savedSettings.getId()); - publishEvictEvent(new AiModelSettingsCacheEvictEvent.Saved(cacheKey, savedSettings)); - - return savedSettings; - } - - @Override - public Optional findAiModelSettingsById(TenantId tenantId, AiModelSettingsId settingsId) { - return Optional.ofNullable(aiModelSettingsDao.findById(tenantId, settingsId.getId())); - } - - @Override - public PageData findAiModelSettingsByTenantId(TenantId tenantId, PageLink pageLink) { - validatePageLink(pageLink, AiModelSettingsEntity.ALLOWED_SORT_PROPERTIES); - return aiModelSettingsDao.findAllByTenantId(tenantId, pageLink); - } - - @Override - public Optional findAiModelSettingsByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId) { - var cacheKey = AiModelSettingsCacheKey.of(tenantId, settingsId); - return Optional.ofNullable(cache.get(cacheKey, () -> aiModelSettingsDao.findByTenantIdAndId(tenantId, settingsId).orElse(null))); - } - - @Override - public FluentFuture> findAiModelSettingsByTenantIdAndIdAsync(TenantId tenantId, AiModelSettingsId settingsId) { - return FluentFuture.from(jpaExecutor.submit(() -> findAiModelSettingsByTenantIdAndId(tenantId, settingsId))); - } - - @Override - @Transactional - public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId) { - return deleteByTenantIdAndIdInternal(tenantId, settingsId); - } - - @Override - public Optional> findEntity(TenantId tenantId, EntityId entityId) { - return findAiModelSettingsByTenantIdAndId(tenantId, (AiModelSettingsId) entityId) - .map(settings -> settings); // necessary to cast to HasId - } - - @Override - public long countByTenantId(TenantId tenantId) { - return aiModelSettingsDao.countByTenantId(tenantId); - } - - @Override - @Transactional - public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { - deleteByTenantIdAndIdInternal(tenantId, new AiModelSettingsId(id.getId())); - } - - private boolean deleteByTenantIdAndIdInternal(TenantId tenantId, AiModelSettingsId settingsId) { - boolean deleted = aiModelSettingsDao.deleteByTenantIdAndId(tenantId, settingsId); - if (deleted) { - publishEvictEvent(new AiModelSettingsCacheEvictEvent.Deleted(AiModelSettingsCacheKey.of(tenantId, settingsId))); - } - return deleted; - } - - @Override - @Transactional - public void deleteByTenantId(TenantId tenantId) { - Set deleted = aiModelSettingsDao.deleteByTenantId(tenantId); - deleted.forEach(id -> publishEvictEvent(new AiModelSettingsCacheEvictEvent.Deleted(AiModelSettingsCacheKey.of(tenantId, id)))); - } - - @Override - public EntityType getEntityType() { - return EntityType.AI_MODEL_SETTINGS; - } - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java b/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java index 28c96c4675..2f23d20f00 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java @@ -47,7 +47,7 @@ public class CleanUpService { private final Set skippedEntities = EnumSet.of( EntityType.ALARM, EntityType.QUEUE, EntityType.TB_RESOURCE, EntityType.OTA_PACKAGE, EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_TEMPLATE, - EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, EntityType.AI_MODEL_SETTINGS + EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, EntityType.AI_MODEL ); @TransactionalEventListener(fallbackExecution = true) // after transaction commit diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 6245d75214..ca59d3bce0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -752,12 +752,12 @@ public class ModelConstants { public static final String JOB_RESULT_PROPERTY = "result"; /** - * AI model settings constants. + * AI model constants. */ - public static final String AI_MODEL_SETTINGS_TABLE_NAME = "ai_model_settings"; - public static final String AI_MODEL_SETTINGS_TENANT_ID_COLUMN_NAME = TENANT_ID_COLUMN; - public static final String AI_MODEL_SETTINGS_NAME_COLUMN_NAME = NAME_PROPERTY; - public static final String AI_MODEL_SETTINGS_CONFIGURATION_COLUMN_NAME = "configuration"; + public static final String AI_MODEL_TABLE_NAME = "ai_model"; + public static final String AI_MODEL_TENANT_ID_COLUMN_NAME = TENANT_ID_COLUMN; + public static final String AI_MODEL_NAME_COLUMN_NAME = NAME_PROPERTY; + public static final String AI_MODEL_CONFIGURATION_COLUMN_NAME = "configuration"; protected static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, JSON_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN}; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelSettingsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelEntity.java similarity index 62% rename from dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelSettingsEntity.java rename to dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelEntity.java index 25aa7a0018..d4f3d36db6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelSettingsEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelEntity.java @@ -24,9 +24,9 @@ import lombok.Setter; import lombok.ToString; import org.hibernate.annotations.Type; import org.hibernate.proxy.HibernateProxy; -import org.thingsboard.server.common.data.ai.AiModelSettings; -import org.thingsboard.server.common.data.ai.model.AiModel; -import org.thingsboard.server.common.data.id.AiModelSettingsId; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.model.BaseVersionedEntity; import org.thingsboard.server.dao.model.ModelConstants; @@ -43,52 +43,52 @@ import java.util.UUID; @Setter @ToString @Entity -@Table(name = ModelConstants.AI_MODEL_SETTINGS_TABLE_NAME) -public class AiModelSettingsEntity extends BaseVersionedEntity { +@Table(name = ModelConstants.AI_MODEL_TABLE_NAME) +public class AiModelEntity extends BaseVersionedEntity { public static final Map COLUMN_MAP = Map.of( "createdTime", "created_time", "provider", "(configuration ->> 'provider')", - "modelId", "(configuration -> 'modelConfig' ->> 'modelId')" + "modelId", "(configuration ->> 'modelId')" ); public static final Set ALLOWED_SORT_PROPERTIES = Collections.unmodifiableSet( new LinkedHashSet<>(List.of("createdTime", "name", "provider", "modelId")) ); - @Column(name = ModelConstants.AI_MODEL_SETTINGS_TENANT_ID_COLUMN_NAME, nullable = false, columnDefinition = "UUID") + @Column(name = ModelConstants.AI_MODEL_TENANT_ID_COLUMN_NAME, nullable = false, columnDefinition = "UUID") private UUID tenantId; - @Column(name = ModelConstants.AI_MODEL_SETTINGS_NAME_COLUMN_NAME, nullable = false) + @Column(name = ModelConstants.AI_MODEL_NAME_COLUMN_NAME, nullable = false) private String name; @Type(JsonBinaryType.class) - @Column(name = ModelConstants.AI_MODEL_SETTINGS_CONFIGURATION_COLUMN_NAME, nullable = false, columnDefinition = "JSONB") - private AiModel configuration; + @Column(name = ModelConstants.AI_MODEL_CONFIGURATION_COLUMN_NAME, nullable = false, columnDefinition = "JSONB") + private AiModelConfig configuration; @Column(name = ModelConstants.EXTERNAL_ID_PROPERTY, columnDefinition = "UUID") private UUID externalId; - public AiModelSettingsEntity() {} + public AiModelEntity() {} - public AiModelSettingsEntity(AiModelSettings aiModelSettings) { - super(aiModelSettings); - tenantId = getTenantUuid(aiModelSettings.getTenantId()); - name = aiModelSettings.getName(); - configuration = aiModelSettings.getConfiguration(); - externalId = getUuid(aiModelSettings.getExternalId()); + public AiModelEntity(AiModel aiModel) { + super(aiModel); + tenantId = getTenantUuid(aiModel.getTenantId()); + name = aiModel.getName(); + configuration = aiModel.getConfiguration(); + externalId = getUuid(aiModel.getExternalId()); } @Override - public AiModelSettings toData() { - var settings = new AiModelSettings(new AiModelSettingsId(id)); - settings.setCreatedTime(createdTime); - settings.setVersion(version); - settings.setTenantId(TenantId.fromUUID(tenantId)); - settings.setName(name); - settings.setConfiguration(configuration); - settings.setExternalId(getEntityId(externalId, AiModelSettingsId::new)); - return settings; + public AiModel toData() { + var model = new AiModel(new AiModelId(id)); + model.setCreatedTime(createdTime); + model.setVersion(version); + model.setTenantId(TenantId.fromUUID(tenantId)); + model.setName(name); + model.setConfiguration(configuration); + model.setExternalId(getEntityId(externalId, AiModelId::new)); + return model; } @Override @@ -98,7 +98,7 @@ public class AiModelSettingsEntity extends BaseVersionedEntity Class oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass(); Class thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); if (thisEffectiveClass != oEffectiveClass) return false; - AiModelSettingsEntity that = (AiModelSettingsEntity) o; + AiModelEntity that = (AiModelEntity) o; return getId() != null && Objects.equals(getId(), that.getId()); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelSettingsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java similarity index 51% rename from dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelSettingsDataValidator.java rename to dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java index fdba1f4e0c..fdccf2955f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelSettingsDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java @@ -17,9 +17,9 @@ package org.thingsboard.server.dao.service.validator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.ai.AiModelSettingsDao; +import org.thingsboard.server.dao.ai.AiModelDao; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.tenant.TenantService; @@ -28,41 +28,41 @@ import java.util.Optional; @Component @RequiredArgsConstructor -class AiModelSettingsDataValidator extends DataValidator { +class AiModelDataValidator extends DataValidator { private final TenantService tenantService; - private final AiModelSettingsDao aiModelSettingsDao; + private final AiModelDao aiModelDao; @Override - protected AiModelSettings validateUpdate(TenantId tenantId, AiModelSettings settings) { - Optional existing = aiModelSettingsDao.findByTenantIdAndId(tenantId, settings.getId()); + protected AiModel validateUpdate(TenantId tenantId, AiModel model) { + Optional existing = aiModelDao.findByTenantIdAndId(tenantId, model.getId()); if (existing.isEmpty()) { - throw new DataValidationException("Cannot update non-existent AI model settings!"); + throw new DataValidationException("Cannot update non-existent AI model!"); } return existing.get(); } @Override - protected void validateDataImpl(TenantId tenantId, AiModelSettings settings) { + protected void validateDataImpl(TenantId tenantId, AiModel model) { // ID validation - if (settings.getId() != null) { - if (settings.getUuidId() == null) { - throw new DataValidationException("AI model settings UUID should be specified!"); + if (model.getId() != null) { + if (model.getUuidId() == null) { + throw new DataValidationException("AI model UUID should be specified!"); } - if (settings.getId().isNullUid()) { - throw new DataValidationException("AI model settings UUID must not be the reserved null value!"); + if (model.getId().isNullUid()) { + throw new DataValidationException("AI model UUID must not be the reserved null value!"); } } // tenant ID validation - if (settings.getTenantId() == null || settings.getTenantId().getId() == null) { - throw new DataValidationException("AI model settings should be assigned to tenant!"); + if (model.getTenantId() == null || model.getTenantId().getId() == null) { + throw new DataValidationException("AI model should be assigned to tenant!"); } - if (settings.getTenantId().isSysTenantId()) { - throw new DataValidationException("AI model settings cannot be assigned to the system tenant!"); + if (model.getTenantId().isSysTenantId()) { + throw new DataValidationException("AI model cannot be assigned to the system tenant!"); } if (!tenantService.tenantExists(tenantId)) { - throw new DataValidationException("AI model settings reference a non-existent tenant!"); + throw new DataValidationException("AI model reference a non-existent tenant!"); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java similarity index 55% rename from dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java rename to dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java index 6be7d97f33..0a746f15ab 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java @@ -23,58 +23,58 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.dao.ExportableEntityRepository; -import org.thingsboard.server.dao.model.sql.AiModelSettingsEntity; +import org.thingsboard.server.dao.model.sql.AiModelEntity; import java.util.Optional; import java.util.Set; import java.util.UUID; -interface AiModelSettingsRepository extends JpaRepository, ExportableEntityRepository { +interface AiModelRepository extends JpaRepository, ExportableEntityRepository { - Optional findByTenantIdAndId(UUID tenantId, UUID id); + Optional findByTenantIdAndId(UUID tenantId, UUID id); - Optional findByTenantIdAndName(UUID tenantId, String name); + Optional findByTenantIdAndName(UUID tenantId, String name); @Query( value = """ SELECT * - FROM ai_model_settings ai_model - WHERE ai_model.tenant_id = :tenantId + FROM ai_model model + WHERE model.tenant_id = :tenantId AND (:textSearch IS NULL - OR ai_model.name ILIKE '%' || :textSearch || '%' - OR (ai_model.configuration ->> 'provider') ILIKE '%' || :textSearch || '%' - OR (ai_model.configuration -> 'modelConfig' ->> 'modelId') ILIKE '%' || :textSearch || '%') + OR model.name ILIKE '%' || :textSearch || '%' + OR (model.configuration ->> 'provider') ILIKE '%' || :textSearch || '%' + OR (model.configuration ->> 'modelId') ILIKE '%' || :textSearch || '%') """, countQuery = """ SELECT COUNT(*) - FROM ai_model_settings ai_model - WHERE ai_model.tenant_id = :tenantId + FROM ai_model model + WHERE model.tenant_id = :tenantId AND (:textSearch IS NULL - OR ai_model.name ILIKE '%' || :textSearch || '%' - OR (ai_model.configuration ->> 'provider') ILIKE '%' || :textSearch || '%' - OR (ai_model.configuration -> 'modelConfig' ->> 'modelId') ILIKE '%' || :textSearch || '%') + OR model.name ILIKE '%' || :textSearch || '%' + OR (model.configuration ->> 'provider') ILIKE '%' || :textSearch || '%' + OR (model.configuration ->> 'modelId') ILIKE '%' || :textSearch || '%') """, nativeQuery = true ) - Page findByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable); + Page findByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable); - @Query("SELECT ai_model.id FROM AiModelSettingsEntity ai_model WHERE ai_model.tenantId = :tenantId") + @Query("SELECT ai_model.id FROM AiModelEntity ai_model WHERE ai_model.tenantId = :tenantId") Page findIdsByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable); - @Query("SELECT externalId FROM AiModelSettingsEntity WHERE id = :id") + @Query("SELECT externalId FROM AiModelEntity WHERE id = :id") Optional getExternalIdById(@Param("id") UUID id); long countByTenantId(UUID tenantId); @Transactional @Modifying - @Query("DELETE FROM AiModelSettingsEntity ai_model WHERE ai_model.id IN (:ids)") + @Query("DELETE FROM AiModelEntity ai_model WHERE ai_model.id IN (:ids)") int deleteByIdIn(@Param("ids") Set ids); @Transactional @Modifying @Query(value = """ - DELETE FROM ai_model_settings + DELETE FROM ai_model WHERE tenant_id = :tenantId RETURNING id """, nativeQuery = true @@ -83,7 +83,7 @@ interface AiModelSettingsRepository extends JpaRepository ids); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java similarity index 50% rename from dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelSettingsDao.java rename to dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java index 38e7280313..e0c145fe03 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java @@ -23,15 +23,15 @@ import org.springframework.data.jpa.domain.JpaSort; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.ai.AiModelSettings; -import org.thingsboard.server.common.data.id.AiModelSettingsId; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; 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.page.SortOrder; import org.thingsboard.server.dao.DaoUtil; -import org.thingsboard.server.dao.ai.AiModelSettingsDao; -import org.thingsboard.server.dao.model.sql.AiModelSettingsEntity; +import org.thingsboard.server.dao.ai.AiModelDao; +import org.thingsboard.server.dao.model.sql.AiModelEntity; import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.util.SqlDao; @@ -44,40 +44,40 @@ import static java.util.stream.Collectors.toSet; @SqlDao @Component @RequiredArgsConstructor -class JpaAiModelSettingsDao extends JpaAbstractDao implements AiModelSettingsDao { +class JpaAiModelDao extends JpaAbstractDao implements AiModelDao { - private final AiModelSettingsRepository aiModelSettingsRepository; + private final AiModelRepository aiModelRepository; @Override - public Optional findByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId) { - return aiModelSettingsRepository.findByTenantIdAndId(tenantId.getId(), settingsId.getId()).map(DaoUtil::getData); + public Optional findByTenantIdAndId(TenantId tenantId, AiModelId modelId) { + return aiModelRepository.findByTenantIdAndId(tenantId.getId(), modelId.getId()).map(DaoUtil::getData); } @Override - public AiModelSettings findByTenantIdAndName(UUID tenantId, String name) { - return DaoUtil.getData(aiModelSettingsRepository.findByTenantIdAndName(tenantId, name)); + public AiModel findByTenantIdAndName(UUID tenantId, String name) { + return DaoUtil.getData(aiModelRepository.findByTenantIdAndName(tenantId, name)); } @Override - public AiModelSettings findByTenantIdAndExternalId(UUID tenantId, UUID externalId) { - return DaoUtil.getData(aiModelSettingsRepository.findByTenantIdAndExternalId(tenantId, externalId)); + public AiModel findByTenantIdAndExternalId(UUID tenantId, UUID externalId) { + return DaoUtil.getData(aiModelRepository.findByTenantIdAndExternalId(tenantId, externalId)); } @Override - public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { return findByTenantId(tenantId.getId(), pageLink); } @Override - public PageData findByTenantId(UUID tenantId, PageLink pageLink) { - return DaoUtil.toPageData(aiModelSettingsRepository.findByTenantId( + public PageData findByTenantId(UUID tenantId, PageLink pageLink) { + return DaoUtil.toPageData(aiModelRepository.findByTenantId( tenantId, StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), toPageRequest(pageLink)) ); } @Override - public PageData findIdsByTenantId(UUID tenantId, PageLink pageLink) { - return DaoUtil.pageToPageData(aiModelSettingsRepository.findIdsByTenantId(tenantId, toPageRequest(pageLink)).map(AiModelSettingsId::new)); + public PageData findIdsByTenantId(UUID tenantId, PageLink pageLink) { + return DaoUtil.pageToPageData(aiModelRepository.findIdsByTenantId(tenantId, toPageRequest(pageLink)).map(AiModelId::new)); } private static PageRequest toPageRequest(PageLink pageLink) { @@ -88,52 +88,52 @@ class JpaAiModelSettingsDao extends JpaAbstractDao 0; + public boolean deleteById(TenantId tenantId, AiModelId modelId) { + return aiModelRepository.deleteByIdIn(Set.of(modelId.getId())) > 0; } @Override - public Set deleteByTenantId(TenantId tenantId) { - return aiModelSettingsRepository.deleteByTenantId(tenantId.getId()).stream() - .map(AiModelSettingsId::new) + public Set deleteByTenantId(TenantId tenantId) { + return aiModelRepository.deleteByTenantId(tenantId.getId()).stream() + .map(AiModelId::new) .collect(toSet()); } @Override - public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId) { - return aiModelSettingsRepository.deleteByTenantIdAndIdIn(tenantId.getId(), Set.of(settingsId.getId())) > 0; + public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId) { + return aiModelRepository.deleteByTenantIdAndIdIn(tenantId.getId(), Set.of(modelId.getId())) > 0; } @Override public EntityType getEntityType() { - return EntityType.AI_MODEL_SETTINGS; + return EntityType.AI_MODEL; } @Override - protected Class getEntityClass() { - return AiModelSettingsEntity.class; + protected Class getEntityClass() { + return AiModelEntity.class; } @Override - protected JpaRepository getRepository() { - return aiModelSettingsRepository; + protected JpaRepository getRepository() { + return aiModelRepository; } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index 9b37e9c2dd..0eb4a60e6d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -183,7 +183,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService> FluentFuture sendChatRequestAsync(AiChatModel chatModel, ChatRequest chatRequest); + > FluentFuture sendChatRequestAsync(AiChatModelConfig chatModelConfig, ChatRequest chatRequest); } 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 1eeb644bba..d2687a1b10 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 @@ -44,7 +44,7 @@ import org.thingsboard.server.common.data.rule.RuleNodeState; import org.thingsboard.server.common.data.script.ScriptLanguage; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; -import org.thingsboard.server.dao.ai.AiModelSettingsService; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -423,9 +423,9 @@ public interface TbContext { AuditLogService getAuditLogService(); - RuleEngineAiModelService getAiModelService(); + RuleEngineAiChatModelService getAiChatModelService(); - AiModelSettingsService getAiModelSettingsService(); + AiModelService getAiModelService(); // Configuration parameters for the MQTT client that is used in the MQTT node and Azure IoT hub node 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 62d92ef2ce..93c2cafaae 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 @@ -33,11 +33,10 @@ 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.ai.AiModelSettings; +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.AiChatModel; import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; -import org.thingsboard.server.common.data.id.AiModelSettingsId; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.msg.TbMsg; @@ -65,7 +64,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { private String userPrompt; private ResponseFormat responseFormat; private int timeoutSeconds; - private AiModelSettingsId modelSettingsId; + private AiModelId modelId; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { @@ -79,7 +78,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { throw new TbNodeException(e, true); } - // LC4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT + // LangChain4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT if (config.getResponseFormat().type() == TbResponseFormat.TbResponseFormatType.JSON) { responseFormat = config.getResponseFormat().toLangChainResponseFormat(); } @@ -87,15 +86,15 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { systemPrompt = config.getSystemPrompt(); userPrompt = config.getUserPrompt(); timeoutSeconds = config.getTimeoutSeconds(); - modelSettingsId = config.getAiModelSettingsId(); + modelId = config.getAiModelId(); - Optional modelSettings = ctx.getAiModelSettingsService().findAiModelSettingsByTenantIdAndId(ctx.getTenantId(), modelSettingsId); - if (modelSettings.isEmpty()) { - throw new TbNodeException("[" + ctx.getTenantId() + "] AI model settings with ID: [" + modelSettingsId + "] were not found", true); + Optional model = ctx.getAiModelService().findAiModelByTenantIdAndId(ctx.getTenantId(), modelId); + if (model.isEmpty()) { + throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] was not found", true); } - AiModelType modelType = modelSettings.get().getConfiguration().modelType(); + AiModelType modelType = model.get().getConfiguration().modelType(); if (modelType != AiModelType.CHAT) { - throw new TbNodeException("[" + ctx.getTenantId() + "] AI model settings with ID: [" + modelSettingsId + "] must be of type CHAT, but was " + modelType, true); + throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] must be of type CHAT, but was " + modelType, true); } } @@ -134,24 +133,24 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { } private > FluentFuture sendChatRequestAsync(TbContext ctx, ChatRequest chatRequest) { - return ctx.getAiModelSettingsService().findAiModelSettingsByTenantIdAndIdAsync(ctx.getTenantId(), modelSettingsId).transformAsync(settingsOpt -> { - if (settingsOpt.isEmpty()) { - throw new NoSuchElementException("[" + ctx.getTenantId() + "] AI model settings with ID: [" + modelSettingsId + "] were not found"); + return ctx.getAiModelService().findAiModelByTenantIdAndIdAsync(ctx.getTenantId(), modelId).transformAsync(modelOpt -> { + if (modelOpt.isEmpty()) { + throw new NoSuchElementException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] was not found"); } - AiModelSettings settings = settingsOpt.get(); - AiModelType modelType = settings.getConfiguration().modelType(); + AiModel model = modelOpt.get(); + AiModelType modelType = model.getConfiguration().modelType(); if (modelType != AiModelType.CHAT) { - throw new IllegalStateException("[" + ctx.getTenantId() + "] AI model settings with ID: [" + modelSettingsId + "] must be of type CHAT, but was " + modelType); + throw new IllegalStateException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] must be of type CHAT, but was " + modelType); } @SuppressWarnings("unchecked") - AiChatModel chatModel = (AiChatModel) settingsOpt.get().getConfiguration(); + AiChatModelConfig chatModelConfig = (AiChatModelConfig) model.getConfiguration(); - chatModel = chatModel.withModelConfig(chatModel.modelConfig() + chatModelConfig = chatModelConfig .withTimeoutSeconds(timeoutSeconds) - .withMaxRetries(0)); // disable retries to respect timeout set in rule node config + .withMaxRetries(0); // disable retries to respect timeout set in rule node config - return ctx.getAiModelService().sendChatRequestAsync(chatModel, chatRequest); + return ctx.getAiChatModelService().sendChatRequestAsync(chatModelConfig, chatRequest); }, ctx.getDbCallbackExecutor()); } @@ -174,7 +173,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { systemPrompt = null; userPrompt = null; responseFormat = null; - modelSettingsId = 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 ebfcf943f7..eba3d5fef9 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 @@ -23,7 +23,7 @@ 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.AiModelSettingsId; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.validation.Length; import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; @@ -32,7 +32,7 @@ import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseForm public class TbAiNodeConfiguration implements NodeConfiguration { @NotNull - private AiModelSettingsId aiModelSettingsId; + private AiModelId aiModelId; @Pattern(regexp = ".*\\S.*", message = "must not be blank") @Length(min = 1, max = 10000) diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java index 130332ad64..a8ae187c84 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java @@ -19,7 +19,7 @@ import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; -import org.thingsboard.server.common.data.id.AiModelSettingsId; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.ApiUsageStateId; import org.thingsboard.server.common.data.id.AssetId; @@ -180,8 +180,8 @@ public class TenantIdLoader { case JOB: tenantEntity = ctx.getJobService().findJobById(ctxTenantId, new JobId(id)); break; - case AI_MODEL_SETTINGS: - tenantEntity = ctx.getAiModelSettingsService().findAiModelSettingsById(ctxTenantId, new AiModelSettingsId(id)).orElse(null); + case AI_MODEL: + tenantEntity = ctx.getAiModelService().findAiModelById(ctxTenantId, new AiModelId(id)).orElse(null); break; default: throw new RuntimeException("Unexpected entity type: " + entityId.getEntityType()); diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java index 3713dbea3e..2d7b8a0d30 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java @@ -40,7 +40,7 @@ import org.thingsboard.server.common.data.OtaPackage; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.ai.AiModelSettings; +import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; @@ -70,7 +70,7 @@ import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetsBundle; -import org.thingsboard.server.dao.ai.AiModelSettingsService; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.customer.CustomerService; @@ -168,7 +168,7 @@ public class TenantIdLoaderTest { @Mock private JobService jobService; @Mock - private AiModelSettingsService aiModelSettingsService; + private AiModelService aiModelService; private TenantId tenantId; private TenantProfileId tenantProfileId; @@ -434,11 +434,11 @@ public class TenantIdLoaderTest { when(ctx.getJobService()).thenReturn(jobService); doReturn(job).when(jobService).findJobById(eq(tenantId), any()); break; - case AI_MODEL_SETTINGS: - AiModelSettings aiModelSettings = new AiModelSettings(); - aiModelSettings.setTenantId(tenantId); - when(ctx.getAiModelSettingsService()).thenReturn(aiModelSettingsService); - doReturn(Optional.of(aiModelSettings)).when(aiModelSettingsService).findAiModelSettingsById(eq(tenantId), any()); + case AI_MODEL: + AiModel aiModel = new AiModel(); + aiModel.setTenantId(tenantId); + when(ctx.getAiModelService()).thenReturn(aiModelService); + doReturn(Optional.of(aiModel)).when(aiModelService).findAiModelById(eq(tenantId), any()); break; default: throw new RuntimeException("Unexpected originator EntityType " + entityType); From ad7cfb94acdf11950131db08c6a5efe517f8ff38 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Wed, 9 Jul 2025 18:07:02 +0300 Subject: [PATCH 113/249] UI: Change entity type name --- ui-ngx/src/app/core/http/ai-model.service.ts | 8 +++---- ui-ngx/src/app/core/http/entity.service.ts | 4 ++-- .../external/ai-config.component.html | 2 +- .../ai-model/ai-model-table-config.resolve.ts | 8 +++---- .../ai-model/ai-model-dialog.component.html | 12 +++++----- .../ai-model/ai-model-dialog.component.ts | 18 +++++++-------- .../check-connectivity-dialog.component.ts | 10 ++++----- .../entity/entity-autocomplete.component.ts | 2 +- .../src/app/shared/models/ai-model.models.ts | 22 +++++++++---------- .../app/shared/models/entity-type.models.ts | 6 ++--- .../src/app/shared/models/id/ai-model-id.ts | 2 +- ui-ngx/src/app/shared/models/vc.models.ts | 4 ++-- 12 files changed, 46 insertions(+), 52 deletions(-) diff --git a/ui-ngx/src/app/core/http/ai-model.service.ts b/ui-ngx/src/app/core/http/ai-model.service.ts index b2f63ba583..64e003c30e 100644 --- a/ui-ngx/src/app/core/http/ai-model.service.ts +++ b/ui-ngx/src/app/core/http/ai-model.service.ts @@ -32,19 +32,19 @@ export class AiModelService { ) {} public saveAiModel(aiModel: AiModel, config?: RequestConfig): Observable { - return this.http.post('/api/ai/model/settings', aiModel, defaultHttpOptionsFromConfig(config)); + return this.http.post('/api/ai/model', aiModel, defaultHttpOptionsFromConfig(config)); } public getAiModels(pageLink: PageLink, config?: RequestConfig): Observable> { - return this.http.get>(`/api/ai/model/settings${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + return this.http.get>(`/api/ai/model${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); } public getAiModelById(aiModelId: string, config?: RequestConfig): Observable { - return this.http.get(`/api/ai/model/settings/${aiModelId}`, defaultHttpOptionsFromConfig(config)); + return this.http.get(`/api/ai/model/${aiModelId}`, defaultHttpOptionsFromConfig(config)); } public deleteAiModel(aiModelId: string, config?: RequestConfig) { - return this.http.delete(`/api/ai/model/settings/${aiModelId}`, defaultHttpOptionsFromConfig(config)); + return this.http.delete(`/api/ai/model/${aiModelId}`, defaultHttpOptionsFromConfig(config)); } public checkConnectivity(aiModelWithUserMsg: AiModelWithUserMsg, config?: RequestConfig): Observable { diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 17a755ad30..ac6e4b7a7a 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -185,7 +185,7 @@ export class EntityService { case EntityType.MOBILE_APP_BUNDLE: observable = this.mobileAppService.getMobileAppBundleInfoById(entityId, config); break; - case EntityType.AI_MODEL_SETTINGS: + case EntityType.AI_MODEL: observable = this.aiModelService.getAiModelById(entityId, config); break; } @@ -490,7 +490,7 @@ export class EntityService { pageLink.sortOrder.property = 'title'; entitiesObservable = this.mobileAppService.getTenantMobileAppBundleInfos(pageLink, config); break; - case EntityType.AI_MODEL_SETTINGS: + case EntityType.AI_MODEL: pageLink.sortOrder.property = 'name'; entitiesObservable = this.aiModelService.getAiModels(pageLink, config); break; 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 f085913d5c..8846858a8d 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 @@ -28,7 +28,7 @@ appearance="outline" labelText="ai-models.ai-model" (entityChanged)="onEntityChange($event)" - [entityType]="entityType.AI_MODEL_SETTINGS" + [entityType]="entityType.AI_MODEL" (createNew)="createModelAi('modelSettingsId')" formControlName="modelSettingsId"> diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts index 9f135d002c..6e9bde0b60 100644 --- a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts @@ -46,11 +46,11 @@ export class AiModelsTableConfigResolver { private dialog: MatDialog ) { this.config.selectionEnabled = true; - this.config.entityType = EntityType.AI_MODEL_SETTINGS; + this.config.entityType = EntityType.AI_MODEL; this.config.addAsTextButton = true; this.config.detailsPanelEnabled = false; - this.config.entityTranslations = entityTypeTranslations.get(EntityType.AI_MODEL_SETTINGS); - this.config.entityResources = entityTypeResources.get(EntityType.AI_MODEL_SETTINGS); + this.config.entityTranslations = entityTypeTranslations.get(EntityType.AI_MODEL); + this.config.entityResources = entityTypeResources.get(EntityType.AI_MODEL); this.config.headerComponent = AiModelTableHeaderComponent; this.config.addDialogStyle = {width: '850px', maxHeight: '100vh'}; @@ -65,7 +65,7 @@ export class AiModelsTableConfigResolver { entity => this.translate.instant(AiProviderTranslations.get(entity.configuration.provider)) ), new EntityTableColumn('aiModel', 'ai-models.ai-model', '33%', - entity => entity.configuration.modelConfig.modelId, () => ({}), false + entity => entity.configuration.modelId, () => ({}), false ) ) diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html index 1a8fae3b45..97eeb79f02 100644 --- a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html +++ b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html @@ -117,7 +117,7 @@
-
+
ai-models.configuration
@@ -139,7 +139,7 @@ matTooltipPosition="above" matTooltipClass="tb-error-tooltip" [matTooltip]="'ai-models.temperature-min' | translate" - *ngIf="aiModelForms.get('configuration').get('modelConfig').get('temperature').hasError('min')" + *ngIf="aiModelForms.get('configuration').get('temperature').hasError('min')" class="tb-error"> warning @@ -156,8 +156,8 @@ matTooltipPosition="above" matTooltipClass="tb-error-tooltip" [matTooltip]="'ai-models.top-p-min-max' | translate" - *ngIf="aiModelForms.get('configuration').get('modelConfig').get('topP').hasError('min') || - aiModelForms.get('configuration').get('modelConfig').get('topP').hasError('max')" + *ngIf="aiModelForms.get('configuration').get('topP').hasError('min') || + aiModelForms.get('configuration').get('topP').hasError('max')" class="tb-error"> warning @@ -176,7 +176,7 @@ matTooltipPosition="above" matTooltipClass="tb-error-tooltip" [matTooltip]="'ai-models.top-k-min' | translate" - *ngIf="aiModelForms.get('configuration').get('modelConfig').get('topK').hasError('min')" + *ngIf="aiModelForms.get('configuration').get('topK').hasError('min')" class="tb-error"> warning @@ -216,7 +216,7 @@ matTooltipPosition="above" matTooltipClass="tb-error-tooltip" [matTooltip]="'ai-models.max-output-token-min' | translate" - *ngIf="aiModelForms.get('configuration').get('modelConfig').get('maxOutputTokens').hasError('min')" + *ngIf="aiModelForms.get('configuration').get('maxOutputTokens').hasError('min')" class="tb-error"> warning diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts index 5dd63d3f5b..04087dcb79 100644 --- a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts @@ -91,15 +91,13 @@ export class AIModelDialogComponent extends DialogComponent { this.provider = provider; - this.aiModelForms.get('configuration.modelConfig').reset({}); + // this.aiModelForms.get('configuration').reset({}); this.aiModelForms.get('configuration.providerConfig').reset({}); this.updateValidation(provider); }) diff --git a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts b/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts index 70896cdc6d..e7f0d8c174 100644 --- a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts @@ -54,15 +54,13 @@ export class CheckConnectivityDialogComponent extends DialogComponent, 'label'>, HasTenantId serviceAccountKey?: string; serviceAccountKeyFileName?: string }; - modelConfig: { - modelId: string; - temperature?: number | null; - topP?: number; - topK?: number; - frequencyPenalty?: number; - presencePenalty?: number; - maxOutputTokens?: number; - } + modelId: string; + temperature?: number | null; + topP?: number; + topK?: number; + frequencyPenalty?: number; + presencePenalty?: number; + maxOutputTokens?: number; } } @@ -131,7 +129,7 @@ export interface AiModelWithUserMsg { userMessage: { contents: Array<{contentType: string; text: string}>; } - chatModel: { + chatModelConfig: { modelType: string; provider: AiProvider providerConfig: { @@ -144,11 +142,11 @@ export interface AiModelWithUserMsg { serviceAccountKey?: string; serviceAccountKeyFileName?: string }; - modelConfig: { + // chatModelConfig: { modelId: string; maxRetries: number; timeoutSeconds: number; - } + // } } } diff --git a/ui-ngx/src/app/shared/models/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts index 3b9e5f5427..6e7ba24578 100644 --- a/ui-ngx/src/app/shared/models/entity-type.models.ts +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -51,7 +51,7 @@ export enum EntityType { MOBILE_APP_BUNDLE = 'MOBILE_APP_BUNDLE', MOBILE_APP = 'MOBILE_APP', CALCULATED_FIELD = 'CALCULATED_FIELD', - AI_MODEL_SETTINGS = 'AI_MODEL_SETTINGS', + AI_MODEL = 'AI_MODEL', } export enum AliasEntityType { @@ -496,7 +496,7 @@ export const entityTypeTranslations = new Map = [ EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, - EntityType.AI_MODEL_SETTINGS, + EntityType.AI_MODEL, ]; export const entityTypesWithoutRelatedData = new Set([ @@ -46,7 +46,7 @@ export const entityTypesWithoutRelatedData = new Set Date: Wed, 9 Jul 2025 20:05:06 +0300 Subject: [PATCH 114/249] UI: Refactoring --- ui-ngx/src/app/modules/common/modules-map.ts | 2 - .../check-connectivity-dialog.component.html | 0 .../check-connectivity-dialog.component.scss | 0 .../check-connectivity-dialog.component.ts | 4 +- .../home/components/home-components.module.ts | 3 + .../common/time-unit-input.component.html | 98 +++------ .../common/time-unit-input.component.ts | 29 ++- .../external/ai-config.component.html | 28 +-- .../rule-node/external/ai-config.component.ts | 36 ++-- .../ai-model/ai-model-table-config.resolve.ts | 11 +- .../ai-model-table-header.component.scss | 18 -- .../ai-model-table-header.component.ts | 2 +- .../ai-model/ai-model-dialog.component.html | 166 ++++++++------- .../ai-model/ai-model-dialog.component.ts | 54 ++--- .../models-list-autocomplete.component.ts | 4 +- .../src/app/shared/models/ai-model.models.ts | 201 ++++++++++++------ ui-ngx/src/app/shared/shared.module.ts | 3 - 17 files changed, 335 insertions(+), 324 deletions(-) rename ui-ngx/src/app/{shared => modules/home}/components/ai-model/check-connectivity-dialog.component.html (100%) rename ui-ngx/src/app/{shared => modules/home}/components/ai-model/check-connectivity-dialog.component.scss (100%) rename ui-ngx/src/app/{shared => modules/home}/components/ai-model/check-connectivity-dialog.component.ts (95%) delete mode 100644 ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index 997af07462..60a26e4211 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -337,7 +337,6 @@ import * as AggregationTypeSelectComponent from '@shared/components/time/aggrega import * as AggregationOptionsConfigComponent from '@shared/components/time/aggregation/aggregation-options-config-panel.component'; import * as IntervalOptionsConfigPanelComponent from '@shared/components/time/interval-options-config-panel.component'; import * as AIModelDialogComponent from '@shared/components/ai-model/ai-model-dialog.component'; -import * as CheckConnectivityDialogComponent from '@shared/components/ai-model/check-connectivity-dialog.component'; import * as ModelsListAutocompleteComponent from '@shared/components/ai-model/models-list-autocomplete.component'; import { IModulesMap } from '@modules/common/modules-map.models'; @@ -536,7 +535,6 @@ class ModulesMap implements IModulesMap { '@shared/components/image/multiple-gallery-image-input.component': MultipleGalleryImageInputComponent, '@shared/components/popover.service': TbPopoverService, '@shared/components/ai-model/ai-model-dialog.component': AIModelDialogComponent, - '@shared/components/ai-model/check-connectivity-dialog.component': CheckConnectivityDialogComponent, '@shared/components/ai-model/models-list-autocomplete.component': ModelsListAutocompleteComponent, diff --git a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html similarity index 100% rename from ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.html rename to ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html diff --git a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.scss b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.scss similarity index 100% rename from ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.scss rename to ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.scss diff --git a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts similarity index 95% rename from ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts rename to ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts index e7f0d8c174..3b6c96d426 100644 --- a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts @@ -20,7 +20,7 @@ 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 { AiModel, AiModelWithUserMsg } from '@shared/models/ai-model.models'; +import { AiModel, AiModelWithUserMsg, ModelType } from '@shared/models/ai-model.models'; import { AiModelService } from '@core/http/ai-model.service'; export interface AIModelDialogData { @@ -55,7 +55,7 @@ export class CheckConnectivityDialogComponent extends DialogComponent
- @if (outlined) { -
-
{{ labelText }}
-
- - - - warning - - - warning - - - warning - - - - - @for (timeUnit of timeUnits; track timeUnit) { - {{ timeUnitTranslations.get(timeUnit) | translate }} - } - - -
+ + @if (labelText && !inlineField) { + {{ labelText }} + } + +
+
- } @else { - - {{ labelText }} - -
- -
+ @if (inlineField) { + + warning + + } @else { - - {{ requiredText }} + + {{ hasError }} - - {{ minErrorText }} - - - {{ maxErrorText }} - -
- + } + + + @if (!inlineField) { rule-node-config.units - - @for (timeUnit of timeUnits; track timeUnit) { - {{ timeUnitTranslations.get(timeUnit) | translate }} - } - - - } + } + + @for (timeUnit of timeUnits; track timeUnit) { + {{ timeUnitTranslations.get(timeUnit) | translate }} + } + +
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts index 2cd8e7d879..e31d9abf9e 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts @@ -30,7 +30,7 @@ import { isDefinedAndNotNull, isNumeric } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { coerceBoolean, coerceNumber } from '@shared/decorators/coercion'; import { DAY, HOUR, MINUTE, SECOND } from '@shared/models/time/time.models'; -import { SubscriptSizing } from '@angular/material/form-field'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; interface TimeUnitInputModel { time: number; @@ -55,9 +55,6 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, @Input() labelText: string; - @Input() - labelIconHintTooltipText: string; - @Input() @coerceBoolean() required: boolean; @@ -83,12 +80,11 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, subscriptSizing: SubscriptSizing = 'fixed'; @Input() - @coerceBoolean() - filterTimeUnitsByMaxTime = false; + appearance: MatFormFieldAppearance = 'fill'; @Input() @coerceBoolean() - outlined = false; + inlineField: boolean; timeUnits = Object.values(TimeUnit).filter(item => item !== TimeUnit.MILLISECONDS) as TimeUnit[]; @@ -115,12 +111,13 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, } ngOnInit() { - if (this.filterTimeUnitsByMaxTime && this.maxTime) { - if (this.maxTime < 60) { + if (this.maxTime) { + const maxTimeMs = this.maxTime * SECOND; + if (maxTimeMs < MINUTE) { this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.MINUTES && item !== TimeUnit.HOURS && item !== TimeUnit.DAYS); - } else if (this.maxTime < 3600) { + } else if (maxTimeMs < HOUR) { this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.HOURS && item !== TimeUnit.DAYS); - } else if (this.maxTime < 86400) { + } else if (maxTimeMs < DAY) { this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.DAYS); } } @@ -157,6 +154,16 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, }); } + get hasError(): string { + if (this.timeInputForm.get('time').hasError('required') && this.requiredText) { + return this.requiredText; + } else if (this.timeInputForm.get('time').hasError('min') && this.minErrorText) { + return this.minErrorText; + } else if (this.timeInputForm.get('time').hasError('max') && this.maxErrorText) { + return this.maxErrorText; + } + } + registerOnChange(fn: any) { this.propagateChange = fn; } 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 8846858a8d..ef9dbe04a4 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 @@ -96,19 +96,21 @@ rule-node-config.ai.advanced-settings
- - +
+
{{ 'rule-node-config.ai.timeout' | translate }}
+
+ + +
+
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 283abb636c..3b7f0a92d6 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 @@ -20,15 +20,9 @@ import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/m import { EntityType } from '@shared/models/entity-type.models'; import { MatDialog } from '@angular/material/dialog'; import { AIModelDialogComponent, AIModelDialogData } from '@shared/components/ai-model/ai-model-dialog.component'; -import { AiModel, AiProvider } from '@shared/models/ai-model.models'; +import { AiModel, AiRuleNodeResponseFormatTypeOnlyText, ResponseFormat } from '@shared/models/ai-model.models'; import { deepTrim } from '@core/utils'; -enum ResponseFormat { - TEXT = 'TEXT', - JSON = 'JSON', - JSON_SCHEMA = 'JSON_SCHEMA' -} - @Component({ selector: 'tb-external-node-ai-config', templateUrl: './ai-config.component.html', @@ -53,14 +47,14 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { protected onConfigurationSet(configuration: RuleNodeConfiguration) { this.aiConfigForm = this.fb.group({ - modelSettingsId: [configuration ? configuration.modelSettingsId : null, [Validators.required]], - systemPrompt: [configuration ? configuration.systemPrompt : '', [Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], - userPrompt: [configuration ? configuration.userPrompt : '', [Validators.required, Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], + modelSettingsId: [configuration?.modelSettingsId ?? null, [Validators.required]], + systemPrompt: [configuration?.systemPrompt ?? '', [Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], + userPrompt: [configuration?.userPrompt ?? '', [Validators.required, Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], responseFormat: this.fb.group({ - type: [configuration ? configuration.responseFormat.type : ResponseFormat.JSON, []], - schema: [configuration ? configuration.responseFormat.schema : null, []], + type: [configuration?.responseFormat?.type ?? ResponseFormat.JSON, []], + schema: [configuration?.responseFormat?.schema ?? null, [Validators.required]], }), - timeoutSeconds: [configuration ? configuration.timeoutSeconds : 60, []] + timeoutSeconds: [configuration?.timeoutSeconds ?? 60, []] }); } @@ -69,15 +63,11 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { } protected updateValidators(emitEvent: boolean) { - const responseFormatType = this.aiConfigForm.get('responseFormat.type').value; - if (responseFormatType === ResponseFormat.JSON_SCHEMA) { - this.aiConfigForm.get('responseFormat.schema').setValidators([Validators.required]); - this.aiConfigForm.get('responseFormat.schema').enable(); + if (this.aiConfigForm.get('responseFormat.type').value === ResponseFormat.JSON_SCHEMA) { + this.aiConfigForm.get('responseFormat.schema').enable({emitEvent: false}); } else { - this.aiConfigForm.get('responseFormat.schema').setValidators([]); - this.aiConfigForm.get('responseFormat.schema').disable(); + this.aiConfigForm.get('responseFormat.schema').disable({emitEvent: false}); } - this.aiConfigForm.get('responseFormat.schema').updateValueAndValidity({emitEvent}); } protected prepareOutputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration { @@ -86,9 +76,7 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { onEntityChange($event: AiModel) { if ($event) { - if ($event.configuration.provider === AiProvider.AMAZON_BEDROCK || - $event.configuration.provider === AiProvider.ANTHROPIC || - $event.configuration.provider === AiProvider.GITHUB_MODELS) { + if (AiRuleNodeResponseFormatTypeOnlyText.includes($event.configuration.provider)) { this.aiConfigForm.get('responseFormat.type').patchValue(ResponseFormat.TEXT, {emitEvent: false}); this.aiConfigForm.get('responseFormat.type').disable({emitEvent: false}); } @@ -97,7 +85,7 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { } } - createModelAi(formControl: string,) { + createModelAi(formControl: string) { this.dialog.open(AIModelDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts index 6e9bde0b60..3f3ded2abb 100644 --- a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts @@ -79,6 +79,11 @@ export class AiModelsTableConfigResolver { this.config.entitiesFetchFunction = pageLink => this.aiModelService.getAiModels(pageLink); this.config.cellActionDescriptors = this.configureCellActions(); + + this.config.handleRowClick = ($event, model) => { + this.editModel(model); + return true; + }; } resolve(_route: ActivatedRouteSnapshot): EntityTableConfig { @@ -97,11 +102,7 @@ export class AiModelsTableConfigResolver { } private editModel(AIModel: AiModel): void { - this.addModel(AIModel, false).subscribe((res) => { - if (res) { - this.config.updateData(); - } - }); + this.addModel(AIModel, false).subscribe(); } private addModel(AIModel: AiModel, isAdd = false): Observable { diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss deleted file mode 100644 index acb9e2dc87..0000000000 --- a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss +++ /dev/null @@ -1,18 +0,0 @@ -/** - * 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{ - width: 100%; -} diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts index 4fbc906b80..48889dc877 100644 --- a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts @@ -23,7 +23,7 @@ import { AiModel } from '@shared/models/ai-model.models'; @Component({ selector: 'tb-ai-model-table-header', templateUrl: './ai-model-table-header.component.html', - styleUrls: ['./ai-model-table-header.component.scss'] + styleUrls: [] }) export class AiModelTableHeaderComponent extends EntityTableHeaderComponent { diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html index 97eeb79f02..2a5a34a2c9 100644 --- a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html +++ b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html @@ -56,7 +56,7 @@
- @if (provider === aiProvider.GITHUB_MODELS) { + @if (AiModelMap.get(provider).providerFieldsList.includes('personalAccessToken')) { ai-models.personal-access-token @@ -65,24 +65,29 @@ {{ 'ai-models.personal-access-token-required' | translate }} - } @else if (provider === aiProvider.GOOGLE_VERTEX_AI_GEMINI) { - + } + @if (AiModelMap.get(provider).providerFieldsList.includes('projectId')) { + ai-models.project-id {{ 'ai-models.project-id-required' | translate }} - + } + @if (AiModelMap.get(provider).providerFieldsList.includes('location')) { + ai-models.location {{ 'ai-models.location-required' | translate }} + } + @if (AiModelMap.get(provider).providerFieldsList.includes('serviceAccountKey')) { - } @else { - @if (provider === aiProvider.AZURE_OPENAI) { - - ai-models.endpoint - - - {{ 'ai-models.endpoint-required' | translate }} - - - - ai-models.service-version - - - } + } + @if (AiModelMap.get(provider).providerFieldsList.includes('endpoint')) { + + ai-models.endpoint + + + {{ 'ai-models.endpoint-required' | translate }} + + + } + @if (AiModelMap.get(provider).providerFieldsList.includes('serviceVersion')) { + + ai-models.service-version + + + } + @if (AiModelMap.get(provider).providerFieldsList.includes('apiKey')) { ai-models.api-key @@ -128,43 +136,46 @@ [errorText]="(provider === aiProvider.AZURE_OPENAI ? 'ai-models.deployment-name-required': 'ai-models.model-id-required') | translate"> -
-
- {{ 'ai-models.temperature' | translate }} -
- - - - warning - - -
-
-
- {{ 'ai-models.top-p' | translate }} + @if (AiModelMap.get(provider).modelFieldsList.includes('temperature')) { +
+
+ {{ 'ai-models.temperature' | translate }} +
+ + + + warning + +
- - - +
+ {{ 'ai-models.top-p' | translate }} +
+ + + - warning - - -
- - @if (provider === aiProvider.GOOGLE_AI_GEMINI || provider === aiProvider.GOOGLE_VERTEX_AI_GEMINI || provider === aiProvider.ANTHROPIC) { + class="tb-error"> + warning + + +
+ } + @if (AiModelMap.get(provider).modelFieldsList.includes('topK')) {
{{ 'ai-models.top-k' | translate }} @@ -183,45 +194,48 @@
} - - @if (provider !== aiProvider.ANTHROPIC && provider !== aiProvider.AMAZON_BEDROCK) { + @if (AiModelMap.get(provider).modelFieldsList.includes('presencePenalty')) {
{{ 'ai-models.presence-penalty' | translate }}
-
+ } + + @if (AiModelMap.get(provider).modelFieldsList.includes('frequencyPenalty')) {
{{ 'ai-models.frequency-penalty' | translate }}
-
} - -
-
- {{ 'ai-models.max-output-token' | translate }} + @if (AiModelMap.get(provider).modelFieldsList.includes('maxOutputTokens')) { +
+
+ {{ 'ai-models.max-output-token' | translate }} +
+ + + + warning + +
- - - - warning - - -
+ }
diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts index 04087dcb79..d9ae3eaa69 100644 --- a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts @@ -25,9 +25,16 @@ import { StepperOrientation } from '@angular/cdk/stepper'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { EntityType } from '@shared/models/entity-type.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { AiModel, AiProvider, AiProviderTranslations, AiProviderWithApiKey } from '@shared/models/ai-model.models'; +import { + AiModel, + AiModelMap, + AiProvider, + AiProviderTranslations, + ModelType, + ProviderFieldsAllList +} from '@shared/models/ai-model.models'; import { AiModelService } from '@core/http/ai-model.service'; -import { CheckConnectivityDialogComponent } from '@shared/components/ai-model/check-connectivity-dialog.component'; +import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component'; export interface AIModelDialogData { AIModel?: AiModel; @@ -53,7 +60,7 @@ export class AIModelDialogComponent extends DialogComponent { this.provider = provider; - // this.aiModelForms.get('configuration').reset({}); + this.aiModelForms.get('configuration.modelId').reset({}); this.aiModelForms.get('configuration.providerConfig').reset({}); this.updateValidation(provider); }) @@ -114,35 +121,10 @@ export class AIModelDialogComponent extends DialogComponent + this.aiModelForms.get('configuration.providerConfig') + .get(key)[AiModelMap.get(provider).providerFieldsList.includes(key) ? 'enable' : 'disable']() + ) } cancel(): void { diff --git a/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts b/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts index a5cbdb332d..3e3c0cbb66 100644 --- a/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts @@ -99,7 +99,7 @@ export class ModelsListAutocompleteComponent implements ControlValueAccessor, On tap(value => this.updateView(value)), map(value => { const search = value ? value.toLowerCase() : ''; - const options = this.provider ? AiModelMap.get(this.provider) || [] : []; + const options = this.provider ? AiModelMap.get(this.provider).modelList || [] : []; return search ? options.filter(option => option.toLowerCase().includes(search)) : options; }) ); @@ -150,7 +150,7 @@ export class ModelsListAutocompleteComponent implements ControlValueAccessor, On } clear() { - this.selectionFormControl.patchValue(null, {emitEvent: false}); + this.selectionFormControl.patchValue(null, {emitEvent: true}); this.propagateChange(null); this.modelValue = null; setTimeout(() => { diff --git a/ui-ngx/src/app/shared/models/ai-model.models.ts b/ui-ngx/src/app/shared/models/ai-model.models.ts index cd844f57e6..1eb69d867e 100644 --- a/ui-ngx/src/app/shared/models/ai-model.models.ts +++ b/ui-ngx/src/app/shared/models/ai-model.models.ts @@ -19,7 +19,7 @@ import { HasTenantId } from '@shared/models/entity.models'; import { AiModelId } from '@shared/models/id/ai-model-id'; export interface AiModel extends Omit, 'label'>, HasTenantId, ExportableEntity { - modelType: string; + modelType: ModelType; configuration: { provider: AiProvider providerConfig: { @@ -30,10 +30,10 @@ export interface AiModel extends Omit, 'label'>, HasTenantId projectId?: string; location?: string; serviceAccountKey?: string; - serviceAccountKeyFileName?: string + fileName?: string }; modelId: string; - temperature?: number | null; + temperature?: number; topP?: number; topK?: number; frequencyPenalty?: number; @@ -42,6 +42,10 @@ export interface AiModel extends Omit, 'label'>, HasTenantId } } +export enum ModelType { + CHAT = 'CHAT' +} + export enum AiProvider { OPENAI = 'OPENAI', AZURE_OPENAI = 'AZURE_OPENAI', @@ -53,15 +57,6 @@ export enum AiProvider { GITHUB_MODELS = 'GITHUB_MODELS' } -export const AiProviderWithApiKey: AiProvider[] = [ - AiProvider.OPENAI, - AiProvider.AZURE_OPENAI, - AiProvider.GOOGLE_AI_GEMINI, - AiProvider.MISTRAL_AI, - AiProvider.ANTHROPIC, - AiProvider.AMAZON_BEDROCK -] - export const AiProviderTranslations = new Map( [ [AiProvider.OPENAI , 'ai-models.ai-providers.openai'], @@ -75,55 +70,130 @@ export const AiProviderTranslations = new Map( ] ); -export const AiModelMap = new Map( +export const ProviderFieldsAllList = [ + 'apiKey', + 'personalAccessToken', + 'projectId', + 'location', + 'serviceAccountKey', + 'fileName', + 'endpoint', + 'serviceVersion' +]; + +export const ModelFieldsAllList = ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens']; + +export const AiModelMap = new Map([ [ - [AiProvider.OPENAI , [ - 'o4-mini', - 'o3-pro', - 'o3', - 'o3-mini', - 'o1', - 'gpt-4.1', - 'gpt-4.1-mini', - 'gpt-4.1-nano', - 'gpt-4o', - 'gpt-4o-mini' - ]], - [AiProvider.AZURE_OPENAI , []], - [AiProvider.GOOGLE_AI_GEMINI , [ - 'gemini-2.5-pro', - 'gemini-2.5-flash', - 'gemini-2.0-flash', - 'gemini-2.0-flash-lite', - ]], - [AiProvider.GOOGLE_VERTEX_AI_GEMINI , [ - 'gemini-2.5-pro', - 'gemini-2.5-flash', - 'gemini-2.0-flash', - 'gemini-2.0-flash-lite', - ]], - [AiProvider.MISTRAL_AI , [ - 'magistral-medium-latest', - 'magistral-small-latest', - 'mistral-large-latest', - 'mistral-medium-latest', - 'mistral-small-latest', - 'pixtral-large-latest', - 'ministral-8b-latest', - 'ministral-3b-latest', - 'open-mistral-nemo' - ]], - [AiProvider.ANTHROPIC , [ - 'claude-opus-4-0', - 'claude-sonnet-4-0', - 'claude-3-7-sonnet-latest', - 'claude-3-5-sonnet-latest', - 'claude-3-5-haiku-latest' - ]], - [AiProvider.AMAZON_BEDROCK , []], - [AiProvider.GITHUB_MODELS , []] - ] -); + AiProvider.OPENAI, + { + modelList: [ + 'o4-mini', + 'o3-pro', + 'o3', + 'o3-mini', + 'o1', + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'gpt-4o', + 'gpt-4o-mini', + ], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.AZURE_OPENAI, + { + modelList: [], + providerFieldsList: ['apiKey', 'endpoint', 'serviceVersion'], + modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.GOOGLE_AI_GEMINI, + { + modelList: [ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-2.0-flash-lite', + ], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.GOOGLE_VERTEX_AI_GEMINI, + { + modelList: [ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-2.0-flash-lite', + ], + providerFieldsList: ['projectId', 'location', 'serviceAccountKey', 'fileName'], + modelFieldsList: ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.MISTRAL_AI, + { + modelList: [ + 'magistral-medium-latest', + 'magistral-small-latest', + 'mistral-large-latest', + 'mistral-medium-latest', + 'mistral-small-latest', + 'pixtral-large-latest', + 'ministral-8b-latest', + 'ministral-3b-latest', + 'open-mistral-nemo', + ], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.ANTHROPIC, + { + modelList: [ + 'claude-opus-4-0', + 'claude-sonnet-4-0', + 'claude-3-7-sonnet-latest', + 'claude-3-5-sonnet-latest', + 'claude-3-5-haiku-latest', + ], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'topK', 'maxOutputTokens'], + }, + ], + [ + AiProvider.AMAZON_BEDROCK, + { + modelList: [], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'maxOutputTokens'], + }, + ], + [ + AiProvider.GITHUB_MODELS, + { + modelList: [], + providerFieldsList: ['personalAccessToken'], + modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], +]); + +export const AiRuleNodeResponseFormatTypeOnlyText: AiProvider[] = [AiProvider.AMAZON_BEDROCK, AiProvider.ANTHROPIC, AiProvider.GITHUB_MODELS]; + +export enum ResponseFormat { + TEXT = 'TEXT', + JSON = 'JSON', + JSON_SCHEMA = 'JSON_SCHEMA' +} export interface AiModelWithUserMsg { userMessage: { @@ -140,17 +210,14 @@ export interface AiModelWithUserMsg { projectId?: string; location?: string; serviceAccountKey?: string; - serviceAccountKeyFileName?: string + fileName?: string }; - // chatModelConfig: { - modelId: string; - maxRetries: number; - timeoutSeconds: number; - // } + modelId: string; + maxRetries: number; + timeoutSeconds: number; } } - export interface CheckConnectivityResult { status: string; errorDetails: string; diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 8f3de3c26a..0a559361c8 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -230,7 +230,6 @@ import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe'; import { MqttVersionSelectComponent } from '@shared/components/mqtt-version-select.component'; import { AIModelDialogComponent } from '@shared/components/ai-model/ai-model-dialog.component'; import { ModelsListAutocompleteComponent } from '@shared/components/ai-model/models-list-autocomplete.component'; -import { CheckConnectivityDialogComponent } from '@shared/components/ai-model/check-connectivity-dialog.component'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -448,7 +447,6 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) MqttVersionSelectComponent, AIModelDialogComponent, ModelsListAutocompleteComponent, - CheckConnectivityDialogComponent, ], imports: [ CommonModule, @@ -715,7 +713,6 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) MqttVersionSelectComponent, AIModelDialogComponent, ModelsListAutocompleteComponent, - CheckConnectivityDialogComponent, ] }) export class SharedModule { } From e7c971a7bbb82d4d62d7db914e805a45beb03e11 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Wed, 9 Jul 2025 20:40:14 +0300 Subject: [PATCH 115/249] AI rule node: add node description and details --- .../thingsboard/rule/engine/ai/TbAiNode.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) 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 93c2cafaae..6c4815308a 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 @@ -52,9 +52,20 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie @RuleNode( type = ComponentType.EXTERNAL, - name = "AI", - nodeDescription = "Interact with AI", - nodeDetails = "This node makes requests to AI based on a prompt and a input message and returns a response in a form of output message", + name = "AI request", + nodeDescription = "Sends a request to an AI model using system and user prompts. Supports JSON mode.", + nodeDetails = """ + Interact with large language models (LLMs) by sending dynamic requests from your rule chain. + You can select a specific AI model and define its behavior using a system prompt (optional context or role) and a user prompt (the main task). + Both prompts can be populated with data and metadata from the incoming message using patterns. + For example, the $[*] and ${*} patterns allow you to access the all message body and all metadata, respectively. +

+ After sending the request, the node waits for a response within a configured timeout. + You can specify the desired response format as Text, JSON, or provide a specific JSON Schema to structure the output. + The AI-generated content is forwarded as the body of the outgoing message; the originator, message type, and metadata from the incoming message remain unchanged. +

+ Output connections: Success, Failure. + """, configClazz = TbAiNodeConfiguration.class, ruleChainTypes = RuleChainType.CORE ) From ed47569f07f01ab47b40a5214cd8baf69fe59991 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Wed, 9 Jul 2025 20:55:54 +0300 Subject: [PATCH 116/249] AI rule node: improve text search query --- .../thingsboard/server/dao/sql/ai/AiModelRepository.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java index 0a746f15ab..cbe681fb58 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java @@ -42,8 +42,8 @@ interface AiModelRepository extends JpaRepository, Exportab WHERE model.tenant_id = :tenantId AND (:textSearch IS NULL OR model.name ILIKE '%' || :textSearch || '%' - OR (model.configuration ->> 'provider') ILIKE '%' || :textSearch || '%' - OR (model.configuration ->> 'modelId') ILIKE '%' || :textSearch || '%') + OR REPLACE(model.configuration ->> 'provider', '_', ' ') ILIKE '%' || :textSearch || '%' + OR model.configuration ->> 'modelId' ILIKE '%' || :textSearch || '%') """, countQuery = """ SELECT COUNT(*) @@ -51,7 +51,7 @@ interface AiModelRepository extends JpaRepository, Exportab WHERE model.tenant_id = :tenantId AND (:textSearch IS NULL OR model.name ILIKE '%' || :textSearch || '%' - OR (model.configuration ->> 'provider') ILIKE '%' || :textSearch || '%' + OR REPLACE(model.configuration ->> 'provider', '_', ' ') ILIKE '%' || :textSearch || '%' OR (model.configuration ->> 'modelId') ILIKE '%' || :textSearch || '%') """, nativeQuery = true From ff9d3f25d39911e952492042a53ef47ede288a8d Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Wed, 9 Jul 2025 21:01:23 +0300 Subject: [PATCH 117/249] AI rule node: add default sorting --- .../java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java index e0c145fe03..882c86555b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java @@ -89,7 +89,7 @@ class JpaAiModelDao extends JpaAbstractDao implements Ai sort = JpaSort.unsafe( Sort.Direction.fromString(sortOrder.getDirection().name()), AiModelEntity.COLUMN_MAP.getOrDefault(sortOrder.getProperty(), sortOrder.getProperty()) - ); + ).and(Sort.by(Sort.Direction.ASC, "id")); } return PageRequest.of(pageLink.getPage(), pageLink.getPageSize(), sort); } From 14d89aa5eae9799e3f548ac9d1c1e315f4ee484f Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Wed, 9 Jul 2025 21:08:57 +0300 Subject: [PATCH 118/249] AI rule node: add missing cache specs to test application properties --- dao/src/test/resources/application-test.properties | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties index a44303107c..1c2c0c5519 100644 --- a/dao/src/test/resources/application-test.properties +++ b/dao/src/test/resources/application-test.properties @@ -111,6 +111,9 @@ cache.specs.mobileSecretKey.maxSize=10000 cache.specs.trendzSettings.timeToLiveInMinutes=1440 cache.specs.trendzSettings.maxSize=10000 +cache.specs.aiModel.timeToLiveInMinutes=1440 +cache.specs.aiModel.maxSize=10000 + redis.connection.host=localhost redis.connection.port=6379 redis.connection.db=0 @@ -158,4 +161,4 @@ queue.core.poll-interval=5 queue.core.partitions=2 queue.rule-engine.poll-interval=5 -spring.jpa.properties.hibernate.dialect=org.thingsboard.server.dao.ThingsboardPostgreSQLDialect \ No newline at end of file +spring.jpa.properties.hibernate.dialect=org.thingsboard.server.dao.ThingsboardPostgreSQLDialect From 9bcd3d8849663e7a18503541b55328fe449a0127 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 10 Jul 2025 10:37:09 +0300 Subject: [PATCH 119/249] AI rule node: fix rule engine message metadata (de)serialization --- .../java/org/thingsboard/server/common/msg/TbMsgMetaData.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java index ef083ebccb..60fb1df6aa 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.msg; +import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Data; import java.io.Serializable; @@ -64,6 +65,7 @@ public final class TbMsgMetaData implements Serializable { return new TbMsgMetaData(data); } + @JsonIgnore public boolean isEmpty() { return data == null || data.isEmpty(); } From fd7faa5a9e06f1a2a89dabcc7bfac4efd2303cf3 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 10 Jul 2025 11:18:59 +0300 Subject: [PATCH 120/249] AI rule node: fix flaky Azure IoT hub node test --- .../common/util/AzureIotHubUtil.java | 24 +++++++++---------- .../engine/mqtt/azure/TbAzureIotHubNode.java | 12 +++++++++- .../mqtt/azure/TbAzureIotHubNodeTest.java | 10 ++++++-- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java b/common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java index 2c214460f6..001513b008 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java +++ b/common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java @@ -26,11 +26,13 @@ import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Clock; import java.util.Base64; import java.util.Iterator; @Slf4j public final class AzureIotHubUtil { + private static final String BASE_DIR_PATH = System.getProperty("user.dir"); private static final String APP_DIR = "application"; private static final String SRC_DIR = "src"; @@ -52,41 +54,37 @@ public final class AzureIotHubUtil { } } - private static final long SAS_TOKEN_VALID_SECS = 365 * 24 * 60 * 60; - private static final long ONE_SECOND_IN_MILLISECONDS = 1000; + private static final long SAS_TOKEN_VALID_SECS = 365 * 24 * 60 * 60; // one year private static final String SAS_TOKEN_FORMAT = "SharedAccessSignature sr=%s&sig=%s&se=%s"; private static final String USERNAME_FORMAT = "%s/%s/?api-version=2018-06-30"; - private AzureIotHubUtil() { - } + private AzureIotHubUtil() {} public static String buildUsername(String host, String deviceId) { return String.format(USERNAME_FORMAT, host, deviceId); } - public static String buildSasToken(String host, String sasKey) { + public static String buildSasToken(String host, String sasKey, Clock clock) { try { - final String targetUri = URLEncoder.encode(host.toLowerCase(), "UTF-8"); - final long expiryTime = buildExpiresOn(); + final String targetUri = URLEncoder.encode(host.toLowerCase(), StandardCharsets.UTF_8); + final long expiryTime = buildExpiresOn(clock); String toSign = targetUri + "\n" + expiryTime; byte[] keyBytes = Base64.getDecoder().decode(sasKey.getBytes(StandardCharsets.UTF_8)); SecretKeySpec signingKey = new SecretKeySpec(keyBytes, "HmacSHA256"); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(signingKey); byte[] rawHmac = mac.doFinal(toSign.getBytes(StandardCharsets.UTF_8)); - String signature = URLEncoder.encode(Base64.getEncoder().encodeToString(rawHmac), "UTF-8"); + String signature = URLEncoder.encode(Base64.getEncoder().encodeToString(rawHmac), StandardCharsets.UTF_8); return String.format(SAS_TOKEN_FORMAT, targetUri, signature, expiryTime); } catch (Exception e) { - throw new RuntimeException("Failed to build SAS token!!!", e); + throw new RuntimeException("Failed to build SAS token!", e); } } - private static long buildExpiresOn() { - long expiresOnDate = System.currentTimeMillis(); - expiresOnDate += SAS_TOKEN_VALID_SECS * ONE_SECOND_IN_MILLISECONDS; - return expiresOnDate / ONE_SECOND_IN_MILLISECONDS; + private static long buildExpiresOn(Clock clock) { + return clock.instant().plusSeconds(SAS_TOKEN_VALID_SECS).getEpochSecond(); } public static String getDefaultCaCert() { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java index 2ea56ce799..26c5b3fa42 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java @@ -17,6 +17,7 @@ package org.thingsboard.rule.engine.mqtt.azure; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.annotations.VisibleForTesting; import io.netty.handler.codec.mqtt.MqttVersion; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.AzureIotHubUtil; @@ -36,6 +37,8 @@ import org.thingsboard.server.common.data.plugin.ComponentClusteringMode; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.util.TbPair; +import java.time.Clock; + @Slf4j @RuleNode( type = ComponentType.EXTERNAL, @@ -49,6 +52,8 @@ import org.thingsboard.server.common.data.util.TbPair; ) public class TbAzureIotHubNode extends TbMqttNode { + private Clock clock = Clock.systemUTC(); + @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { super.init(ctx); @@ -73,7 +78,7 @@ public class TbAzureIotHubNode extends TbMqttNode { config.setUsername(AzureIotHubUtil.buildUsername(mqttNodeConfiguration.getHost(), config.getClientId())); ClientCredentials credentials = mqttNodeConfiguration.getCredentials(); if (CredentialsType.SAS == credentials.getType()) { - config.setPassword(AzureIotHubUtil.buildSasToken(mqttNodeConfiguration.getHost(), ((AzureIotHubSasCredentials) credentials).getSasKey())); + config.setPassword(AzureIotHubUtil.buildSasToken(mqttNodeConfiguration.getHost(), ((AzureIotHubSasCredentials) credentials).getSasKey(), clock)); } } @@ -81,6 +86,11 @@ public class TbAzureIotHubNode extends TbMqttNode { return initClient(ctx); } + @VisibleForTesting + void setClock(Clock clock) { + this.clock = clock; + } + @Override public TbPair upgrade(int fromVersion, JsonNode oldConfiguration) throws TbNodeException { boolean hasChanges = false; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeTest.java index 433d5d4673..c8c1553fa5 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeTest.java @@ -34,6 +34,9 @@ import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.credentials.CertPemCredentials; import org.thingsboard.rule.engine.mqtt.TbMqttNodeConfiguration; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; @@ -77,7 +80,10 @@ public class TbAzureIotHubNodeTest extends AbstractRuleNodeUpgradeTest { @Test public void verifyPrepareMqttClientConfigMethodWithAzureIotHubSasCredentials() throws Exception { - AzureIotHubSasCredentials credentials = new AzureIotHubSasCredentials(); + var fixedClock = Clock.fixed(Instant.parse("2030-01-01T00:00:00Z"), ZoneOffset.UTC); + azureIotHubNode.setClock(fixedClock); + + var credentials = new AzureIotHubSasCredentials(); credentials.setSasKey("testSasKey"); credentials.setCaCert("test-ca-cert.pem"); azureIotHubNodeConfig.setCredentials(credentials); @@ -89,7 +95,7 @@ public class TbAzureIotHubNodeTest extends AbstractRuleNodeUpgradeTest { azureIotHubNode.prepareMqttClientConfig(mqttClientConfig); assertThat(mqttClientConfig.getUsername()).isEqualTo(AzureIotHubUtil.buildUsername(azureIotHubNodeConfig.getHost(), mqttClientConfig.getClientId())); - assertThat(mqttClientConfig.getPassword()).isEqualTo(AzureIotHubUtil.buildSasToken(azureIotHubNodeConfig.getHost(), credentials.getSasKey())); + assertThat(mqttClientConfig.getPassword()).isEqualTo(AzureIotHubUtil.buildSasToken(azureIotHubNodeConfig.getHost(), credentials.getSasKey(), fixedClock)); } @Test From 82d2c1d93a15504698e69b1d53934511c44122ac Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 10 Jul 2025 11:40:14 +0300 Subject: [PATCH 121/249] AI rule node: get rid of model type as record component --- .../thingsboard/server/common/data/ai/model/AiModelConfig.java | 2 -- .../server/common/data/ai/model/chat/AiChatModelConfig.java | 2 ++ .../common/data/ai/model/chat/AmazonBedrockChatModelConfig.java | 2 -- .../common/data/ai/model/chat/AnthropicChatModelConfig.java | 2 -- .../common/data/ai/model/chat/AzureOpenAiChatModelConfig.java | 2 -- .../common/data/ai/model/chat/GitHubModelsChatModelConfig.java | 2 -- .../data/ai/model/chat/GoogleAiGeminiChatModelConfig.java | 2 -- .../data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java | 2 -- .../common/data/ai/model/chat/MistralAiChatModelConfig.java | 2 -- .../server/common/data/ai/model/chat/OpenAiChatModelConfig.java | 2 -- 10 files changed, 2 insertions(+), 18 deletions(-) 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 d9e6a1753e..0a2b41a91f 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 @@ -15,7 +15,6 @@ */ package org.thingsboard.server.common.data.ai.model; -import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; @@ -74,7 +73,6 @@ public interface AiModelConfig { }) AiProviderConfig providerConfig(); - @JsonProperty("modelType") AiModelType modelType(); } 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 2284d8b485..8df5cc0075 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 @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.ai.model.chat; +import com.fasterxml.jackson.annotation.JsonProperty; import dev.langchain4j.model.chat.ChatModel; import org.thingsboard.server.common.data.ai.model.AiModelConfig; import org.thingsboard.server.common.data.ai.model.AiModelType; @@ -28,6 +29,7 @@ public sealed interface AiChatModelConfig> extend ChatModel configure(Langchain4jChatModelConfigurer configurer); @Override + @JsonProperty(value = "modelType", access = JsonProperty.Access.READ_ONLY) default AiModelType modelType() { return AiModelType.CHAT; } 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 1f4e4645dd..d792829c23 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 @@ -23,12 +23,10 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; import lombok.With; -import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; public record AmazonBedrockChatModelConfig( - AiModelType modelType, @NotNull @Valid AmazonBedrockProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, 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 69af0f8c2b..478b0a4208 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 @@ -23,12 +23,10 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; import lombok.With; -import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; public record AnthropicChatModelConfig( - AiModelType modelType, @NotNull @Valid AnthropicProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, 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 afe5f6c563..88098e36e8 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 @@ -23,12 +23,10 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; import lombok.With; -import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; public record AzureOpenAiChatModelConfig( - AiModelType modelType, @NotNull @Valid AzureOpenAiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, 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 ecadb7d453..57322c7df5 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 @@ -23,12 +23,10 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; import lombok.With; -import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig; public record GitHubModelsChatModelConfig( - AiModelType modelType, @NotNull @Valid GitHubModelsProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, 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 1922dea6a1..1b59bce3b5 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 @@ -23,12 +23,10 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; import lombok.With; -import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; public record GoogleAiGeminiChatModelConfig( - AiModelType modelType, @NotNull @Valid GoogleAiGeminiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, 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 e8691c0926..cb23406534 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 @@ -23,12 +23,10 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; import lombok.With; -import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; public record GoogleVertexAiGeminiChatModelConfig( - AiModelType modelType, @NotNull @Valid GoogleVertexAiGeminiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, 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 ea2409ba35..da15e1a873 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 @@ -23,12 +23,10 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; import lombok.With; -import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; public record MistralAiChatModelConfig( - AiModelType modelType, @NotNull @Valid MistralAiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, 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 95f6f1cdc3..886c020634 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 @@ -23,12 +23,10 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; import lombok.With; -import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; public record OpenAiChatModelConfig( - AiModelType modelType, @NotNull @Valid OpenAiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, From 17d7931e5dda6aa377fbc2eb13c4973256c7b5e4 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 10 Jul 2025 12:26:10 +0300 Subject: [PATCH 122/249] AI rule node: add basic save API tests --- .../controller/AiModelControllerTest.java | 192 ++++++++++++++++++ .../ai/model/chat/OpenAiChatModelConfig.java | 2 + 2 files changed, 194 insertions(+) create mode 100644 application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java diff --git a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java new file mode 100644 index 0000000000..1a6ece29f3 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java @@ -0,0 +1,192 @@ +/** + * 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.controller; + +import org.junit.Test; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.web.servlet.ResultActions; +import org.thingsboard.server.common.data.EntityType; +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.audit.ActionType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.service.entitiy.TbLogEntityActionService; +import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; + +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +public class AiModelControllerTest extends AbstractControllerTest { + + @SpyBean + private EntitiesVersionControlService versionControlService; + + @SpyBean + private TbLogEntityActionService logEntityActionService; + + /* --- Save API tests --- */ + + @Test + public void saveAiModel_whenUserIsSysAdmin_shouldReturnForbidden() throws Exception { + // GIVEN + loginSysAdmin(); + + AiModel model = constructValidModel(); + + // WHEN + ResultActions result = doPost("/api/ai/model", model); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void saveAiModel_whenUserIsCustomerUser_shouldReturnForbidden() throws Exception { + // GIVEN + loginCustomerUser(); + + AiModel model = constructValidModel(); + + // WHEN + ResultActions result = doPost("/api/ai/model", model); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void saveAiModel_whenCreatingValidModelAsTenantAdmin_shouldSucceed() throws Exception { + // GIVEN + loginTenantAdmin(); + + AiModel model = constructValidModel(); + + // WHEN + var savedModel = doPost("/api/ai/model", model, AiModel.class); + + // THEN + + // verify returned object + assertThat(savedModel.getId()).isNotNull(); + assertThat(savedModel.getUuidId()).isNotNull().isNotEqualTo(EntityId.NULL_UUID); + assertThat(savedModel.getId().getEntityType()).isEqualTo(EntityType.AI_MODEL); + + assertThat(savedModel.getCreatedTime()).isPositive(); + assertThat(savedModel.getVersion()).isEqualTo(1); + + assertThat(savedModel.getTenantId()).isEqualTo(tenantId); + assertThat(savedModel.getName()).isEqualTo("Test model"); + assertThat(savedModel.getConfiguration()).isEqualTo(model.getConfiguration()); + + assertThat(savedModel.getExternalId()).isNull(); + + // verify auto-commit + then(versionControlService).should().autoCommit( + argThat(actualUser -> Objects.equals(actualUser.getId(), tenantAdminUser.getId())), eq(savedModel.getId()) + ); + + // verify a rule engine message was sent, and an audit log was created + then(logEntityActionService).should().logEntityAction( + eq(tenantId), + eq(savedModel.getId()), + eq(savedModel), + eq(ActionType.ADDED), + argThat(actualUser -> Objects.equals(actualUser.getId(), tenantAdminUser.getId())) + ); + } + + @Test + public void saveAiModel_whenUpdatingExistingModelAsTenantAdmin_shouldSucceedAndLogAction() throws Exception { + // GIVEN + loginTenantAdmin(); + + var model = doPost("/api/ai/model", constructValidModel(), AiModel.class); + + var newModelConfig = OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("test-api-key-updated")) + .modelId("o4-mini") + .temperature(0.2) + .topP(0.4) + .frequencyPenalty(0.2) + .presencePenalty(0.5) + .maxOutputTokens(2000) + .timeoutSeconds(20) + .maxRetries(0) + .build(); + + model.setName("Test model updated"); + model.setConfiguration(newModelConfig); + + // WHEN + var updatedModel = doPost("/api/ai/model", model, AiModel.class); + + // THEN + + // verify returned object + assertThat(updatedModel.getId()).isEqualTo(model.getId()); + + assertThat(updatedModel.getCreatedTime()).isEqualTo(model.getCreatedTime()); + assertThat(updatedModel.getVersion()).isEqualTo(2); + + assertThat(updatedModel.getTenantId()).isEqualTo(tenantId); + assertThat(updatedModel.getName()).isEqualTo("Test model updated"); + assertThat(updatedModel.getConfiguration()).isEqualTo(newModelConfig); + + assertThat(updatedModel.getExternalId()).isNull(); + + // verify auto-commit + then(versionControlService).should(times(2)).autoCommit( + argThat(actualUser -> Objects.equals(actualUser.getId(), tenantAdminUser.getId())), eq(updatedModel.getId()) + ); + + // verify a rule engine message was sent, and an audit log was created + then(logEntityActionService).should().logEntityAction( + eq(tenantId), eq(updatedModel.getId()), eq(updatedModel), eq(ActionType.UPDATED), + argThat(actualUser -> Objects.equals(actualUser.getId(), tenantAdminUser.getId())) + ); + } + + private AiModel constructValidModel() { + 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("Test model") + .configuration(modelConfig) + .build(); + } + +} 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 886c020634..7c03a670a7 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 @@ -22,10 +22,12 @@ 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.OpenAiProviderConfig; +@Builder public record OpenAiChatModelConfig( @NotNull @Valid OpenAiProviderConfig providerConfig, @NotBlank String modelId, From 64f0da33655ae8d7569b140649bfafff92b9530a Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Thu, 10 Jul 2025 13:32:39 +0300 Subject: [PATCH 123/249] UI: ref after review --- ui-ngx/src/app/core/services/menu.models.ts | 2 +- ui-ngx/src/app/modules/common/modules-map.ts | 8 +- .../ai-model/ai-model-dialog.component.html | 41 ++--- .../ai-model/ai-model-dialog.component.scss | 0 .../ai-model/ai-model-dialog.component.ts | 30 +++- .../check-connectivity-dialog.component.html | 9 +- .../check-connectivity-dialog.component.scss | 7 +- .../check-connectivity-dialog.component.ts | 6 +- .../home/components/home-components.module.ts | 3 + .../external/ai-config.component.html | 5 +- .../rule-node/external/ai-config.component.ts | 4 +- .../ai-model/ai-model-table-config.resolve.ts | 9 +- .../models-list-autocomplete.component.html | 41 ----- .../models-list-autocomplete.component.ts | 161 ------------------ .../json-object-edit.component.html | 7 + .../components/json-object-edit.component.ts | 2 + .../string-autocomplete.component.html | 2 +- .../string-autocomplete.component.ts | 3 + ui-ngx/src/app/shared/shared.module.ts | 6 - .../assets/locale/locale.constant-en_US.json | 1 + 20 files changed, 88 insertions(+), 259 deletions(-) rename ui-ngx/src/app/{shared => modules/home}/components/ai-model/ai-model-dialog.component.html (88%) rename ui-ngx/src/app/{shared => modules/home}/components/ai-model/ai-model-dialog.component.scss (100%) rename ui-ngx/src/app/{shared => modules/home}/components/ai-model/ai-model-dialog.component.ts (86%) delete mode 100644 ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.html delete mode 100644 ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts diff --git a/ui-ngx/src/app/core/services/menu.models.ts b/ui-ngx/src/app/core/services/menu.models.ts index dc568c148e..b660b0ab0d 100644 --- a/ui-ngx/src/app/core/services/menu.models.ts +++ b/ui-ngx/src/app/core/services/menu.models.ts @@ -794,6 +794,7 @@ const defaultUserMenuMap = new Map([ {id: MenuId.home}, {id: MenuId.alarms}, {id: MenuId.dashboards}, + {id: MenuId.ai_models}, { id: MenuId.entities, pages: [ @@ -852,7 +853,6 @@ const defaultUserMenuMap = new Map([ {id: MenuId.notification_rules} ] }, - {id: MenuId.ai_models}, { id: MenuId.mobile_center, pages: [ diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index 60a26e4211..0890b9e623 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -336,8 +336,7 @@ import * as DatapointsLimitComponent from '@shared/components/time/datapoints-li import * as AggregationTypeSelectComponent from '@shared/components/time/aggregation/aggregation-type-select.component'; import * as AggregationOptionsConfigComponent from '@shared/components/time/aggregation/aggregation-options-config-panel.component'; import * as IntervalOptionsConfigPanelComponent from '@shared/components/time/interval-options-config-panel.component'; -import * as AIModelDialogComponent from '@shared/components/ai-model/ai-model-dialog.component'; -import * as ModelsListAutocompleteComponent from '@shared/components/ai-model/models-list-autocomplete.component'; +import * as AIModelDialogComponent from '@home/components/ai-model/ai-model-dialog.component'; import { IModulesMap } from '@modules/common/modules-map.models'; import { Observable, of } from 'rxjs'; @@ -534,8 +533,6 @@ class ModulesMap implements IModulesMap { '@shared/components/image/gallery-image-input.component': GalleryImageInputComponent, '@shared/components/image/multiple-gallery-image-input.component': MultipleGalleryImageInputComponent, '@shared/components/popover.service': TbPopoverService, - '@shared/components/ai-model/ai-model-dialog.component': AIModelDialogComponent, - '@shared/components/ai-model/models-list-autocomplete.component': ModelsListAutocompleteComponent, '@home/components/alarm/alarm-filter-config.component': AlarmFilterConfigComponent, @@ -672,7 +669,8 @@ class ModulesMap implements IModulesMap { '@home/components/dashboard-page/dashboard-image-dialog.component': DashboardImageDialogComponent, '@home/components/widget/widget-container.component': WidgetContainerComponent, '@home/components/profile/queue/tenant-profile-queues.component': TenantProfileQueuesComponent, - '@home/components/queue/queue-form.component': QueueFormComponent + '@home/components/queue/queue-form.component': QueueFormComponent, + '@home/components/ai-model/ai-model-dialog.component': AIModelDialogComponent, }; init(): Observable { diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html similarity index 88% rename from ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html rename to ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html index 2a5a34a2c9..4201c5a418 100644 --- a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html @@ -56,7 +56,7 @@
- @if (AiModelMap.get(provider).providerFieldsList.includes('personalAccessToken')) { + @if (providerFieldsList.includes('personalAccessToken')) { ai-models.personal-access-token @@ -66,7 +66,7 @@ } - @if (AiModelMap.get(provider).providerFieldsList.includes('projectId')) { + @if (providerFieldsList.includes('projectId')) { ai-models.project-id @@ -75,7 +75,7 @@ } - @if (AiModelMap.get(provider).providerFieldsList.includes('location')) { + @if (providerFieldsList.includes('location')) { ai-models.location @@ -84,7 +84,7 @@ } - @if (AiModelMap.get(provider).providerFieldsList.includes('serviceAccountKey')) { + @if (providerFieldsList.includes('serviceAccountKey')) { } - @if (AiModelMap.get(provider).providerFieldsList.includes('endpoint')) { + @if (providerFieldsList.includes('endpoint')) { ai-models.endpoint @@ -106,13 +106,13 @@ } - @if (AiModelMap.get(provider).providerFieldsList.includes('serviceVersion')) { + @if (providerFieldsList.includes('serviceVersion')) { ai-models.service-version } - @if (AiModelMap.get(provider).providerFieldsList.includes('apiKey')) { + @if (providerFieldsList.includes('apiKey')) { ai-models.api-key @@ -129,14 +129,17 @@
ai-models.configuration
- - + +
- @if (AiModelMap.get(provider).modelFieldsList.includes('temperature')) { + @if (modelFieldsList.includes('temperature')) {
{{ 'ai-models.temperature' | translate }} @@ -155,7 +158,7 @@
} - @if (AiModelMap.get(provider).modelFieldsList.includes('topP')) { + @if (modelFieldsList.includes('topP')) {
{{ 'ai-models.top-p' | translate }} @@ -175,7 +178,7 @@
} - @if (AiModelMap.get(provider).modelFieldsList.includes('topK')) { + @if (modelFieldsList.includes('topK')) {
{{ 'ai-models.top-k' | translate }} @@ -194,7 +197,7 @@
} - @if (AiModelMap.get(provider).modelFieldsList.includes('presencePenalty')) { + @if (modelFieldsList.includes('presencePenalty')) {
{{ 'ai-models.presence-penalty' | translate }} @@ -206,7 +209,7 @@
} - @if (AiModelMap.get(provider).modelFieldsList.includes('frequencyPenalty')) { + @if (modelFieldsList.includes('frequencyPenalty')) {
{{ 'ai-models.frequency-penalty' | translate }} @@ -217,7 +220,7 @@
} - @if (AiModelMap.get(provider).modelFieldsList.includes('maxOutputTokens')) { + @if (modelFieldsList.includes('maxOutputTokens')) {
{{ 'ai-models.max-output-token' | translate }} diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.scss b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.scss similarity index 100% rename from ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.scss rename to ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.scss diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts similarity index 86% rename from ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts rename to ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts index d9ae3eaa69..a5fca8d122 100644 --- a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts @@ -20,7 +20,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { Router } from '@angular/router'; import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { StepperOrientation } from '@angular/cdk/stepper'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { EntityType } from '@shared/models/entity-type.models'; @@ -35,6 +35,7 @@ import { } from '@shared/models/ai-model.models'; import { AiModelService } from '@core/http/ai-model.service'; import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component'; +import { map } from 'rxjs/operators'; export interface AIModelDialogData { AIModel?: AiModel; @@ -112,7 +113,7 @@ export class AIModelDialogComponent extends DialogComponent { this.provider = provider; - this.aiModelForms.get('configuration.modelId').reset({}); + this.aiModelForms.get('configuration.modelId').reset(''); this.aiModelForms.get('configuration.providerConfig').reset({}); this.updateValidation(provider); }) @@ -120,11 +121,28 @@ export class AIModelDialogComponent extends DialogComponent> { + const search = searchText ? searchText?.toLowerCase() : ''; + return of(this.provider ? AiModelMap.get(this.provider).modelList || [] : []).pipe( + map(name => name?.filter(option => option.toLowerCase().includes(search))), + ); + } + private updateValidation(provider: AiProvider) { - ProviderFieldsAllList.forEach(key => - this.aiModelForms.get('configuration.providerConfig') - .get(key)[AiModelMap.get(provider).providerFieldsList.includes(key) ? 'enable' : 'disable']() - ) + ProviderFieldsAllList.forEach(key => { + if (AiModelMap.get(provider).providerFieldsList.includes(key)) { + this.aiModelForms.get('configuration.providerConfig').get(key).enable(); + } else { + this.aiModelForms.get('configuration.providerConfig').get(key).disable(); + } + }) + } + + get providerFieldsList(): string[] { + return AiModelMap.get(this.provider).providerFieldsList; + } + get modelFieldsList(): string[] { + return AiModelMap.get(this.provider).modelFieldsList; } cancel(): void { diff --git a/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html index 9ead874275..afc881b10b 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html @@ -15,15 +15,15 @@ limitations under the License. --> -
-

ai-models.check-connectivity

+ +

ai-models.check-connectivity

-
+
-
- +
@@ -84,6 +84,7 @@ @if (aiConfigForm.get('responseFormat.type').value === responseFormat.JSON_SCHEMA) { 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 3b7f0a92d6..3c9d8bf919 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 @@ -19,7 +19,7 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; import { EntityType } from '@shared/models/entity-type.models'; import { MatDialog } from '@angular/material/dialog'; -import { AIModelDialogComponent, AIModelDialogData } from '@shared/components/ai-model/ai-model-dialog.component'; +import { AIModelDialogComponent, AIModelDialogData } from '@home/components/ai-model/ai-model-dialog.component'; import { AiModel, AiRuleNodeResponseFormatTypeOnlyText, ResponseFormat } from '@shared/models/ai-model.models'; import { deepTrim } from '@core/utils'; @@ -47,7 +47,7 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { protected onConfigurationSet(configuration: RuleNodeConfiguration) { this.aiConfigForm = this.fb.group({ - modelSettingsId: [configuration?.modelSettingsId ?? null, [Validators.required]], + modelId: [configuration?.modelId ?? null, [Validators.required]], systemPrompt: [configuration?.systemPrompt ?? '', [Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], userPrompt: [configuration?.userPrompt ?? '', [Validators.required, Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], responseFormat: this.fb.group({ diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts index 3f3ded2abb..03db209ada 100644 --- a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts @@ -31,7 +31,7 @@ import { Observable } from 'rxjs'; import { AiModel, AiProviderTranslations } from '@shared/models/ai-model.models'; import { AiModelService } from '@core/http/ai-model.service'; import { AiModelTableHeaderComponent } from '@home/pages/ai-model/ai-model-table-header.component'; -import { AIModelDialogComponent, AIModelDialogData } from '@shared/components/ai-model/ai-model-dialog.component'; +import { AIModelDialogComponent, AIModelDialogData } from '@home/components/ai-model/ai-model-dialog.component'; import { map } from 'rxjs/operators'; @Injectable() @@ -81,7 +81,7 @@ export class AiModelsTableConfigResolver { this.config.cellActionDescriptors = this.configureCellActions(); this.config.handleRowClick = ($event, model) => { - this.editModel(model); + this.editModel($event, model); return true; }; } @@ -96,12 +96,13 @@ export class AiModelsTableConfigResolver { name: this.translate.instant('action.edit'), icon: 'edit', isEnabled: () => true, - onAction: ($event, entity) => this.editModel(entity) + onAction: ($event, entity) => this.editModel($event, entity) } ]; } - private editModel(AIModel: AiModel): void { + private editModel($event, AIModel: AiModel): void { + $event?.stopPropagation(); this.addModel(AIModel, false).subscribe(); } diff --git a/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.html b/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.html deleted file mode 100644 index d204013805..0000000000 --- a/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.html +++ /dev/null @@ -1,41 +0,0 @@ - - - {{label}} - - - - {{errorText}} - - - - - - - diff --git a/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts b/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts deleted file mode 100644 index 3e3c0cbb66..0000000000 --- a/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts +++ /dev/null @@ -1,161 +0,0 @@ -/// -/// 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 { Component, ElementRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core'; -import { ControlValueAccessor, FormBuilder, FormControl, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; -import { Observable } from 'rxjs'; -import { map, startWith, tap } from 'rxjs/operators'; -import { TranslateService } from '@ngx-translate/core'; -import { coerceBoolean } from '@shared/decorators/coercion'; -import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; -import { AiModelMap, AiProvider } from '@shared/models/ai-model.models'; - -@Component({ - selector: 'tb-models-list-autocomplete', - templateUrl: './models-list-autocomplete.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ModelsListAutocompleteComponent), - multi: true - } - ] -}) -export class ModelsListAutocompleteComponent implements ControlValueAccessor, OnInit, OnChanges { - - @ViewChild('nameInput', {static: true}) nameInput: ElementRef; - - @Input() - disabled: boolean; - - @Input() - @coerceBoolean() - required = false; - - @Input() - provider: AiProvider; - - @Input() - placeholderText: string = this.translate.instant('widget-config.set'); - - @Input() - subscriptSizing: SubscriptSizing = 'dynamic'; - - @Input() - appearance: MatFormFieldAppearance = 'outline'; - - @Input() - label: string; - - @Input() - errorText: string; - - selectionFormControl: FormControl; - modelValue: string | null; - - filteredOptions$: Observable>; - - searchText = ''; - - private dirty = false; - - private propagateChange = (_val: any) => {}; - - constructor(private fb: FormBuilder, - private translate: TranslateService) { - } - - ngOnInit() { - this.selectionFormControl = this.fb.control('', this.required ? [Validators.required] : []); - this.setupFilteredOptions(); - } - - ngOnChanges(changes: SimpleChanges) { - if (changes.provider && !changes.provider.isFirstChange()) { - this.setupFilteredOptions(); - this.selectionFormControl.setValue(null, {emitEvent: false}); - this.modelValue = null; - this.propagateChange(null); - } - } - - private setupFilteredOptions() { - this.filteredOptions$ = this.selectionFormControl.valueChanges.pipe( - startWith(''), - tap(value => this.updateView(value)), - map(value => { - const search = value ? value.toLowerCase() : ''; - const options = this.provider ? AiModelMap.get(this.provider).modelList || [] : []; - return search ? options.filter(option => option.toLowerCase().includes(search)) : options; - }) - ); - } - - writeValue(option?: string): void { - this.searchText = ''; - this.modelValue = option ? option : null; - - if (option) { - this.selectionFormControl.patchValue(option, { emitEvent: false }); - this.dirty = true; - } else { - this.selectionFormControl.patchValue(null, { emitEvent: false }); - this.dirty = true; - } - } - - onFocus() { - if (this.dirty) { - this.selectionFormControl.updateValueAndValidity({onlySelf: true, emitEvent: true}); - this.dirty = false; - } - } - - updateView(value: string) { - this.searchText = value ? value : ''; - if (this.modelValue !== value && value) { - this.modelValue = value; - this.propagateChange(this.modelValue); - } - } - - registerOnChange(fn: any): void { - this.propagateChange = fn; - } - - registerOnTouched(fn: any): void { - } - - setDisabledState(isDisabled: boolean): void { - this.disabled = isDisabled; - if (this.disabled) { - this.selectionFormControl.disable({emitEvent: false}); - } else { - this.selectionFormControl.enable({emitEvent: false}); - } - } - - clear() { - this.selectionFormControl.patchValue(null, {emitEvent: true}); - this.propagateChange(null); - this.modelValue = null; - setTimeout(() => { - this.nameInput.nativeElement.blur(); - this.nameInput.nativeElement.focus(); - }, 0); - } -} diff --git a/ui-ngx/src/app/shared/components/json-object-edit.component.html b/ui-ngx/src/app/shared/components/json-object-edit.component.html index 70967d3483..608efd8ad4 100644 --- a/ui-ngx/src/app/shared/components/json-object-edit.component.html +++ b/ui-ngx/src/app/shared/components/json-object-edit.component.html @@ -32,6 +32,13 @@ mat-button *ngIf="!readonly && !disabled" class="tidy" (click)="minifyJSON()"> {{'js-func.mini' | translate }} + @if (iconHint) { + + } }
diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts index 4f89321c30..82fff06270 100644 --- a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts @@ -39,18 +39,11 @@ export const aiModelRoutes: Routes = [ } ]; -const routes: Routes = [ - { - path: 'ai-models', - redirectTo: '/settings/ai-models' - } -]; - @NgModule({ providers: [ AiModelsTableConfigResolver ], - imports: [RouterModule.forChild(routes)], + imports: [RouterModule.forChild(aiModelRoutes)], exports: [RouterModule], }) export class AiModelRoutingModule { } diff --git a/ui-ngx/src/app/shared/components/json-object-edit.component.html b/ui-ngx/src/app/shared/components/json-object-edit.component.html index 608efd8ad4..b95f0eddad 100644 --- a/ui-ngx/src/app/shared/components/json-object-edit.component.html +++ b/ui-ngx/src/app/shared/components/json-object-edit.component.html @@ -32,13 +32,7 @@ mat-button *ngIf="!readonly && !disabled" class="tidy" (click)="minifyJSON()"> {{'js-func.mini' | translate }} - @if (iconHint) { - - } +