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; + } + +}