Browse Source

Merge pull request #4 from ArtemDzhereleiko/AD/imp/ai-models

YES
pull/13371/head
Dmytro Skarzhynets 1 year ago
committed by GitHub
parent
commit
2a8c6a3d2a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java
  2. 54
      ui-ngx/src/app/core/http/ai-model.service.ts
  3. 9
      ui-ngx/src/app/core/http/entity.service.ts
  4. 1
      ui-ngx/src/app/core/http/public-api.ts
  5. 16
      ui-ngx/src/app/core/services/menu.models.ts
  6. 4
      ui-ngx/src/app/modules/common/modules-map.ts
  7. 265
      ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html
  8. 23
      ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.scss
  9. 164
      ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts
  10. 57
      ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html
  11. 68
      ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.scss
  12. 89
      ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts
  13. 6
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  14. 43
      ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html
  15. 29
      ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts
  16. 133
      ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html
  17. 104
      ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts
  18. 7
      ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts
  19. 4
      ui-ngx/src/app/modules/home/models/services.map.ts
  20. 2
      ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts
  21. 49
      ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts
  22. 127
      ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts
  23. 21
      ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.html
  24. 33
      ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts
  25. 33
      ui-ngx/src/app/modules/home/pages/ai-model/ai-model.module.ts
  26. 4
      ui-ngx/src/app/modules/home/pages/home-pages.module.ts
  27. 17
      ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts
  28. 1
      ui-ngx/src/app/shared/components/json-object-edit.component.html
  29. 2
      ui-ngx/src/app/shared/components/string-autocomplete.component.html
  30. 3
      ui-ngx/src/app/shared/components/string-autocomplete.component.ts
  31. 224
      ui-ngx/src/app/shared/models/ai-model.models.ts
  32. 1
      ui-ngx/src/app/shared/models/constants.ts
  33. 19
      ui-ngx/src/app/shared/models/entity-type.models.ts
  34. 27
      ui-ngx/src/app/shared/models/id/ai-model-id.ts
  35. 1
      ui-ngx/src/app/shared/models/id/public-api.ts
  36. 3
      ui-ngx/src/app/shared/models/public-api.ts
  37. 4
      ui-ngx/src/app/shared/models/vc.models.ts
  38. 1
      ui-ngx/src/assets/help/en_US/rulenode/ai_node_prompt_settings.md
  39. 100
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  40. 30
      ui-ngx/src/form.scss

1
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java

@ -67,6 +67,7 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie
Output connections: <code>Success</code>, <code>Failure</code>.
""",
configClazz = TbAiNodeConfiguration.class,
configDirective = "tbExternalNodeAiConfig",
ruleChainTypes = RuleChainType.CORE
)
public final class TbAiNode extends TbAbstractExternalNode implements TbNode {

54
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<AiModel> {
return this.http.post<AiModel>('/api/ai/model', aiModel, defaultHttpOptionsFromConfig(config));
}
public getAiModels(pageLink: PageLink, config?: RequestConfig): Observable<PageData<AiModel>> {
return this.http.get<PageData<AiModel>>(`/api/ai/model${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config));
}
public getAiModelById(aiModelId: string, config?: RequestConfig): Observable<AiModel> {
return this.http.get<AiModel>(`/api/ai/model/${aiModelId}`, defaultHttpOptionsFromConfig(config));
}
public deleteAiModel(aiModelId: string, config?: RequestConfig) {
return this.http.delete(`/api/ai/model/${aiModelId}`, defaultHttpOptionsFromConfig(config));
}
public checkConnectivity(aiModelWithUserMsg: AiModelWithUserMsg, config?: RequestConfig): Observable<CheckConnectivityResult> {
return this.http.post<CheckConnectivityResult>('/api/ai/model/chat', aiModelWithUserMsg, defaultHttpOptionsFromConfig(config));
}
}

9
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:
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:
pageLink.sortOrder.property = 'name';
entitiesObservable = this.aiModelService.getAiModels(pageLink, config);
break;
}
return entitiesObservable;
}

1
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'

16
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<MenuId, MenuSection>([
icon: 'mdi:message-cog'
}
],
[
MenuId.ai_models,
{
id: MenuId.ai_models,
name: 'ai-models.ai-models',
type: 'link',
path: '/settings/ai-models',
icon: 'auto_awesome'
}
],
[
MenuId.mobile_center,
{
@ -856,7 +867,8 @@ const defaultUserMenuMap = new Map<Authority, MenuReference[]>([
{id: MenuId.notification_settings},
{id: MenuId.repository_settings},
{id: MenuId.auto_commit_settings},
{id: MenuId.trendz_settings}
{id: MenuId.trendz_settings},
{id: MenuId.ai_models}
]
},
{

4
ui-ngx/src/app/modules/common/modules-map.ts

@ -336,6 +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 '@home/components/ai-model/ai-model-dialog.component';
import { IModulesMap } from '@modules/common/modules-map.models';
import { Observable, of } from 'rxjs';
@ -668,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<any> {

265
ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html

@ -0,0 +1,265 @@
<!--
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.
-->
<mat-toolbar color="primary">
<h2>{{ dialogTitle | translate }}</h2>
<span class="flex-1"></span>
<div tb-help="aiModels"></div>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content>
<form [formGroup]="aiModelForms">
<section class="tb-form-panel outlined no-border no-padding">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label translate>ai-models.name</mat-label>
<input matInput required formControlName="name">
<mat-hint></mat-hint>
<mat-error *ngIf="aiModelForms.get('name').hasError('required')">
{{ 'ai-models.name-required' | translate }}
</mat-error>
<mat-error *ngIf="aiModelForms.get('name').hasError('maxlength')">
{{ 'ai-models.name-max-length' | translate }}
</mat-error>
</mat-form-field>
</section>
<div formGroupName="configuration" class="tb-form-panel no-border no-padding">
<section class="tb-form-panel stroked no-padding-bottom">
<div class="tb-form-panel-title" translate>ai-models.provider</div>
<section class="tb-form-panel outlined no-border no-padding">
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>ai-models.ai-provider</mat-label>
<mat-select required formControlName="provider">
<mat-option *ngFor="let provider of providerMap" [value]="provider">
{{providerTranslationMap.get(provider) | translate}}
</mat-option>
</mat-select>
</mat-form-field>
<div formGroupName="providerConfig" class="tb-form-panel no-border no-padding">
@if (providerFieldsList.includes('personalAccessToken')) {
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>ai-models.personal-access-token</mat-label>
<input type="password" required matInput formControlName="personalAccessToken">
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-error *ngIf="aiModelForms.get('configuration').get('providerConfig').get('personalAccessToken').hasError('required')">
{{ 'ai-models.personal-access-token-required' | translate }}
</mat-error>
</mat-form-field>
}
@if (providerFieldsList.includes('projectId')) {
<mat-form-field class="mat-block flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>ai-models.project-id</mat-label>
<input matInput required formControlName="projectId">
<mat-error *ngIf="aiModelForms.get('configuration').get('providerConfig').get('projectId').hasError('required')">
{{ 'ai-models.project-id-required' | translate }}
</mat-error>
</mat-form-field>
}
@if (providerFieldsList.includes('location')) {
<mat-form-field class="mat-block flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>ai-models.location</mat-label>
<input matInput required formControlName="location">
<mat-error *ngIf="aiModelForms.get('configuration').get('providerConfig').get('location').hasError('required')">
{{ 'ai-models.location-required' | translate }}
</mat-error>
</mat-form-field>
}
@if (providerFieldsList.includes('serviceAccountKey')) {
<tb-file-input formControlName="serviceAccountKey"
[existingFileName]="aiModelForms.get('configuration').get('providerConfig').get('fileName').value"
(fileNameChanged)="aiModelForms.get('configuration').get('providerConfig').get('fileName').setValue($event)"
required
requiredAsError
accept=".json,application/json"
allowedExtensions="json"
label="{{'ai-models.service-account-key-file' | translate}}"
noFileText="ai-models.no-file"
dropLabel="{{'ai-models.drop-file' | translate}}">
</tb-file-input>
}
@if (providerFieldsList.includes('endpoint')) {
<mat-form-field class="mat-block flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>ai-models.endpoint</mat-label>
<input required matInput formControlName="endpoint">
<mat-error *ngIf="aiModelForms.get('configuration').get('providerConfig').get('endpoint').hasError('required')">
{{ 'ai-models.endpoint-required' | translate }}
</mat-error>
</mat-form-field>
}
@if (providerFieldsList.includes('serviceVersion')) {
<mat-form-field class="mat-block flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>ai-models.service-version</mat-label>
<input matInput formControlName="serviceVersion">
</mat-form-field>
}
@if (providerFieldsList.includes('apiKey')) {
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>ai-models.api-key</mat-label>
<input type="password" required matInput formControlName="apiKey">
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-error *ngIf="aiModelForms.get('configuration').get('providerConfig').get('apiKey').hasError('required')">
{{ 'ai-models.api-key-required' | translate }}
</mat-error>
</mat-form-field>
}
</div>
</section>
</section>
<section class="tb-form-panel stroked">
<div class="tb-form-panel-title" translate>ai-models.configuration</div>
<section class="tb-form-panel no-border no-padding">
<section class="tb-form-panel outlined no-border no-padding">
<tb-string-autocomplete [fetchOptionsFn]="fetchOptions.bind(this)"
additionalClass="tb-suffix-show-on-hover"
appearance="outline"
panelWidth=""
required
[label]="(provider === aiProvider.AZURE_OPENAI ? 'ai-models.deployment-name': 'ai-models.model-id') | translate"
[errorText]="(provider === aiProvider.AZURE_OPENAI ? 'ai-models.deployment-name-required': 'ai-models.model-id-required') | translate"
formControlName="modelId">
</tb-string-autocomplete>
</section>
@if (modelFieldsList.includes('temperature')) {
<div class="tb-form-row space-between">
<div tb-hint-tooltip-icon="{{ 'ai-models.temperature-hint' | translate }}">
{{ 'ai-models.temperature' | translate }}
</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="temperature"
type="number" min="0" step="0.1" placeholder="{{ 'ai-models.set' | translate }}">
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'ai-models.temperature-min' | translate"
*ngIf="aiModelForms.get('configuration').get('temperature').hasError('min')"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
</div>
}
@if (modelFieldsList.includes('topP')) {
<div class="tb-form-row space-between">
<div tb-hint-tooltip-icon="{{ 'ai-models.top-p-hint' | translate }}">
{{ 'ai-models.top-p' | translate }}
</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="topP"
type="number" min="0" step="0.1" max="1" placeholder="{{ 'ai-models.set' | translate }}">
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'ai-models.top-p-min-max' | translate"
*ngIf="aiModelForms.get('configuration').get('topP').hasError('min') ||
aiModelForms.get('configuration').get('topP').hasError('max')"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
</div>
}
@if (modelFieldsList.includes('topK')) {
<div class="tb-form-row space-between">
<div tb-hint-tooltip-icon="{{ 'ai-models.top-k-hint' | translate }}">
{{ 'ai-models.top-k' | translate }}
</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="topK"
type="number" min="0" step="1" placeholder="{{ 'ai-models.set' | translate }}">
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'ai-models.top-k-min' | translate"
*ngIf="aiModelForms.get('configuration').get('topK').hasError('min')"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
</div>
}
@if (modelFieldsList.includes('presencePenalty')) {
<div class="tb-form-row space-between">
<div tb-hint-tooltip-icon="{{ 'ai-models.presence-penalty-hint' | translate }}">
{{ 'ai-models.presence-penalty' | translate }}
</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="presencePenalty"
type="number" step="1" placeholder="{{ 'ai-models.set' | translate }}">
</mat-form-field>
</div>
}
@if (modelFieldsList.includes('frequencyPenalty')) {
<div class="tb-form-row space-between">
<div tb-hint-tooltip-icon="{{ 'ai-models.frequency-penalty-hint' | translate }}">
{{ 'ai-models.frequency-penalty' | translate }}
</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="frequencyPenalty"
type="number" step="1" placeholder="{{ 'ai-models.set' | translate }}">
</mat-form-field>
</div>
}
@if (modelFieldsList.includes('maxOutputTokens')) {
<div class="tb-form-row space-between">
<div tb-hint-tooltip-icon="{{ 'ai-models.max-output-token-hint' | translate }}">
{{ 'ai-models.max-output-token' | translate }}
</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="maxOutputTokens"
type="number" min="1" step="1" placeholder="{{ 'ai-models.set' | translate }}">
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'ai-models.max-output-token-min' | translate"
*ngIf="aiModelForms.get('configuration').get('maxOutputTokens').hasError('min')"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
</div>
}
</section>
</section>
</div>
</form>
</div>
<div mat-dialog-actions>
<button mat-button color="primary"
type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()" cdkFocusInitial>
{{ 'action.cancel' | translate }}
</button>
<span class="flex-1"></span>
<button mat-button mat-stroked-button color="primary"
type="button" (click)="checkConnectivity()"
[disabled]="(isLoading$ | async) || aiModelForms.invalid">
{{ 'ai-models.check-connectivity' | translate }}
</button>
<button mat-button mat-raised-button color="primary"
type="button" (click)="add()"
[disabled]="(isLoading$ | async) || aiModelForms.invalid || !aiModelForms.dirty">
{{ 'action.save' | translate }}
</button>
</div>

23
ui-ngx/src/app/modules/home/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;
}

164
ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts

@ -0,0 +1,164 @@
///
/// 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, of } 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,
AiModelMap,
AiProvider,
AiProviderTranslations,
ModelType,
ProviderFieldsAllList
} 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;
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<AIModelDialogComponent, AiModel> {
readonly entityType = EntityType;
selectedIndex = 0;
dialogTitle = 'ai-models.ai-model';
stepperOrientation: Observable<StepperOrientation>;
aiProvider = AiProvider;
providerMap: AiProvider[] = Object.keys(AiProvider) as AiProvider[];
providerTranslationMap = AiProviderTranslations;
provider: AiProvider = AiProvider.OPENAI;
aiModelForms: FormGroup;
isAdd = false;
constructor(protected store: Store<AppState>,
protected router: Router,
protected dialogRef: MatDialogRef<AIModelDialogComponent, AiModel>,
@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: [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]],
fileName: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.fileName : '', [Validators.required]],
}),
modelId: [this.data.AIModel ? this.data.AIModel.configuration?.modelId : '', [Validators.required]],
temperature: [this.data.AIModel ? this.data.AIModel.configuration?.temperature : null, [Validators.min(0)]],
topP: [this.data.AIModel ? this.data.AIModel.configuration?.topP : null, [Validators.min(0.1), Validators.max(1)]],
topK: [this.data.AIModel ? this.data.AIModel.configuration?.topK : null, [Validators.min(0)]],
frequencyPenalty: [this.data.AIModel ? this.data.AIModel.configuration?.frequencyPenalty : null],
presencePenalty: [this.data.AIModel ? this.data.AIModel.configuration?.presencePenalty : null],
maxOutputTokens: [this.data.AIModel ? this.data.AIModel.configuration?.maxOutputTokens : null, [Validators.min(1)]]
})
});
this.aiModelForms.get('configuration.provider').valueChanges.pipe(
takeUntilDestroyed()
).subscribe((provider: AiProvider) => {
this.provider = provider;
this.aiModelForms.get('configuration.modelId').reset('');
this.aiModelForms.get('configuration.providerConfig').reset({});
this.updateValidation(provider);
})
this.updateValidation(this.provider);
}
fetchOptions(searchText: string): Observable<Array<string>> {
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 => {
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 {
this.dialogRef.close(null);
}
checkConnectivity() {
return this.dialog.open<CheckConnectivityDialogComponent, AIModelDialogData>(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));
}
}

57
ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html

@ -0,0 +1,57 @@
<!--
Copyright © 2016-2025 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<mat-toolbar class="transparent">
<h2 translate>ai-models.check-connectivity</h2>
<span class="flex-1"></span>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<div mat-dialog-content>
<div class="flex h-full flex-1 flex-col items-center justify-center">
<mat-progress-spinner color="warn" mode="indeterminate"
strokeWidth="5" *ngIf="!showCheckSuccess && !checkErrMsg">
</mat-progress-spinner>
<div *ngIf="showCheckSuccess">
<div class="flex flex-1 items-center justify-center">
<mat-icon class="success connection-icon">check_circle</mat-icon>
</div>
<span class="connection-status success">{{ "ai-models.check-connectivity-success" | translate }}</span>
</div>
<div *ngIf="checkErrMsg" class="w-full">
<div class="flex flex-1 items-center justify-center">
<mat-icon class="error connection-icon">cancel</mat-icon>
</div>
<div class="connection-status error">{{ "ai-models.check-connectivity-failed" | translate }}</div>
<tb-json-object-edit class="json-editor"
readonly
[ngModel]="checkErrMsg">
</tb-json-object-edit>
</div>
</div>
</div>
<div mat-dialog-actions class="justify-end">
<button mat-button color="primary"
type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()" cdkFocusInitial>
{{ 'action.cancel' | translate }}
</button>
</div>

68
ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.scss

@ -0,0 +1,68 @@
/**
* 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;
.transparent {
background-color: transparent;
}
.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;
}
}
}
}

89
ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts

@ -0,0 +1,89 @@
///
/// 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, ModelType } 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<CheckConnectivityDialogComponent> {
showCheckSuccess = false;
checkErrMsg = '';
constructor(protected store: Store<AppState>,
protected router: Router,
protected dialogRef: MatDialogRef<CheckConnectivityDialogComponent>,
@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?"
}
]
},
chatModelConfig: {
modelType: ModelType.CHAT,
provider: this.data.AIModel.configuration.provider,
providerConfig: {...this.data.AIModel.configuration.providerConfig},
modelId: this.data.AIModel.configuration.modelId,
maxRetries: 0,
timeoutSeconds: 20
}
}
this.aiModelService.checkConnectivity(aiModelWithMsg, {
ignoreErrors: true,
ignoreLoading: true
}).subscribe({
next: (result) => {
if (result.status === 'SUCCESS') {
this.showCheckSuccess = true;
} else {
try {
this.checkErrMsg = JSON.parse(result.errorDetails);
} catch (e) {
this.checkErrMsg = result.errorDetails;
}
}
},
error: err => this.checkErrMsg = err.error.message
});
}
}
cancel(): void {
this.dialogRef.close(null);
}
}

6
ui-ngx/src/app/modules/home/components/home-components.module.ts

@ -203,6 +203,8 @@ import {
import {
CalculatedFieldTestArgumentsComponent
} from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component';
import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component';
import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialog.component';
@NgModule({
declarations:
@ -354,6 +356,8 @@ import {
CalculatedFieldDebugDialogComponent,
CalculatedFieldScriptTestDialogComponent,
CalculatedFieldTestArgumentsComponent,
CheckConnectivityDialogComponent,
AIModelDialogComponent,
],
imports: [
CommonModule,
@ -499,6 +503,8 @@ import {
CalculatedFieldDebugDialogComponent,
CalculatedFieldScriptTestDialogComponent,
CalculatedFieldTestArgumentsComponent,
CheckConnectivityDialogComponent,
AIModelDialogComponent,
],
providers: [
WidgetComponentService,

43
ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html

@ -16,25 +16,38 @@
-->
<section [formGroup]="timeInputForm" class="flex gap-4">
<mat-form-field class="max-w-66% flex-full" subscriptSizing="dynamic">
<mat-label *ngIf="labelText">{{ labelText }}</mat-label>
<input type="number" min="0" step="1" matInput formControlName="time">
<mat-form-field [class]="{'number': inlineField, 'max-w-66%': !inlineField, 'flex-full': !inlineField}"
[appearance]="inlineField ? 'outline' : appearance"
[subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing">
@if (labelText && !inlineField) {
<mat-label>{{ labelText }}</mat-label>
}
<input type="number" min="0" step="1" matInput formControlName="time" placeholder="{{ 'widget-config.set' | translate }}">
<div matSuffix>
<ng-content select="[matSuffix]"></ng-content>
</div>
<mat-hint *ngIf="subscriptSizing === 'fixed'"></mat-hint>
<mat-error *ngIf="timeInputForm.get('time').hasError('required') && requiredText">
{{ requiredText }}
</mat-error>
<mat-error *ngIf="timeInputForm.get('time').hasError('min') && minErrorText">
{{ minErrorText }}
</mat-error>
<mat-error *ngIf="timeInputForm.get('time').hasError('max') && maxErrorText">
{{ maxErrorText }}
</mat-error>
@if (inlineField) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="hasError"
*ngIf="hasError"
class="tb-error">
warning
</mat-icon>
} @else {
<mat-hint *ngIf="subscriptSizing === 'fixed'"></mat-hint>
<mat-error *ngIf="hasError">
{{ hasError }}
</mat-error>
}
</mat-form-field>
<mat-form-field class="h-fit max-w-33% flex-full" [subscriptSizing]="subscriptSizing">
<mat-label translate>rule-node-config.units</mat-label>
<mat-form-field [class.h-fit.max-w-33%.flex-full]="!inlineField"
[appearance]="inlineField ? 'outline' : appearance"
[subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing">
@if (!inlineField) {
<mat-label translate>rule-node-config.units</mat-label>
}
<mat-select formControlName="timeUnit">
@for (timeUnit of timeUnits; track timeUnit) {
<mat-option [value]="timeUnit">{{ timeUnitTranslations.get(timeUnit) | translate }}</mat-option>

29
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;
@ -79,6 +79,13 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
@Input()
subscriptSizing: SubscriptSizing = 'fixed';
@Input()
appearance: MatFormFieldAppearance = 'fill';
@Input()
@coerceBoolean()
inlineField: boolean;
timeUnits = Object.values(TimeUnit).filter(item => item !== TimeUnit.MILLISECONDS) as TimeUnit[];
timeUnitTranslations = timeUnitTranslations;
@ -104,6 +111,16 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
}
ngOnInit() {
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 (maxTimeMs < HOUR) {
this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.HOURS && item !== TimeUnit.DAYS);
} else if (maxTimeMs < DAY) {
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*$/)];
@ -137,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;
}

133
ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html

@ -0,0 +1,133 @@
<!--
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.
-->
<section [formGroup]="aiConfigForm" class="flex flex-col gap-4">
<section class="tb-form-panel stroked no-padding-bottom">
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'rule-node-config.ai.ai-model-hint' | translate }}">
{{ 'rule-node-config.ai.ai-model' | translate }}
</div>
<section class="flex flex-col">
<tb-entity-autocomplete
allowCreateNew
useFullEntityId
required
appearance="outline"
labelText="ai-models.ai-model"
(entityChanged)="onEntityChange($event)"
[entityType]="entityType.AI_MODEL"
(createNew)="createModelAi('modelId')"
formControlName="modelId">
</tb-entity-autocomplete>
</section>
</section>
<div class="tb-form-panel stroked no-padding no-gap">
<mat-expansion-panel class="tb-settings" expanded>
<mat-expansion-panel-header style="padding: 16px">
<mat-panel-title>
{{'rule-node-config.ai.prompt-settings' | translate}}
</mat-panel-title>
</mat-expansion-panel-header>
<div class="tb-form-panel no-border no-padding-top" style="margin-top: 0">
<tb-example-hint
[hintText]="'rule-node-config.ai.prompt-settings-hint'"
[popupHelpLink]="'rulenode/ai_node_prompt_settings'">
</tb-example-hint>
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.ai.system-prompt</mat-label>
<textarea matInput rows="4" formControlName="systemPrompt"></textarea>
<mat-error *ngIf="aiConfigForm.get('systemPrompt').hasError('maxlength')">
{{ 'rule-node-config.ai.system-prompt-max-length' | translate }}
</mat-error>
<mat-error *ngIf="aiConfigForm.get('systemPrompt').hasError('pattern')">
{{ 'rule-node-config.ai.system-prompt-blank' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.ai.user-prompt</mat-label>
<textarea required matInput rows="4" formControlName="userPrompt"></textarea>
<mat-error *ngIf="aiConfigForm.get('userPrompt').hasError('required')">
{{ 'rule-node-config.ai.user-prompt-required' | translate }}
</mat-error>
<mat-error *ngIf="aiConfigForm.get('userPrompt').hasError('maxlength')">
{{ 'rule-node-config.ai.user-prompt-max-length' | translate }}
</mat-error>
<mat-error *ngIf="aiConfigForm.get('userPrompt').hasError('pattern')">
{{ 'rule-node-config.ai.user-prompt-blank' | translate }}
</mat-error>
</mat-form-field>
</div>
</mat-expansion-panel>
</div>
<div class="tb-form-panel stroked" formGroupName="responseFormat">
<div class="flex flex-row items-center justify-between xs:flex-col xs:items-start xs:gap-3">
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'rule-node-config.ai.response-format-hint' | translate }}">
{{ 'rule-node-config.ai.response-format' | translate }}
</div>
<tb-toggle-select formControlName="type">
<tb-toggle-option [value]="responseFormat.TEXT">{{ 'rule-node-config.ai.response-text' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="responseFormat.JSON">{{ 'rule-node-config.ai.response-json' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="responseFormat.JSON_SCHEMA">{{ 'rule-node-config.ai.response-json-schema' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
@if (aiConfigForm.get('responseFormat.type').value === responseFormat.JSON_SCHEMA) {
<tb-json-object-edit
jsonRequired
label="{{ 'rule-node-config.ai.response-json-schema' | translate }}"
formControlName="schema">
<button mat-icon-button class="tb-mat-32"
toolbarSuffixButton
matTooltip="{{ 'rule-node-config.ai.response-json-schema-hint' | translate }}"
matTooltipPosition="above">
<mat-icon style="color: #7b7b7b">info</mat-icon>
</button>
</tb-json-object-edit>
}
</div>
<div class="tb-form-panel stroked no-padding no-gap">
<mat-expansion-panel class="tb-settings">
<mat-expansion-panel-header style="padding: 16px">
<mat-panel-title translate>rule-node-config.ai.advanced-settings</mat-panel-title>
</mat-expansion-panel-header>
<div class="tb-form-panel no-border no-padding-top" style="margin-top: 0">
<div class="tb-form-row space-between flex-1 columns-xs">
<div translate tb-hint-tooltip-icon="{{'rule-node-config.ai.timeout-hint' | translate}}">{{ 'rule-node-config.ai.timeout' | translate }}</div>
<div class="flex flex-row items-center justify-start gap-2">
<tb-time-unit-input
required
inlineField
requiredText="{{ 'rule-node-config.ai.timeout-required' | translate }}"
minErrorText="{{ 'rule-node-config.ai.timeout-validation' | translate }}"
maxErrorText="{{ 'rule-node-config.ai.timeout-validation' | translate }}"
[maxTime]="600"
[minTime]="1"
formControlName="timeoutSeconds">
</tb-time-unit-input>
</div>
</div>
<div tb-hint-tooltip-icon="{{ 'rule-node-config.ai.force-acknowledgement-hint' | translate }}"
class="tb-form-row same-padding">
<mat-slide-toggle class="mat-slide margin" formControlName="forceAck">
{{ 'rule-node-config.ai.force-acknowledgement' | translate }}
</mat-slide-toggle>
</div>
</div>
</mat-expansion-panel>
</div>
</section>

104
ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts

@ -0,0 +1,104 @@
///
/// 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 '@home/components/ai-model/ai-model-dialog.component';
import { AiModel, AiRuleNodeResponseFormatTypeOnlyText, ResponseFormat } from '@shared/models/ai-model.models';
import { deepTrim } from '@core/utils';
@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({
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({
type: [configuration?.responseFormat?.type ?? ResponseFormat.JSON, []],
schema: [configuration?.responseFormat?.schema ?? null, [Validators.required]],
}),
timeoutSeconds: [configuration?.timeoutSeconds ?? 60, []],
forceAck: [configuration?.forceAck ?? true, []]
});
}
protected validatorTriggers(): string[] {
return ['responseFormat.type'];
}
protected updateValidators(emitEvent: boolean) {
if (this.aiConfigForm.get('responseFormat.type').value === ResponseFormat.JSON_SCHEMA) {
this.aiConfigForm.get('responseFormat.schema').enable({emitEvent: false});
} else {
this.aiConfigForm.get('responseFormat.schema').disable({emitEvent: false});
}
}
protected prepareOutputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration {
return deepTrim(configuration);
}
onEntityChange($event: AiModel) {
if ($event) {
if (AiRuleNodeResponseFormatTypeOnlyText.includes($event.configuration.provider)) {
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, AIModelDialogData, AiModel>(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();
}
});
}
}

7
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 {

4
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<string, Type<any>>(
[
@ -95,6 +96,7 @@ export const ServicesMap = new Map<string, Type<any>>(
['eventService', EventService],
['unitService', UnitService],
['auditLogService', AuditLogService],
['trendzSettingsService', TrendzSettingsService]
['trendzSettingsService', TrendzSettingsService],
['aiModelService', AiModelService]
]
);

2
ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts

@ -47,6 +47,7 @@ import { MenuId } from '@core/services/menu.models';
import { catchError } from 'rxjs/operators';
import { JsLibraryTableConfigResolver } from '@home/pages/admin/resource/js-library-table-config.resolver';
import { TrendzSettingsComponent } from '@home/pages/admin/trendz-settings.component';
import { aiModelRoutes } from '@home/pages/ai-model/ai-model-routing.module';
export const scadaSymbolResolver: ResolveFn<ScadaSymbolData> =
(route: ActivatedRouteSnapshot,
@ -362,6 +363,7 @@ const routes: Routes = [
}
}
},
...aiModelRoutes,
{
path: 'security-settings',
redirectTo: '/security-settings/general'

49
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 { }

127
ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts

@ -0,0 +1,127 @@
///
/// 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 '@home/components/ai-model/ai-model-dialog.component';
import { map } from 'rxjs/operators';
@Injectable()
export class AiModelsTableConfigResolver {
private readonly config: EntityTableConfig<AiModel> = new EntityTableConfig<AiModel>();
constructor(
private datePipe: DatePipe,
private aiModelService: AiModelService,
private translate : TranslateService,
private dialog: MatDialog
) {
this.config.selectionEnabled = true;
this.config.entityType = EntityType.AI_MODEL;
this.config.addAsTextButton = true;
this.config.rowPointer = true;
this.config.detailsPanelEnabled = false;
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'};
this.config.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC};
this.config.addEntity = () => this.addModel(null, true);
this.config.columns.push(
new DateEntityTableColumn<AiModel>('createdTime', 'common.created-time', this.datePipe, '170px'),
new EntityTableColumn<AiModel>('name', 'ai-models.name', '33%'),
new EntityTableColumn<AiModel>('provider', 'ai-models.ai-provider', '33%',
entity => this.translate.instant(AiProviderTranslations.get(entity.configuration.provider))
),
new EntityTableColumn<AiModel>('aiModel', 'ai-models.ai-model', '33%',
entity => entity.configuration.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();
this.config.handleRowClick = ($event, model) => {
this.editModel($event, model);
return true;
};
}
resolve(_route: ActivatedRouteSnapshot): EntityTableConfig<AiModel> {
return this.config;
}
private configureCellActions(): Array<CellActionDescriptor<AiModel>> {
return [
{
name: this.translate.instant('action.edit'),
icon: 'edit',
isEnabled: () => true,
onAction: ($event, entity) => this.editModel($event, entity)
}
];
}
private editModel($event, AIModel: AiModel): void {
$event?.stopPropagation();
this.addModel(AIModel, false).subscribe();
}
private addModel(AIModel: AiModel, isAdd = false): Observable<AiModel> {
return this.dialog.open<AIModelDialogComponent, AIModelDialogData, AiModel>(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;
}
}));
}
}

21
ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.html

@ -0,0 +1,21 @@
<!--
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.
-->
<section class="flex items-center">
<div tbTruncateWithTooltip translate>ai-models.ai-models</div>
<div tb-help="aiModels"></div>
</section>

33
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: []
})
export class AiModelTableHeaderComponent extends EntityTableHeaderComponent<AiModel> {
constructor(protected store: Store<AppState>) {
super(store);
}
}

33
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 { }

4
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 { }

17
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:
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';

1
ui-ngx/src/app/shared/components/json-object-edit.component.html

@ -32,6 +32,7 @@
mat-button *ngIf="!readonly && !disabled" class="tidy" (click)="minifyJSON()">
{{'js-func.mini' | translate }}
</button>
<ng-content select="[toolbarSuffixButton]"></ng-content>
<button mat-icon-button class="tb-mat-32" (click)="fullscreen = !fullscreen"
matTooltip="{{(fullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}"
matTooltipPosition="above">

2
ui-ngx/src/app/shared/components/string-autocomplete.component.html

@ -43,7 +43,7 @@
<mat-autocomplete
#optionsAutocomplete="matAutocomplete"
class="tb-autocomplete tb-options-input-autocomplete"
panelWidth="fit-content">
[panelWidth]="panelWidth">
<mat-option *ngFor="let option of filteredOptions$ | async" [value]="option">
<span class="tb-option-name flex-1" [innerHTML]="option | highlight:searchText:true:'ig'"></span>
</mat-option>

3
ui-ngx/src/app/shared/components/string-autocomplete.component.ts

@ -76,6 +76,9 @@ export class StringAutocompleteComponent implements ControlValueAccessor, OnInit
@Input()
label: string;
@Input()
panelWidth: string = 'fit-content';
@Input()
tooltipClass = 'tb-error-tooltip';

224
ui-ngx/src/app/shared/models/ai-model.models.ts

@ -0,0 +1,224 @@
///
/// 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, 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<BaseData<AiModelId>, 'label'>, HasTenantId, ExportableEntity<AiModelId> {
modelType: ModelType;
configuration: {
provider: AiProvider
providerConfig: {
apiKey?: string;
personalAccessToken?: string;
endpoint?: string;
serviceVersion?: string;
projectId?: string;
location?: string;
serviceAccountKey?: string;
fileName?: string
};
modelId: string;
temperature?: number;
topP?: number;
topK?: number;
frequencyPenalty?: number;
presencePenalty?: number;
maxOutputTokens?: number;
}
}
export enum ModelType {
CHAT = 'CHAT'
}
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 AiProviderTranslations = new Map<AiProvider, string>(
[
[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 ProviderFieldsAllList = [
'apiKey',
'personalAccessToken',
'projectId',
'location',
'serviceAccountKey',
'fileName',
'endpoint',
'serviceVersion'
];
export const ModelFieldsAllList = ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'];
export const AiModelMap = new Map<AiProvider, { modelList: string[], providerFieldsList: string[], modelFieldsList: string[] }>([
[
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: {
contents: Array<{contentType: string; text: string}>;
}
chatModelConfig: {
modelType: string;
provider: AiProvider
providerConfig: {
apiKey?: string;
personalAccessToken?: string;
endpoint?: string;
serviceVersion?: string;
projectId?: string;
location?: string;
serviceAccountKey?: string;
fileName?: string
};
modelId: string;
maxRetries: number;
timeoutSeconds: number;
}
}
export interface CheckConnectivityResult {
status: string;
errorDetails: string;
}

1
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/`
}

19
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,10 +496,15 @@ export const entityTypeTranslations = new Map<EntityType | AliasEntityType, Enti
}
],
[
EntityType.AI_MODEL_SETTINGS,
EntityType.AI_MODEL,
{
type: 'entity.type-ai-model-settings',
typePlural: 'entity.type-ai-model-settings'
type: 'entity.type-ai-model',
typePlural: 'entity.type-ai-models',
list: 'ai-models.list',
add: 'ai-models.add',
noEntities: 'ai-models.no-found',
search: 'action.search',
selectedEntities: 'ai-models.selected-fields'
}
]
]
@ -633,6 +638,12 @@ export const entityTypeResources = new Map<EntityType, EntityTypeResource<BaseDa
{
helpLinkId: 'mobileBundle'
}
],
[
EntityType.AI_MODEL,
{
helpLinkId: 'aiModels'
}
]
]
);

27
ui-ngx/src/app/shared/models/id/ai-model-id.ts

@ -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.
///
import { EntityId } from '@shared/models/id/entity-id';
import { EntityType } from '@shared/models/entity-type.models';
export class AiModelId implements EntityId {
entityType = EntityType.AI_MODEL;
id: string;
constructor(id: string) {
this.id = id;
}
}

1
ui-ngx/src/app/shared/models/id/public-api.ts

@ -44,3 +44,4 @@ export * from './widget-type-id';
export * from './widgets-bundle-id';
export * from './edge-id';
export * from './asset-id';
export * from './ai-model-id';

3
ui-ngx/src/app/shared/models/public-api.ts

@ -63,4 +63,5 @@ export * from './window-message.model';
export * from './usage.models';
export * from './query/query.models';
export * from './regex.constants';
export * from './trendz-settings.models'
export * from './trendz-settings.models';
export * from './ai-model.models';

4
ui-ngx/src/app/shared/models/vc.models.ts

@ -36,7 +36,8 @@ export const exportableEntityTypes: Array<EntityType> = [
EntityType.OTA_PACKAGE,
EntityType.NOTIFICATION_TEMPLATE,
EntityType.NOTIFICATION_TARGET,
EntityType.NOTIFICATION_RULE
EntityType.NOTIFICATION_RULE,
EntityType.AI_MODEL,
];
export const entityTypesWithoutRelatedData = new Set<EntityType | AliasEntityType>([
@ -45,6 +46,7 @@ export const entityTypesWithoutRelatedData = new Set<EntityType | AliasEntityTyp
EntityType.NOTIFICATION_RULE,
EntityType.TB_RESOURCE,
EntityType.OTA_PACKAGE,
EntityType.AI_MODEL,
]);
export interface VersionCreateConfig {

1
ui-ngx/src/assets/help/en_US/rulenode/ai_node_prompt_settings.md

@ -0,0 +1 @@
### Prompt settings

100
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -1089,6 +1089,76 @@
"use-latest-timestamp": "If enabled, the calculated value will be persisted using the most recent timestamp from the arguments telemetry, instead of the server time."
}
},
"ai-models": {
"ai-models": "AI models",
"ai-model": "AI model",
"name": "Name",
"ai-provider": "AI provider",
"no-found": "No AI models found",
"list": "{ count, plural, =1 {One model} other {List of # models} }",
"selected-fields": "{ count, plural, =1 {1 model} other {# models} } selected",
"add": "Add model",
"delete-model-title": "Are you sure you want to delete the model '{{modelName}}'?",
"delete-model-text": "Be careful, after the confirmation the model and all related data will become unrecoverable.",
"delete-models-title": "Are you sure you want to delete { count, plural, =1 {1 model} other {# models} }?",
"delete-models-text": "Be careful, after the confirmation all selected models will be removed and all related data will become unrecoverable.",
"ai-providers": {
"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"
},
"name-required": "Name is required.",
"name-max-length": "Name should be less than 256 characters.",
"provider": "Provider",
"api-key": "API key",
"api-key-required": "API key is required.",
"project-id": "Project ID",
"project-id-required": "Project ID is required",
"location": "Location",
"location-required": "Location is required.",
"service-account-key-file": "Service account key file",
"service-account-key-file-required": "Service account key file is required.",
"no-file": "No file selected.",
"drop-file": "Drop a file or click to select a file to upload.",
"personal-access-token": "Personal access token",
"personal-access-token-required": "Personal access token is required.",
"configuration": "Configuration",
"model-id": "Model ID",
"model-id-required": "Model ID is required.",
"deployment-name": "Deployment name",
"deployment-name-required": "Deployment name is required",
"set": "Set",
"temperature": "Temperature",
"temperature-hint": "Adjusts the level of randomness in the model's output. Higher values increase randomness, while lower values decrease it.",
"temperature-min": "Min value is 0.",
"top-p": "Top P",
"top-p-hint": "Creates a pool of the most probable tokens for the model to choose from. Higher values create a larger and more diverse pool, while lower values create a smaller one.",
"top-p-min-max": "Min value is more than 0, max value is 1.",
"top-k": "Top K",
"top-k-hint": "Restricts the model's choices to a fixed set of the \"K\" most likely tokens.",
"top-k-min": "Min value is 0.",
"presence-penalty": "Presence penalty",
"presence-penalty-hint": "Applies a fixed penalty to the likelihood of a token if it has already appeared in the text.",
"frequency-penalty": "Frequency penalty",
"frequency-penalty-hint": "Applies a penalty to a token's likelihood that increases based on its frequency in the text.",
"max-output-token": "Maximum output token",
"max-output-token-min": "Min value is more than 0.",
"max-output-token-hint": "Sets the maximum number of tokens that the \nmodel can generate in a single response.",
"endpoint": "Endpoint",
"endpoint-required": "Endpoint id required.",
"service-version": "Service version",
"check-connectivity": "Check connectivity",
"check-connectivity-success": "Test request was successful",
"check-connectivity-failed": "Test request failed",
"no-model-matching": "No model matching '{{entity}}' were found.",
"model-required": "AI Model is required.",
"no-model-text": "No model found"
},
"confirm-on-exit": {
"message": "You have unsaved changes. Are you sure you want to leave this page?",
"html-message": "You have unsaved changes.<br/>Are 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} }",
@ -5358,6 +5429,33 @@
"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-hint": "While any valid JSON Schema can be entered, this rule node only supports a limited subset of its features. See node documentation for details.",
"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.",
"force-acknowledgement": "Force acknowledgement",
"force-acknowledgement-hint": "Force acknowledgement"
}
},
"timezone": {

30
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);
}
}
}
}

Loading…
Cancel
Save