Browse Source

UI: AI models

pull/13371/head
Artem Dzhereleiko 1 year ago
parent
commit
6ecb3a49f1
  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. 14
      ui-ngx/src/app/core/services/menu.models.ts
  6. 6
      ui-ngx/src/app/modules/common/modules-map.ts
  7. 91
      ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html
  8. 20
      ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts
  9. 115
      ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html
  10. 115
      ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts
  11. 7
      ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts
  12. 4
      ui-ngx/src/app/modules/home/models/services.map.ts
  13. 49
      ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts
  14. 124
      ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts
  15. 21
      ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.html
  16. 18
      ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss
  17. 33
      ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts
  18. 33
      ui-ngx/src/app/modules/home/pages/ai-model/ai-model.module.ts
  19. 4
      ui-ngx/src/app/modules/home/pages/home-pages.module.ts
  20. 248
      ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html
  21. 23
      ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.scss
  22. 168
      ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts
  23. 58
      ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.html
  24. 71
      ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.scss
  25. 87
      ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts
  26. 41
      ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.html
  27. 161
      ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts
  28. 17
      ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts
  29. 159
      ui-ngx/src/app/shared/models/ai-model.models.ts
  30. 1
      ui-ngx/src/app/shared/models/constants.ts
  31. 15
      ui-ngx/src/app/shared/models/entity-type.models.ts
  32. 27
      ui-ngx/src/app/shared/models/id/ai-model-id.ts
  33. 1
      ui-ngx/src/app/shared/models/id/public-api.ts
  34. 3
      ui-ngx/src/app/shared/models/public-api.ts
  35. 9
      ui-ngx/src/app/shared/shared.module.ts
  36. 97
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  37. 30
      ui-ngx/src/form.scss

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

@ -57,6 +57,7 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie
nodeDescription = "Interact with AI", nodeDescription = "Interact with AI",
nodeDetails = "This node makes requests to AI based on a prompt and a input message and returns a response in a form of output message", nodeDetails = "This node makes requests to AI based on a prompt and a input message and returns a response in a form of output message",
configClazz = TbAiNodeConfiguration.class, configClazz = TbAiNodeConfiguration.class,
configDirective = "tbExternalNodeAiConfig",
ruleChainTypes = RuleChainType.CORE ruleChainTypes = RuleChainType.CORE
) )
public final class TbAiNode extends TbAbstractExternalNode implements TbNode { 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/settings', aiModel, defaultHttpOptionsFromConfig(config));
}
public getAiModels(pageLink: PageLink, config?: RequestConfig): Observable<PageData<AiModel>> {
return this.http.get<PageData<AiModel>>(`/api/ai/model/settings${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config));
}
public getAiModelById(aiModelId: string, config?: RequestConfig): Observable<AiModel> {
return this.http.get<AiModel>(`/api/ai/model/settings/${aiModelId}`, defaultHttpOptionsFromConfig(config));
}
public deleteAiModel(aiModelId: string, config?: RequestConfig) {
return this.http.delete(`/api/ai/model/settings/${aiModelId}`, defaultHttpOptionsFromConfig(config));
}
public checkConnectivity(aiModelWithUserMsg: AiModelWithUserMsg, config?: RequestConfig): Observable<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 { OAuth2Service } from '@core/http/oauth2.service';
import { MobileAppService } from '@core/http/mobile-app.service'; import { MobileAppService } from '@core/http/mobile-app.service';
import { PlatformType } from '@shared/models/oauth2.models'; import { PlatformType } from '@shared/models/oauth2.models';
import { AiModelService } from '@core/http/ai-model.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -131,6 +132,7 @@ export class EntityService {
private resourceService: ResourceService, private resourceService: ResourceService,
private oauth2Service: OAuth2Service, private oauth2Service: OAuth2Service,
private mobileAppService: MobileAppService, private mobileAppService: MobileAppService,
private aiModelService: AiModelService,
) { } ) { }
private getEntityObservable(entityType: EntityType, entityId: string, private getEntityObservable(entityType: EntityType, entityId: string,
@ -183,6 +185,9 @@ export class EntityService {
case EntityType.MOBILE_APP_BUNDLE: case EntityType.MOBILE_APP_BUNDLE:
observable = this.mobileAppService.getMobileAppBundleInfoById(entityId, config); observable = this.mobileAppService.getMobileAppBundleInfoById(entityId, config);
break; break;
case EntityType.AI_MODEL_SETTINGS:
observable = this.aiModelService.getAiModelById(entityId, config);
break;
} }
return observable; return observable;
} }
@ -485,6 +490,10 @@ export class EntityService {
pageLink.sortOrder.property = 'title'; pageLink.sortOrder.property = 'title';
entitiesObservable = this.mobileAppService.getTenantMobileAppBundleInfos(pageLink, config); entitiesObservable = this.mobileAppService.getTenantMobileAppBundleInfos(pageLink, config);
break; break;
case EntityType.AI_MODEL_SETTINGS:
pageLink.sortOrder.property = 'name';
entitiesObservable = this.aiModelService.getAiModels(pageLink, config);
break;
} }
return entitiesObservable; 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 './widget.service';
export * from './usage-info.service'; export * from './usage-info.service';
export * from './trendz-settings.service' export * from './trendz-settings.service'
export * from './ai-model.service'

14
ui-ngx/src/app/core/services/menu.models.ts

@ -105,7 +105,8 @@ export enum MenuId {
otaUpdates = 'otaUpdates', otaUpdates = 'otaUpdates',
version_control = 'version_control', version_control = 'version_control',
api_usage = 'api_usage', api_usage = 'api_usage',
trendz_settings = 'trendz_settings' trendz_settings = 'trendz_settings',
ai_models = 'ai_models'
} }
declare type MenuFilter = (authState: AuthState) => boolean; declare type MenuFilter = (authState: AuthState) => boolean;
@ -286,6 +287,16 @@ export const menuSectionMap = new Map<MenuId, MenuSection>([
icon: 'mdi:message-cog' icon: 'mdi:message-cog'
} }
], ],
[
MenuId.ai_models,
{
id: MenuId.ai_models,
name: 'ai-models.ai-models',
type: 'link',
path: '/ai-models',
icon: 'auto_awesome'
}
],
[ [
MenuId.mobile_center, MenuId.mobile_center,
{ {
@ -841,6 +852,7 @@ const defaultUserMenuMap = new Map<Authority, MenuReference[]>([
{id: MenuId.notification_rules} {id: MenuId.notification_rules}
] ]
}, },
{id: MenuId.ai_models},
{ {
id: MenuId.mobile_center, id: MenuId.mobile_center,
pages: [ pages: [

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

@ -336,6 +336,9 @@ import * as DatapointsLimitComponent from '@shared/components/time/datapoints-li
import * as AggregationTypeSelectComponent from '@shared/components/time/aggregation/aggregation-type-select.component'; import * as 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 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 IntervalOptionsConfigPanelComponent from '@shared/components/time/interval-options-config-panel.component';
import * as AIModelDialogComponent from '@shared/components/ai-model/ai-model-dialog.component';
import * as CheckConnectivityDialogComponent from '@shared/components/ai-model/check-connectivity-dialog.component';
import * as ModelsListAutocompleteComponent from '@shared/components/ai-model/models-list-autocomplete.component';
import { IModulesMap } from '@modules/common/modules-map.models'; import { IModulesMap } from '@modules/common/modules-map.models';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
@ -532,6 +535,9 @@ class ModulesMap implements IModulesMap {
'@shared/components/image/gallery-image-input.component': GalleryImageInputComponent, '@shared/components/image/gallery-image-input.component': GalleryImageInputComponent,
'@shared/components/image/multiple-gallery-image-input.component': MultipleGalleryImageInputComponent, '@shared/components/image/multiple-gallery-image-input.component': MultipleGalleryImageInputComponent,
'@shared/components/popover.service': TbPopoverService, '@shared/components/popover.service': TbPopoverService,
'@shared/components/ai-model/ai-model-dialog.component': AIModelDialogComponent,
'@shared/components/ai-model/check-connectivity-dialog.component': CheckConnectivityDialogComponent,
'@shared/components/ai-model/models-list-autocomplete.component': ModelsListAutocompleteComponent,
'@home/components/alarm/alarm-filter-config.component': AlarmFilterConfigComponent, '@home/components/alarm/alarm-filter-config.component': AlarmFilterConfigComponent,

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

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

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

@ -55,6 +55,9 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
@Input() @Input()
labelText: string; labelText: string;
@Input()
labelIconHintTooltipText: string;
@Input() @Input()
@coerceBoolean() @coerceBoolean()
required: boolean; required: boolean;
@ -79,6 +82,14 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
@Input() @Input()
subscriptSizing: SubscriptSizing = 'fixed'; subscriptSizing: SubscriptSizing = 'fixed';
@Input()
@coerceBoolean()
filterTimeUnitsByMaxTime = false;
@Input()
@coerceBoolean()
outlined = false;
timeUnits = Object.values(TimeUnit).filter(item => item !== TimeUnit.MILLISECONDS) as TimeUnit[]; timeUnits = Object.values(TimeUnit).filter(item => item !== TimeUnit.MILLISECONDS) as TimeUnit[];
timeUnitTranslations = timeUnitTranslations; timeUnitTranslations = timeUnitTranslations;
@ -104,6 +115,15 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
} }
ngOnInit() { ngOnInit() {
if (this.filterTimeUnitsByMaxTime && this.maxTime) {
if (this.maxTime < 60) {
this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.MINUTES && item !== TimeUnit.HOURS && item !== TimeUnit.DAYS);
} else if (this.maxTime < 3600) {
this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.HOURS && item !== TimeUnit.DAYS);
} else if (this.maxTime < 86400) {
this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.DAYS);
}
}
if(this.required || this.maxTime) { if(this.required || this.maxTime) {
const timeControl = this.timeInputForm.get('time'); const timeControl = this.timeInputForm.get('time');
const validators = [Validators.pattern(/^\d*$/)]; const validators = [Validators.pattern(/^\d*$/)];

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

@ -0,0 +1,115 @@
<!--
Copyright © 2016-2025 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<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_SETTINGS"
(createNew)="createModelAi('modelSettingsId')"
formControlName="modelSettingsId">
</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 tb-hint-tooltip-icon="{{ 'rule-node-config.ai.prompt-settings-hint' | translate }}">
{{'rule-node-config.ai.prompt-settings' | translate}}
</mat-panel-title>
</mat-expansion-panel-header>
<div class="tb-form-panel no-border no-padding-bottom no-gap" style="margin-top: 0">
<mat-form-field class="flex" appearance="outline">
<mat-label translate>rule-node-config.ai.system-prompt</mat-label>
<textarea matInput rows="2" 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">
<mat-label translate>rule-node-config.ai.user-prompt</mat-label>
<textarea required matInput rows="2" 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">
</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" style="margin-top: 0">
<tb-time-unit-input
required
outlined
filterTimeUnitsByMaxTime
labelText="{{ 'rule-node-config.ai.timeout' | translate }}"
labelIconHintTooltipText="{{ 'rule-node-config.ai.timeout-hint' | translate }}"
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>
</mat-expansion-panel>
</div>
</section>

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

@ -0,0 +1,115 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models';
import { EntityType } from '@shared/models/entity-type.models';
import { MatDialog } from '@angular/material/dialog';
import { AIModelDialogComponent, AIModelDialogData } from '@shared/components/ai-model/ai-model-dialog.component';
import { AiModel, AiProvider } from '@shared/models/ai-model.models';
import { deepTrim } from '@core/utils';
enum ResponseFormat {
TEXT = 'TEXT',
JSON = 'JSON',
JSON_SCHEMA = 'JSON_SCHEMA'
}
@Component({
selector: 'tb-external-node-ai-config',
templateUrl: './ai-config.component.html',
styleUrls: []
})
export class AiConfigComponent extends RuleNodeConfigurationComponent {
aiConfigForm: UntypedFormGroup;
entityType = EntityType;
responseFormat = ResponseFormat;
constructor(private fb: UntypedFormBuilder,
private dialog: MatDialog) {
super();
}
protected configForm(): UntypedFormGroup {
return this.aiConfigForm;
}
protected onConfigurationSet(configuration: RuleNodeConfiguration) {
this.aiConfigForm = this.fb.group({
modelSettingsId: [configuration ? configuration.modelSettingsId : null, [Validators.required]],
systemPrompt: [configuration ? configuration.systemPrompt : '', [Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]],
userPrompt: [configuration ? configuration.userPrompt : '', [Validators.required, Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]],
responseFormat: this.fb.group({
type: [configuration ? configuration.responseFormat.type : ResponseFormat.JSON, []],
schema: [configuration ? configuration.responseFormat.schema : null, []],
}),
timeoutSeconds: [configuration ? configuration.timeoutSeconds : 60, []]
});
}
protected validatorTriggers(): string[] {
return ['responseFormat.type'];
}
protected updateValidators(emitEvent: boolean) {
const responseFormatType = this.aiConfigForm.get('responseFormat.type').value;
if (responseFormatType === ResponseFormat.JSON_SCHEMA) {
this.aiConfigForm.get('responseFormat.schema').setValidators([Validators.required]);
this.aiConfigForm.get('responseFormat.schema').enable();
} else {
this.aiConfigForm.get('responseFormat.schema').setValidators([]);
this.aiConfigForm.get('responseFormat.schema').disable();
}
this.aiConfigForm.get('responseFormat.schema').updateValueAndValidity({emitEvent});
}
protected prepareOutputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration {
return deepTrim(configuration);
}
onEntityChange($event: AiModel) {
if ($event) {
if ($event.configuration.provider === AiProvider.AMAZON_BEDROCK ||
$event.configuration.provider === AiProvider.ANTHROPIC ||
$event.configuration.provider === AiProvider.GITHUB_MODELS) {
this.aiConfigForm.get('responseFormat.type').patchValue(ResponseFormat.TEXT, {emitEvent: false});
this.aiConfigForm.get('responseFormat.type').disable({emitEvent: false});
}
} else {
this.aiConfigForm.get('responseFormat.type').enable({emitEvent: false});
}
}
createModelAi(formControl: string,) {
this.dialog.open<AIModelDialogComponent, 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 { CommonRuleNodeConfigModule } from '../common/common-rule-node-config.module';
import { SlackConfigComponent } from './slack-config.component'; import { SlackConfigComponent } from './slack-config.component';
import { LambdaConfigComponent } from './lambda-config.component'; import { LambdaConfigComponent } from './lambda-config.component';
import { AiConfigComponent } from '@home/components/rule-node/external/ai-config.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -47,7 +48,8 @@ import { LambdaConfigComponent } from './lambda-config.component';
SendEmailConfigComponent, SendEmailConfigComponent,
AzureIotHubConfigComponent, AzureIotHubConfigComponent,
SendSmsConfigComponent, SendSmsConfigComponent,
SlackConfigComponent SlackConfigComponent,
AiConfigComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -68,7 +70,8 @@ import { LambdaConfigComponent } from './lambda-config.component';
SendEmailConfigComponent, SendEmailConfigComponent,
AzureIotHubConfigComponent, AzureIotHubConfigComponent,
SendSmsConfigComponent, SendSmsConfigComponent,
SlackConfigComponent SlackConfigComponent,
AiConfigComponent
] ]
}) })
export class ExternalRuleNodeConfigModule { 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 { UnitService } from '@core/services/unit.service';
import { AuditLogService } from '@core/http/audit-log.service'; import { AuditLogService } from '@core/http/audit-log.service';
import { TrendzSettingsService } from '@core/http/trendz-settings.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>>( export const ServicesMap = new Map<string, Type<any>>(
[ [
@ -95,6 +96,7 @@ export const ServicesMap = new Map<string, Type<any>>(
['eventService', EventService], ['eventService', EventService],
['unitService', UnitService], ['unitService', UnitService],
['auditLogService', AuditLogService], ['auditLogService', AuditLogService],
['trendzSettingsService', TrendzSettingsService] ['trendzSettingsService', TrendzSettingsService],
['aiModelService', AiModelService]
] ]
); );

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

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

@ -0,0 +1,124 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Injectable } from '@angular/core';
import {
CellActionDescriptor,
DateEntityTableColumn,
EntityTableColumn,
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
import { ActivatedRouteSnapshot } from '@angular/router';
import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models';
import { Direction } from '@shared/models/page/sort-order';
import { DatePipe } from '@angular/common';
import { TranslateService } from '@ngx-translate/core';
import { MatDialog } from '@angular/material/dialog';
import { Observable } from 'rxjs';
import { AiModel, AiProviderTranslations } from '@shared/models/ai-model.models';
import { AiModelService } from '@core/http/ai-model.service';
import { AiModelTableHeaderComponent } from '@home/pages/ai-model/ai-model-table-header.component';
import { AIModelDialogComponent, AIModelDialogData } from '@shared/components/ai-model/ai-model-dialog.component';
import { map } from 'rxjs/operators';
@Injectable()
export class AiModelsTableConfigResolver {
private readonly config: EntityTableConfig<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_SETTINGS;
this.config.addAsTextButton = true;
this.config.detailsPanelEnabled = false;
this.config.entityTranslations = entityTypeTranslations.get(EntityType.AI_MODEL_SETTINGS);
this.config.entityResources = entityTypeResources.get(EntityType.AI_MODEL_SETTINGS);
this.config.headerComponent = AiModelTableHeaderComponent;
this.config.addDialogStyle = {width: '850px', maxHeight: '100vh'};
this.config.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC};
this.config.addEntity = () => this.addModel(null, true);
this.config.columns.push(
new DateEntityTableColumn<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.modelConfig.modelId, () => ({}), false
)
)
this.config.deleteEntityTitle = model => this.translate.instant('ai-models.delete-model-title', {modelName: model.name});
this.config.deleteEntityContent = () => this.translate.instant('ai-models.delete-model-text');
this.config.deleteEntitiesTitle = count => this.translate.instant('ai-models.delete-models-title', {count});
this.config.deleteEntitiesContent = () => this.translate.instant('ai-models.delete-models-text');
this.config.deleteEntity = id => this.aiModelService.deleteAiModel(id.id);
this.config.entitiesFetchFunction = pageLink => this.aiModelService.getAiModels(pageLink);
this.config.cellActionDescriptors = this.configureCellActions();
}
resolve(_route: ActivatedRouteSnapshot): EntityTableConfig<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(entity)
}
];
}
private editModel(AIModel: AiModel): void {
this.addModel(AIModel, false).subscribe((res) => {
if (res) {
this.config.updateData();
}
});
}
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>

18
ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss

@ -0,0 +1,18 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
:host{
width: 100%;
}

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: ['./ai-model-table-header.component.scss']
})
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 { ScadaSymbolModule } from '@home/pages/scada-symbol/scada-symbol.module';
import { GatewaysModule } from '@home/pages/gateways/gateways.module'; import { GatewaysModule } from '@home/pages/gateways/gateways.module';
import { MobileModule } from '@home/pages/mobile/mobile.module'; import { MobileModule } from '@home/pages/mobile/mobile.module';
import { AiModelModule } from '@home/pages/ai-model/ai-model.module';
@NgModule({ @NgModule({
exports: [ exports: [
@ -78,7 +79,8 @@ import { MobileModule } from '@home/pages/mobile/mobile.module';
UserModule, UserModule,
VcModule, VcModule,
AccountModule, AccountModule,
ScadaSymbolModule ScadaSymbolModule,
AiModelModule,
] ]
}) })
export class HomePagesModule { } export class HomePagesModule { }

248
ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html

@ -0,0 +1,248 @@
<!--
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 (provider === aiProvider.GITHUB_MODELS) {
<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>
} @else if (provider === aiProvider.GOOGLE_VERTEX_AI_GEMINI) {
<mat-form-field class="mat-block flex-1" appearance="outline">
<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>
<mat-form-field class="mat-block flex-1" appearance="outline">
<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>
<tb-file-input formControlName="serviceAccountKey"
[existingFileName]="aiModelForms.get('configuration').get('providerConfig').get('serviceAccountKeyFileName').value"
(fileNameChanged)="aiModelForms.get('configuration').get('providerConfig').get('serviceAccountKeyFileName').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>
} @else {
@if (provider === aiProvider.AZURE_OPENAI) {
<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>
<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>
}
<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" formGroupName="modelConfig">
<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-models-list-autocomplete formControlName="modelId"
[provider]="provider"
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">
</tb-models-list-autocomplete>
</section>
<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('modelConfig').get('temperature').hasError('min')"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
</div>
<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('modelConfig').get('topP').hasError('min') ||
aiModelForms.get('configuration').get('modelConfig').get('topP').hasError('max')"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
</div>
@if (provider === aiProvider.GOOGLE_AI_GEMINI || provider === aiProvider.GOOGLE_VERTEX_AI_GEMINI || provider === aiProvider.ANTHROPIC) {
<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('modelConfig').get('topK').hasError('min')"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
</div>
}
@if (provider !== aiProvider.ANTHROPIC && provider !== aiProvider.AMAZON_BEDROCK) {
<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="frequencyPenalty"
type="number" step="1" placeholder="{{ 'ai-models.set' | translate }}">
</mat-form-field>
</div>
<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="presencePenalty"
type="number" step="1" placeholder="{{ 'ai-models.set' | translate }}">
</mat-form-field>
</div>
}
<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('modelConfig').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/shared/components/ai-model/ai-model-dialog.component.scss

@ -0,0 +1,23 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
:host {
width: 850px;
height: 100%;
max-width: 100%;
max-height: 100vh;
display: grid;
}

168
ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts

@ -0,0 +1,168 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, Inject } from '@angular/core';
import { DialogComponent } from '@shared/components/dialog.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog';
import { Observable } from 'rxjs';
import { StepperOrientation } from '@angular/cdk/stepper';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { EntityType } from '@shared/models/entity-type.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AiModel, AiProvider, AiProviderTranslations, AiProviderWithApiKey } from '@shared/models/ai-model.models';
import { AiModelService } from '@core/http/ai-model.service';
import { CheckConnectivityDialogComponent } from '@shared/components/ai-model/check-connectivity-dialog.component';
export interface AIModelDialogData {
AIModel?: AiModel;
isAdd?: boolean;
}
@Component({
selector: 'tb-ai-model-dialog',
templateUrl: './ai-model-dialog.component.html',
styleUrls: ['./ai-model-dialog.component.scss']
})
export class AIModelDialogComponent extends DialogComponent<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;
aiProviderWithApiKey: AiProvider[] = AiProviderWithApiKey;
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: ['CHAT'],
configuration: this.fb.group({
provider: [this.provider, [Validators.required]],
providerConfig: this.fb.group({
apiKey: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.apiKey : '', [Validators.required]],
personalAccessToken: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.personalAccessToken : '', [Validators.required]],
endpoint: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.endpoint : '', [Validators.required]],
serviceVersion: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.serviceVersion : ''],
projectId: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.projectId : '', [Validators.required]],
location: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.location : '', [Validators.required]],
serviceAccountKey: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.serviceAccountKey : '', [Validators.required]],
serviceAccountKeyFileName: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.serviceAccountKeyFileName : '', [Validators.required]],
}),
modelConfig: this.fb.group({
modelId: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.modelId : '', [Validators.required]],
temperature: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.temperature : null, [Validators.min(0)]],
topP: this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.topP : [null, [Validators.min(0.1), Validators.max(1)]],
topK: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.topK : null, [Validators.min(0)]],
frequencyPenalty: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.frequencyPenalty : null],
presencePenalty: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.presencePenalty : null],
maxOutputTokens: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.maxOutputTokens : null, [Validators.min(1)]]
})
})
});
this.aiModelForms.get('configuration.provider').valueChanges.pipe(
takeUntilDestroyed()
).subscribe((provider: AiProvider) => {
this.provider = provider;
this.aiModelForms.get('configuration.modelConfig').reset({});
this.aiModelForms.get('configuration.providerConfig').reset({});
this.updateValidation(provider);
})
this.updateValidation(this.provider);
}
private updateValidation(provider: AiProvider) {
const providerConfig = this.aiModelForms.get('configuration.providerConfig');
if (this.aiProviderWithApiKey.includes(provider)) {
providerConfig.get('apiKey').enable();
} else {
providerConfig.get('apiKey').disable();
}
if (provider === AiProvider.GITHUB_MODELS) {
providerConfig.get('personalAccessToken').enable();
} else {
providerConfig.get('personalAccessToken').disable();
}
if (provider === AiProvider.GOOGLE_VERTEX_AI_GEMINI) {
providerConfig.get('projectId').enable();
providerConfig.get('location').enable();
providerConfig.get('serviceAccountKey').enable();
providerConfig.get('serviceAccountKeyFileName').enable();
} else {
providerConfig.get('projectId').disable();
providerConfig.get('location').disable();
providerConfig.get('serviceAccountKey').disable();
providerConfig.get('serviceAccountKeyFileName').disable();
}
if (provider === AiProvider.AZURE_OPENAI) {
providerConfig.get('endpoint').enable();
providerConfig.get('serviceVersion').enable();
} else {
providerConfig.get('endpoint').disable();
providerConfig.get('serviceVersion').disable();
}
}
cancel(): void {
this.dialogRef.close(null);
}
checkConnectivity() {
return this.dialog.open<CheckConnectivityDialogComponent, 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));
}
}

58
ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.html

@ -0,0 +1,58 @@
<!--
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.
-->
<div class="flex h-6 flex-row">
<h2 class="connectivity-title" 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>
</div>
<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>
<span class="flex-1"></span>
<button mat-button color="primary"
type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()" cdkFocusInitial>
{{ 'action.cancel' | translate }}
</button>
</div>

71
ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.scss

@ -0,0 +1,71 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
:host {
width: 560px;
height: 100%;
max-width: 100%;
max-height: 100vh;
display: grid;
.connectivity-title {
font-size: 18px;
font-weight: 500;
margin: 0;
padding-left: 16px;
}
.connection-status {
font-weight: 500;
letter-spacing: 0.25px;
text-align: center;
}
.connection-icon {
height: 32px;
font-size: 32px;
width: 32px;
margin-bottom: 4px;
}
.error_msg {
text-align: center;
margin-top: 8px;
font-size: 14px;
line-height: 130%;
letter-spacing: 0.25px;
opacity: 0.9;
}
.success {
color: #198038;
}
.error {
color: #D12730;
}
::ng-deep {
.json-editor {
.tb-json-object-toolbar {
display: none;
}
.tb-json-panel {
margin: 0;
}
}
}
}

87
ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts

@ -0,0 +1,87 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, Inject } from '@angular/core';
import { DialogComponent } from '@shared/components/dialog.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { AiModel, AiModelWithUserMsg } from '@shared/models/ai-model.models';
import { AiModelService } from '@core/http/ai-model.service';
export interface AIModelDialogData {
AIModel?: AiModel;
}
@Component({
selector: 'tb-check-connectivity-dialog',
templateUrl: './check-connectivity-dialog.component.html',
styleUrls: ['./check-connectivity-dialog.component.scss']
})
export class CheckConnectivityDialogComponent extends DialogComponent<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?"
}
]
},
chatModel: {
modelType: "CHAT",
provider: this.data.AIModel.configuration.provider,
providerConfig: {...this.data.AIModel.configuration.providerConfig},
modelConfig: {
modelId: this.data.AIModel.configuration.modelConfig.modelId,
maxRetries: 0,
timeoutSeconds: 20
}
}
}
this.aiModelService.checkConnectivity(aiModelWithMsg, {
ignoreErrors: true,
ignoreLoading: true
}).subscribe({
next: (result) => {
if (result.status === 'SUCCESS') {
this.showCheckSuccess = true;
} else {
this.checkErrMsg = JSON.parse(result.errorDetails);
}
},
error: err => this.checkErrMsg = err.error.message
});
}
}
cancel(): void {
this.dialogRef.close(null);
}
}

41
ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.html

@ -0,0 +1,41 @@
<!--
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-form-field [appearance]="appearance" [subscriptSizing]="subscriptSizing" style="width: 100%">
<mat-label *ngIf="label">{{label}}</mat-label>
<input matInput #nameInput [formControl]="selectionFormControl"
[placeholder]="placeholderText"
(focusin)="onFocus()"
[matAutocomplete]="optionsAutocomplete">
<button *ngIf="selectionFormControl.value && !disabled"
type="button"
class="tb-icon-24 mr-2"
matSuffix mat-icon-button aria-label="Clear"
(click)="clear()">
<mat-icon class="material-icons">close</mat-icon>
</button>
<mat-error *ngIf="selectionFormControl.hasError('required')">
{{errorText}}
</mat-error>
<mat-autocomplete
#optionsAutocomplete="matAutocomplete"
class="tb-autocomplete tb-options-input-autocomplete">
<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>
</mat-autocomplete>
</mat-form-field>

161
ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts

@ -0,0 +1,161 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, ElementRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core';
import { ControlValueAccessor, FormBuilder, FormControl, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
import { Observable } from 'rxjs';
import { map, startWith, tap } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
import { coerceBoolean } from '@shared/decorators/coercion';
import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field';
import { AiModelMap, AiProvider } from '@shared/models/ai-model.models';
@Component({
selector: 'tb-models-list-autocomplete',
templateUrl: './models-list-autocomplete.component.html',
styleUrls: [],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ModelsListAutocompleteComponent),
multi: true
}
]
})
export class ModelsListAutocompleteComponent implements ControlValueAccessor, OnInit, OnChanges {
@ViewChild('nameInput', {static: true}) nameInput: ElementRef;
@Input()
disabled: boolean;
@Input()
@coerceBoolean()
required = false;
@Input()
provider: AiProvider;
@Input()
placeholderText: string = this.translate.instant('widget-config.set');
@Input()
subscriptSizing: SubscriptSizing = 'dynamic';
@Input()
appearance: MatFormFieldAppearance = 'outline';
@Input()
label: string;
@Input()
errorText: string;
selectionFormControl: FormControl;
modelValue: string | null;
filteredOptions$: Observable<Array<string>>;
searchText = '';
private dirty = false;
private propagateChange = (_val: any) => {};
constructor(private fb: FormBuilder,
private translate: TranslateService) {
}
ngOnInit() {
this.selectionFormControl = this.fb.control('', this.required ? [Validators.required] : []);
this.setupFilteredOptions();
}
ngOnChanges(changes: SimpleChanges) {
if (changes.provider && !changes.provider.isFirstChange()) {
this.setupFilteredOptions();
this.selectionFormControl.setValue(null, {emitEvent: false});
this.modelValue = null;
this.propagateChange(null);
}
}
private setupFilteredOptions() {
this.filteredOptions$ = this.selectionFormControl.valueChanges.pipe(
startWith(''),
tap(value => this.updateView(value)),
map(value => {
const search = value ? value.toLowerCase() : '';
const options = this.provider ? AiModelMap.get(this.provider) || [] : [];
return search ? options.filter(option => option.toLowerCase().includes(search)) : options;
})
);
}
writeValue(option?: string): void {
this.searchText = '';
this.modelValue = option ? option : null;
if (option) {
this.selectionFormControl.patchValue(option, { emitEvent: false });
this.dirty = true;
} else {
this.selectionFormControl.patchValue(null, { emitEvent: false });
this.dirty = true;
}
}
onFocus() {
if (this.dirty) {
this.selectionFormControl.updateValueAndValidity({onlySelf: true, emitEvent: true});
this.dirty = false;
}
}
updateView(value: string) {
this.searchText = value ? value : '';
if (this.modelValue !== value && value) {
this.modelValue = value;
this.propagateChange(this.modelValue);
}
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) {
this.selectionFormControl.disable({emitEvent: false});
} else {
this.selectionFormControl.enable({emitEvent: false});
}
}
clear() {
this.selectionFormControl.patchValue(null, {emitEvent: false});
this.propagateChange(null);
this.modelValue = null;
setTimeout(() => {
this.nameInput.nativeElement.blur();
this.nameInput.nativeElement.focus();
}, 0);
}
}

17
ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts

@ -14,16 +14,7 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { import { Component, ElementRef, EventEmitter, forwardRef, Input, OnInit, Output, ViewChild } from '@angular/core';
Component,
ElementRef,
EventEmitter,
forwardRef,
Input,
OnInit,
Output,
ViewChild
} from '@angular/core';
import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field';
import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { firstValueFrom, merge, Observable, of, Subject } from 'rxjs'; 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.entityRequiredText = 'notification.notification-recipient-required';
this.notFoundEntities = 'notification.no-recipients-text'; this.notFoundEntities = 'notification.no-recipients-text';
break; break;
case EntityType.AI_MODEL_SETTINGS:
this.entityText = 'ai-models.ai-model';
this.noEntitiesMatchingText = 'ai-models.no-model-matching';
this.entityRequiredText = 'ai-models.model-required';
this.notFoundEntities = 'ai-models.no-model-text';
break;
case AliasEntityType.CURRENT_CUSTOMER: case AliasEntityType.CURRENT_CUSTOMER:
this.entityText = 'customer.default-customer'; this.entityText = 'customer.default-customer';
this.noEntitiesMatchingText = 'customer.no-customers-matching'; this.noEntitiesMatchingText = 'customer.no-customers-matching';

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

@ -0,0 +1,159 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { BaseData } from '@shared/models/base-data';
import { HasTenantId } from '@shared/models/entity.models';
import { AiModelId } from '@shared/models/id/ai-model-id';
export interface AiModel extends Omit<BaseData<AiModelId>, 'label'>, HasTenantId {
modelType: string;
configuration: {
provider: AiProvider
providerConfig: {
apiKey?: string;
personalAccessToken?: string;
endpoint?: string;
serviceVersion?: string;
projectId?: string;
location?: string;
serviceAccountKey?: string;
serviceAccountKeyFileName?: string
};
modelConfig: {
modelId: string;
temperature?: number | null;
topP?: number;
topK?: number;
frequencyPenalty?: number;
presencePenalty?: number;
maxOutputTokens?: number;
}
}
}
export enum AiProvider {
OPENAI = 'OPENAI',
AZURE_OPENAI = 'AZURE_OPENAI',
GOOGLE_AI_GEMINI = 'GOOGLE_AI_GEMINI',
GOOGLE_VERTEX_AI_GEMINI = 'GOOGLE_VERTEX_AI_GEMINI',
MISTRAL_AI = 'MISTRAL_AI',
ANTHROPIC = 'ANTHROPIC',
AMAZON_BEDROCK = 'AMAZON_BEDROCK',
GITHUB_MODELS = 'GITHUB_MODELS'
}
export const AiProviderWithApiKey: AiProvider[] = [
AiProvider.OPENAI,
AiProvider.AZURE_OPENAI,
AiProvider.GOOGLE_AI_GEMINI,
AiProvider.MISTRAL_AI,
AiProvider.ANTHROPIC,
AiProvider.AMAZON_BEDROCK
]
export const AiProviderTranslations = new Map<AiProvider, 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 AiModelMap = new Map<AiProvider, string[]>(
[
[AiProvider.OPENAI , [
'o4-mini',
'o3-pro',
'o3',
'o3-mini',
'o1',
'gpt-4.1',
'gpt-4.1-mini',
'gpt-4.1-nano',
'gpt-4o',
'gpt-4o-mini'
]],
[AiProvider.AZURE_OPENAI , []],
[AiProvider.GOOGLE_AI_GEMINI , [
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.0-flash',
'gemini-2.0-flash-lite',
]],
[AiProvider.GOOGLE_VERTEX_AI_GEMINI , [
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.0-flash',
'gemini-2.0-flash-lite',
]],
[AiProvider.MISTRAL_AI , [
'magistral-medium-latest',
'magistral-small-latest',
'mistral-large-latest',
'mistral-medium-latest',
'mistral-small-latest',
'pixtral-large-latest',
'ministral-8b-latest',
'ministral-3b-latest',
'open-mistral-nemo'
]],
[AiProvider.ANTHROPIC , [
'claude-opus-4-0',
'claude-sonnet-4-0',
'claude-3-7-sonnet-latest',
'claude-3-5-sonnet-latest',
'claude-3-5-haiku-latest'
]],
[AiProvider.AMAZON_BEDROCK , []],
[AiProvider.GITHUB_MODELS , []]
]
);
export interface AiModelWithUserMsg {
userMessage: {
contents: Array<{contentType: string; text: string}>;
}
chatModel: {
modelType: string;
provider: AiProvider
providerConfig: {
apiKey?: string;
personalAccessToken?: string;
endpoint?: string;
serviceVersion?: string;
projectId?: string;
location?: string;
serviceAccountKey?: string;
serviceAccountKeyFileName?: string
};
modelConfig: {
modelId: string;
maxRetries: number;
timeoutSeconds: number;
}
}
}
export interface CheckConnectivityResult {
status: string;
errorDetails: string;
}

1
ui-ngx/src/app/shared/models/constants.ts

@ -200,6 +200,7 @@ export const HelpLinks = {
mobileBundle: `${helpBaseUrl}/docs${docPlatformPrefix}/mobile-center/mobile-center/`, mobileBundle: `${helpBaseUrl}/docs${docPlatformPrefix}/mobile-center/mobile-center/`,
mobileQrCode: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/mobile-qr-code/`, mobileQrCode: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/mobile-qr-code/`,
calculatedField: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/calculated-fields/`, calculatedField: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/calculated-fields/`,
aiModels: `${helpBaseUrl}/docs${docPlatformPrefix}/ai-models`,
timewindowSettings: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/dashboards/#time-window`, timewindowSettings: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/dashboards/#time-window`,
trendzSettings: `${helpBaseUrl}/docs/trendz/` trendzSettings: `${helpBaseUrl}/docs/trendz/`
} }

15
ui-ngx/src/app/shared/models/entity-type.models.ts

@ -496,8 +496,13 @@ export const entityTypeTranslations = new Map<EntityType | AliasEntityType, Enti
[ [
EntityType.AI_MODEL_SETTINGS, EntityType.AI_MODEL_SETTINGS,
{ {
type: 'entity.type-ai-model-settings', type: 'entity.type-ai-model',
typePlural: 'entity.type-ai-model-settings' 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'
} }
] ]
] ]
@ -631,6 +636,12 @@ export const entityTypeResources = new Map<EntityType, EntityTypeResource<BaseDa
{ {
helpLinkId: 'mobileBundle' helpLinkId: 'mobileBundle'
} }
],
[
EntityType.AI_MODEL_SETTINGS,
{
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_SETTINGS;
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 './widgets-bundle-id';
export * from './edge-id'; export * from './edge-id';
export * from './asset-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 './usage.models';
export * from './query/query.models'; export * from './query/query.models';
export * from './regex.constants'; export * from './regex.constants';
export * from './trendz-settings.models' export * from './trendz-settings.models';
export * from './ai-model.models';

9
ui-ngx/src/app/shared/shared.module.ts

@ -228,6 +228,9 @@ import { JsFuncModuleRowComponent } from '@shared/components/js-func-module-row.
import { EntityKeyAutocompleteComponent } from '@shared/components/entity/entity-key-autocomplete.component'; import { EntityKeyAutocompleteComponent } from '@shared/components/entity/entity-key-autocomplete.component';
import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe'; import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe';
import { MqttVersionSelectComponent } from '@shared/components/mqtt-version-select.component'; import { MqttVersionSelectComponent } from '@shared/components/mqtt-version-select.component';
import { AIModelDialogComponent } from '@shared/components/ai-model/ai-model-dialog.component';
import { ModelsListAutocompleteComponent } from '@shared/components/ai-model/models-list-autocomplete.component';
import { CheckConnectivityDialogComponent } from '@shared/components/ai-model/check-connectivity-dialog.component';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService; return markedOptionsService;
@ -443,6 +446,9 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ScadaSymbolInputComponent, ScadaSymbolInputComponent,
EntityKeyAutocompleteComponent, EntityKeyAutocompleteComponent,
MqttVersionSelectComponent, MqttVersionSelectComponent,
AIModelDialogComponent,
ModelsListAutocompleteComponent,
CheckConnectivityDialogComponent,
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -707,6 +713,9 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ScadaSymbolInputComponent, ScadaSymbolInputComponent,
EntityKeyAutocompleteComponent, EntityKeyAutocompleteComponent,
MqttVersionSelectComponent, MqttVersionSelectComponent,
AIModelDialogComponent,
ModelsListAutocompleteComponent,
CheckConnectivityDialogComponent,
] ]
}) })
export class SharedModule { } export class SharedModule { }

97
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." "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": { "confirm-on-exit": {
"message": "You have unsaved changes. Are you sure you want to leave this page?", "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?", "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-current-user-owner": "Current User Owner",
"type-calculated-field": "Calculated field", "type-calculated-field": "Calculated field",
"type-calculated-fields": "Calculated fields", "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-bundle": "Widgets bundle",
"type-widgets-bundles": "Widgets bundles", "type-widgets-bundles": "Widgets bundles",
"list-of-widgets-bundles": "{ count, plural, =1 {One widgets bundle} other {List of # widget bundles} }", "list-of-widgets-bundles": "{ count, plural, =1 {One widgets bundle} other {List of # widget bundles} }",
@ -5356,6 +5427,30 @@
"html-text-description": "Allows you to use HTML tags for formatting, links and images in your mai body.", "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.", "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." "after-template-evaluation-hint": "After template evaluation value should be true for HTML, and false for Plain text."
},
"ai": {
"ai-model": "AI model",
"ai-model-hint": "Select the pre-configured AI model to process requests sent by this rule node, or use \"Create new\" to configure a new one.",
"prompt-settings": "Prompt settings",
"prompt-settings-hint": "Configure the instructions for the AI. The optional system prompt sets the AI's general role and constraints, while the user prompt defines the specific task to perform. Both fields also support rule node templates to include dynamic data (e.g., use $[*] to access the entire message body or ${*} to access all metadata).",
"system-prompt": "System prompt",
"system-prompt-max-length": "System prompt must be 10000 characters or less.",
"system-prompt-blank": "System prompt must not be blank.",
"user-prompt": "User prompt",
"user-prompt-required": "User prompt is required.",
"user-prompt-max-length": "User prompt must be 10000 characters or less.",
"user-prompt-blank": "User prompt must not be blank.",
"response-format": "Response format",
"response-format-hint": "The model is required to generate a JSON that matches the specific structure and data types defined in the provided schema. If the output is not a valid JSON object, it will be automatically wrapped within a JSON object under the \"response\" key.",
"response-text": "Text",
"response-json": "JSON",
"response-json-schema": "JSON Schema",
"response-json-schema-required": "JSON Schema is required",
"advanced-settings": "Advanced settings",
"timeout": "Timeout",
"timeout-hint": "Maximum time to wait for a response \nfrom the AI model before the request is terminated.",
"timeout-required": "Timeout is required",
"timeout-validation": "Must be from 1 second to 10 minutes."
} }
}, },
"timezone": { "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