From 6ecb3a49f16db626b391432408f3833f26b16117 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Tue, 8 Jul 2025 12:53:27 +0300 Subject: [PATCH 1/8] UI: AI models --- .../thingsboard/rule/engine/ai/TbAiNode.java | 1 + ui-ngx/src/app/core/http/ai-model.service.ts | 54 ++++ ui-ngx/src/app/core/http/entity.service.ts | 9 + ui-ngx/src/app/core/http/public-api.ts | 1 + ui-ngx/src/app/core/services/menu.models.ts | 14 +- ui-ngx/src/app/modules/common/modules-map.ts | 6 + .../common/time-unit-input.component.html | 91 +++++-- .../common/time-unit-input.component.ts | 20 ++ .../external/ai-config.component.html | 115 ++++++++ .../rule-node/external/ai-config.component.ts | 115 ++++++++ .../external-rule-node-config.module.ts | 7 +- .../app/modules/home/models/services.map.ts | 4 +- .../pages/ai-model/ai-model-routing.module.ts | 49 ++++ .../ai-model/ai-model-table-config.resolve.ts | 124 +++++++++ .../ai-model-table-header.component.html | 21 ++ .../ai-model-table-header.component.scss | 18 ++ .../ai-model-table-header.component.ts | 33 +++ .../home/pages/ai-model/ai-model.module.ts | 33 +++ .../modules/home/pages/home-pages.module.ts | 4 +- .../ai-model/ai-model-dialog.component.html | 248 ++++++++++++++++++ .../ai-model/ai-model-dialog.component.scss | 23 ++ .../ai-model/ai-model-dialog.component.ts | 168 ++++++++++++ .../check-connectivity-dialog.component.html | 58 ++++ .../check-connectivity-dialog.component.scss | 71 +++++ .../check-connectivity-dialog.component.ts | 87 ++++++ .../models-list-autocomplete.component.html | 41 +++ .../models-list-autocomplete.component.ts | 161 ++++++++++++ .../entity/entity-autocomplete.component.ts | 17 +- .../src/app/shared/models/ai-model.models.ts | 159 +++++++++++ ui-ngx/src/app/shared/models/constants.ts | 1 + .../app/shared/models/entity-type.models.ts | 15 +- .../src/app/shared/models/id/ai-model-id.ts | 27 ++ ui-ngx/src/app/shared/models/id/public-api.ts | 1 + ui-ngx/src/app/shared/models/public-api.ts | 3 +- ui-ngx/src/app/shared/shared.module.ts | 9 + .../assets/locale/locale.constant-en_US.json | 97 ++++++- ui-ngx/src/form.scss | 30 +++ 37 files changed, 1892 insertions(+), 43 deletions(-) create mode 100644 ui-ngx/src/app/core/http/ai-model.service.ts create mode 100644 ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html create mode 100644 ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts create mode 100644 ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/ai-model/ai-model.module.ts create mode 100644 ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html create mode 100644 ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.scss create mode 100644 ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts create mode 100644 ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.html create mode 100644 ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.scss create mode 100644 ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts create mode 100644 ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.html create mode 100644 ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts create mode 100644 ui-ngx/src/app/shared/models/ai-model.models.ts create mode 100644 ui-ngx/src/app/shared/models/id/ai-model-id.ts diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java index 62d92ef2ce..8c9017829a 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java @@ -57,6 +57,7 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie nodeDescription = "Interact with AI", nodeDetails = "This node makes requests to AI based on a prompt and a input message and returns a response in a form of output message", configClazz = TbAiNodeConfiguration.class, + configDirective = "tbExternalNodeAiConfig", ruleChainTypes = RuleChainType.CORE ) public final class TbAiNode extends TbAbstractExternalNode implements TbNode { diff --git a/ui-ngx/src/app/core/http/ai-model.service.ts b/ui-ngx/src/app/core/http/ai-model.service.ts new file mode 100644 index 0000000000..b2f63ba583 --- /dev/null +++ b/ui-ngx/src/app/core/http/ai-model.service.ts @@ -0,0 +1,54 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; +import { Observable } from 'rxjs'; +import { AiModel, AiModelWithUserMsg, CheckConnectivityResult } from '@shared/models/ai-model.models'; +import { PageLink } from '@shared/models/page/page-link'; +import { PageData } from '@shared/models/page/page-data'; + +@Injectable({ + providedIn: 'root' +}) +export class AiModelService { + + constructor( + private http: HttpClient + ) {} + + public saveAiModel(aiModel: AiModel, config?: RequestConfig): Observable { + return this.http.post('/api/ai/model/settings', aiModel, defaultHttpOptionsFromConfig(config)); + } + + public getAiModels(pageLink: PageLink, config?: RequestConfig): Observable> { + return this.http.get>(`/api/ai/model/settings${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + } + + public getAiModelById(aiModelId: string, config?: RequestConfig): Observable { + return this.http.get(`/api/ai/model/settings/${aiModelId}`, defaultHttpOptionsFromConfig(config)); + } + + public deleteAiModel(aiModelId: string, config?: RequestConfig) { + return this.http.delete(`/api/ai/model/settings/${aiModelId}`, defaultHttpOptionsFromConfig(config)); + } + + public checkConnectivity(aiModelWithUserMsg: AiModelWithUserMsg, config?: RequestConfig): Observable { + return this.http.post('/api/ai/model/chat', aiModelWithUserMsg, defaultHttpOptionsFromConfig(config)); + } + +} diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 01fed28bf3..17a755ad30 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -99,6 +99,7 @@ import { ResourceService } from '@core/http/resource.service'; import { OAuth2Service } from '@core/http/oauth2.service'; import { MobileAppService } from '@core/http/mobile-app.service'; import { PlatformType } from '@shared/models/oauth2.models'; +import { AiModelService } from '@core/http/ai-model.service'; @Injectable({ providedIn: 'root' @@ -131,6 +132,7 @@ export class EntityService { private resourceService: ResourceService, private oauth2Service: OAuth2Service, private mobileAppService: MobileAppService, + private aiModelService: AiModelService, ) { } private getEntityObservable(entityType: EntityType, entityId: string, @@ -183,6 +185,9 @@ export class EntityService { case EntityType.MOBILE_APP_BUNDLE: observable = this.mobileAppService.getMobileAppBundleInfoById(entityId, config); break; + case EntityType.AI_MODEL_SETTINGS: + observable = this.aiModelService.getAiModelById(entityId, config); + break; } return observable; } @@ -485,6 +490,10 @@ export class EntityService { pageLink.sortOrder.property = 'title'; entitiesObservable = this.mobileAppService.getTenantMobileAppBundleInfos(pageLink, config); break; + case EntityType.AI_MODEL_SETTINGS: + pageLink.sortOrder.property = 'name'; + entitiesObservable = this.aiModelService.getAiModels(pageLink, config); + break; } return entitiesObservable; } diff --git a/ui-ngx/src/app/core/http/public-api.ts b/ui-ngx/src/app/core/http/public-api.ts index c28d80d173..63cc393ce6 100644 --- a/ui-ngx/src/app/core/http/public-api.ts +++ b/ui-ngx/src/app/core/http/public-api.ts @@ -48,3 +48,4 @@ export * from './user-settings.service'; export * from './widget.service'; export * from './usage-info.service'; export * from './trendz-settings.service' +export * from './ai-model.service' diff --git a/ui-ngx/src/app/core/services/menu.models.ts b/ui-ngx/src/app/core/services/menu.models.ts index 607c5c6dff..dc568c148e 100644 --- a/ui-ngx/src/app/core/services/menu.models.ts +++ b/ui-ngx/src/app/core/services/menu.models.ts @@ -105,7 +105,8 @@ export enum MenuId { otaUpdates = 'otaUpdates', version_control = 'version_control', api_usage = 'api_usage', - trendz_settings = 'trendz_settings' + trendz_settings = 'trendz_settings', + ai_models = 'ai_models' } declare type MenuFilter = (authState: AuthState) => boolean; @@ -286,6 +287,16 @@ export const menuSectionMap = new Map([ icon: 'mdi:message-cog' } ], + [ + MenuId.ai_models, + { + id: MenuId.ai_models, + name: 'ai-models.ai-models', + type: 'link', + path: '/ai-models', + icon: 'auto_awesome' + } + ], [ MenuId.mobile_center, { @@ -841,6 +852,7 @@ const defaultUserMenuMap = new Map([ {id: MenuId.notification_rules} ] }, + {id: MenuId.ai_models}, { id: MenuId.mobile_center, pages: [ diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index 70432e8cdf..997af07462 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -336,6 +336,9 @@ import * as DatapointsLimitComponent from '@shared/components/time/datapoints-li import * as AggregationTypeSelectComponent from '@shared/components/time/aggregation/aggregation-type-select.component'; import * as AggregationOptionsConfigComponent from '@shared/components/time/aggregation/aggregation-options-config-panel.component'; import * as IntervalOptionsConfigPanelComponent from '@shared/components/time/interval-options-config-panel.component'; +import * as AIModelDialogComponent from '@shared/components/ai-model/ai-model-dialog.component'; +import * as CheckConnectivityDialogComponent from '@shared/components/ai-model/check-connectivity-dialog.component'; +import * as ModelsListAutocompleteComponent from '@shared/components/ai-model/models-list-autocomplete.component'; import { IModulesMap } from '@modules/common/modules-map.models'; import { Observable, of } from 'rxjs'; @@ -532,6 +535,9 @@ class ModulesMap implements IModulesMap { '@shared/components/image/gallery-image-input.component': GalleryImageInputComponent, '@shared/components/image/multiple-gallery-image-input.component': MultipleGalleryImageInputComponent, '@shared/components/popover.service': TbPopoverService, + '@shared/components/ai-model/ai-model-dialog.component': AIModelDialogComponent, + '@shared/components/ai-model/check-connectivity-dialog.component': CheckConnectivityDialogComponent, + '@shared/components/ai-model/models-list-autocomplete.component': ModelsListAutocompleteComponent, '@home/components/alarm/alarm-filter-config.component': AlarmFilterConfigComponent, diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html index 37b0a2983c..3c1d85ed73 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html @@ -16,29 +16,72 @@ -->
- - {{ labelText }} - -
- + @if (outlined) { +
+
{{ labelText }}
+
+ + + + warning + + + warning + + + warning + + + + + @for (timeUnit of timeUnits; track timeUnit) { + {{ timeUnitTranslations.get(timeUnit) | translate }} + } + + +
- - - {{ requiredText }} - - - {{ minErrorText }} - - - {{ maxErrorText }} - - - - rule-node-config.units - - @for (timeUnit of timeUnits; track timeUnit) { - {{ timeUnitTranslations.get(timeUnit) | translate }} - } - - + } @else { + + {{ labelText }} + +
+ +
+ + + {{ requiredText }} + + + {{ minErrorText }} + + + {{ maxErrorText }} + +
+ + rule-node-config.units + + @for (timeUnit of timeUnits; track timeUnit) { + {{ timeUnitTranslations.get(timeUnit) | translate }} + } + + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts index b0d0a97641..2cd8e7d879 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts @@ -55,6 +55,9 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, @Input() labelText: string; + @Input() + labelIconHintTooltipText: string; + @Input() @coerceBoolean() required: boolean; @@ -79,6 +82,14 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, @Input() subscriptSizing: SubscriptSizing = 'fixed'; + @Input() + @coerceBoolean() + filterTimeUnitsByMaxTime = false; + + @Input() + @coerceBoolean() + outlined = false; + timeUnits = Object.values(TimeUnit).filter(item => item !== TimeUnit.MILLISECONDS) as TimeUnit[]; timeUnitTranslations = timeUnitTranslations; @@ -104,6 +115,15 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, } ngOnInit() { + if (this.filterTimeUnitsByMaxTime && this.maxTime) { + if (this.maxTime < 60) { + this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.MINUTES && item !== TimeUnit.HOURS && item !== TimeUnit.DAYS); + } else if (this.maxTime < 3600) { + this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.HOURS && item !== TimeUnit.DAYS); + } else if (this.maxTime < 86400) { + this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.DAYS); + } + } if(this.required || this.maxTime) { const timeControl = this.timeInputForm.get('time'); const validators = [Validators.pattern(/^\d*$/)]; diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html new file mode 100644 index 0000000000..f085913d5c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html @@ -0,0 +1,115 @@ + +
+
+
+ {{ 'rule-node-config.ai.ai-model' | translate }} +
+
+ + +
+
+ +
+ + + + {{'rule-node-config.ai.prompt-settings' | translate}} + + +
+ + rule-node-config.ai.system-prompt + + + {{ 'rule-node-config.ai.system-prompt-max-length' | translate }} + + + {{ 'rule-node-config.ai.system-prompt-blank' | translate }} + + + + rule-node-config.ai.user-prompt + + + {{ 'rule-node-config.ai.user-prompt-required' | translate }} + + + {{ 'rule-node-config.ai.user-prompt-max-length' | translate }} + + + {{ 'rule-node-config.ai.user-prompt-blank' | translate }} + + +
+
+
+ +
+
+
+ {{ 'rule-node-config.ai.response-format' | translate }} +
+ + {{ 'rule-node-config.ai.response-text' | translate }} + {{ 'rule-node-config.ai.response-json' | translate }} + {{ 'rule-node-config.ai.response-json-schema' | translate }} + +
+ @if (aiConfigForm.get('responseFormat.type').value === responseFormat.JSON_SCHEMA) { + + + } +
+ +
+ + + rule-node-config.ai.advanced-settings + +
+ + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts new file mode 100644 index 0000000000..283abb636c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts @@ -0,0 +1,115 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { MatDialog } from '@angular/material/dialog'; +import { AIModelDialogComponent, AIModelDialogData } from '@shared/components/ai-model/ai-model-dialog.component'; +import { AiModel, AiProvider } from '@shared/models/ai-model.models'; +import { deepTrim } from '@core/utils'; + +enum ResponseFormat { + TEXT = 'TEXT', + JSON = 'JSON', + JSON_SCHEMA = 'JSON_SCHEMA' +} + +@Component({ + selector: 'tb-external-node-ai-config', + templateUrl: './ai-config.component.html', + styleUrls: [] +}) +export class AiConfigComponent extends RuleNodeConfigurationComponent { + + aiConfigForm: UntypedFormGroup; + + entityType = EntityType; + + responseFormat = ResponseFormat; + + constructor(private fb: UntypedFormBuilder, + private dialog: MatDialog) { + super(); + } + + protected configForm(): UntypedFormGroup { + return this.aiConfigForm; + } + + protected onConfigurationSet(configuration: RuleNodeConfiguration) { + this.aiConfigForm = this.fb.group({ + modelSettingsId: [configuration ? configuration.modelSettingsId : null, [Validators.required]], + systemPrompt: [configuration ? configuration.systemPrompt : '', [Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], + userPrompt: [configuration ? configuration.userPrompt : '', [Validators.required, Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], + responseFormat: this.fb.group({ + type: [configuration ? configuration.responseFormat.type : ResponseFormat.JSON, []], + schema: [configuration ? configuration.responseFormat.schema : null, []], + }), + timeoutSeconds: [configuration ? configuration.timeoutSeconds : 60, []] + }); + } + + protected validatorTriggers(): string[] { + return ['responseFormat.type']; + } + + protected updateValidators(emitEvent: boolean) { + const responseFormatType = this.aiConfigForm.get('responseFormat.type').value; + if (responseFormatType === ResponseFormat.JSON_SCHEMA) { + this.aiConfigForm.get('responseFormat.schema').setValidators([Validators.required]); + this.aiConfigForm.get('responseFormat.schema').enable(); + } else { + this.aiConfigForm.get('responseFormat.schema').setValidators([]); + this.aiConfigForm.get('responseFormat.schema').disable(); + } + this.aiConfigForm.get('responseFormat.schema').updateValueAndValidity({emitEvent}); + } + + protected prepareOutputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration { + return deepTrim(configuration); + } + + onEntityChange($event: AiModel) { + if ($event) { + if ($event.configuration.provider === AiProvider.AMAZON_BEDROCK || + $event.configuration.provider === AiProvider.ANTHROPIC || + $event.configuration.provider === AiProvider.GITHUB_MODELS) { + this.aiConfigForm.get('responseFormat.type').patchValue(ResponseFormat.TEXT, {emitEvent: false}); + this.aiConfigForm.get('responseFormat.type').disable({emitEvent: false}); + } + } else { + this.aiConfigForm.get('responseFormat.type').enable({emitEvent: false}); + } + } + + createModelAi(formControl: string,) { + this.dialog.open(AIModelDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd: true + } + }).afterClosed() + .subscribe((model) => { + if (model) { + this.aiConfigForm.get(formControl).patchValue(model.id); + this.aiConfigForm.get(formControl).markAsDirty(); + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts index d2ed9c4f26..955c555989 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts @@ -32,6 +32,7 @@ import { HomeComponentsModule } from '@home/components/public-api'; import { CommonRuleNodeConfigModule } from '../common/common-rule-node-config.module'; import { SlackConfigComponent } from './slack-config.component'; import { LambdaConfigComponent } from './lambda-config.component'; +import { AiConfigComponent } from '@home/components/rule-node/external/ai-config.component'; @NgModule({ declarations: [ @@ -47,7 +48,8 @@ import { LambdaConfigComponent } from './lambda-config.component'; SendEmailConfigComponent, AzureIotHubConfigComponent, SendSmsConfigComponent, - SlackConfigComponent + SlackConfigComponent, + AiConfigComponent ], imports: [ CommonModule, @@ -68,7 +70,8 @@ import { LambdaConfigComponent } from './lambda-config.component'; SendEmailConfigComponent, AzureIotHubConfigComponent, SendSmsConfigComponent, - SlackConfigComponent + SlackConfigComponent, + AiConfigComponent ] }) export class ExternalRuleNodeConfigModule { diff --git a/ui-ngx/src/app/modules/home/models/services.map.ts b/ui-ngx/src/app/modules/home/models/services.map.ts index e4217b726b..ebccba900c 100644 --- a/ui-ngx/src/app/modules/home/models/services.map.ts +++ b/ui-ngx/src/app/modules/home/models/services.map.ts @@ -54,6 +54,7 @@ import { EventService } from '@core/http/event.service'; import { UnitService } from '@core/services/unit.service'; import { AuditLogService } from '@core/http/audit-log.service'; import { TrendzSettingsService } from '@core/http/trendz-settings.service'; +import { AiModelService } from '@core/http/ai-model.service'; export const ServicesMap = new Map>( [ @@ -95,6 +96,7 @@ export const ServicesMap = new Map>( ['eventService', EventService], ['unitService', UnitService], ['auditLogService', AuditLogService], - ['trendzSettingsService', TrendzSettingsService] + ['trendzSettingsService', TrendzSettingsService], + ['aiModelService', AiModelService] ] ); diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts new file mode 100644 index 0000000000..82fff06270 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts @@ -0,0 +1,49 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + + +import { RouterModule, Routes } from '@angular/router'; +import { EntitiesTableComponent } from '@home/components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { MenuId } from '@core/services/menu.models'; +import { AiModelsTableConfigResolver } from '@home/pages/ai-model/ai-model-table-config.resolve'; +import { NgModule } from '@angular/core'; + +export const aiModelRoutes: Routes = [ + { + path: 'ai-models', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'ai-models.ai-models', + breadcrumb: { + menuId: MenuId.ai_models + } + }, + resolve: { + entitiesTableConfig: AiModelsTableConfigResolver + } + } +]; + +@NgModule({ + providers: [ + AiModelsTableConfigResolver + ], + imports: [RouterModule.forChild(aiModelRoutes)], + exports: [RouterModule], +}) +export class AiModelRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts new file mode 100644 index 0000000000..9f135d002c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts @@ -0,0 +1,124 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { + CellActionDescriptor, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { ActivatedRouteSnapshot } from '@angular/router'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { Direction } from '@shared/models/page/sort-order'; +import { DatePipe } from '@angular/common'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Observable } from 'rxjs'; +import { AiModel, AiProviderTranslations } from '@shared/models/ai-model.models'; +import { AiModelService } from '@core/http/ai-model.service'; +import { AiModelTableHeaderComponent } from '@home/pages/ai-model/ai-model-table-header.component'; +import { AIModelDialogComponent, AIModelDialogData } from '@shared/components/ai-model/ai-model-dialog.component'; +import { map } from 'rxjs/operators'; + +@Injectable() +export class AiModelsTableConfigResolver { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + constructor( + private datePipe: DatePipe, + private aiModelService: AiModelService, + private translate : TranslateService, + private dialog: MatDialog + ) { + this.config.selectionEnabled = true; + this.config.entityType = EntityType.AI_MODEL_SETTINGS; + this.config.addAsTextButton = true; + this.config.detailsPanelEnabled = false; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.AI_MODEL_SETTINGS); + this.config.entityResources = entityTypeResources.get(EntityType.AI_MODEL_SETTINGS); + + this.config.headerComponent = AiModelTableHeaderComponent; + this.config.addDialogStyle = {width: '850px', maxHeight: '100vh'}; + this.config.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; + + this.config.addEntity = () => this.addModel(null, true); + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '170px'), + new EntityTableColumn('name', 'ai-models.name', '33%'), + new EntityTableColumn('provider', 'ai-models.ai-provider', '33%', + entity => this.translate.instant(AiProviderTranslations.get(entity.configuration.provider)) + ), + new EntityTableColumn('aiModel', 'ai-models.ai-model', '33%', + entity => entity.configuration.modelConfig.modelId, () => ({}), false + ) + ) + + this.config.deleteEntityTitle = model => this.translate.instant('ai-models.delete-model-title', {modelName: model.name}); + this.config.deleteEntityContent = () => this.translate.instant('ai-models.delete-model-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('ai-models.delete-models-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('ai-models.delete-models-text'); + + this.config.deleteEntity = id => this.aiModelService.deleteAiModel(id.id); + + this.config.entitiesFetchFunction = pageLink => this.aiModelService.getAiModels(pageLink); + + this.config.cellActionDescriptors = this.configureCellActions(); + } + + resolve(_route: ActivatedRouteSnapshot): EntityTableConfig { + return this.config; + } + + private configureCellActions(): Array> { + return [ + { + name: this.translate.instant('action.edit'), + icon: 'edit', + isEnabled: () => true, + onAction: ($event, entity) => this.editModel(entity) + } + ]; + } + + private editModel(AIModel: AiModel): void { + this.addModel(AIModel, false).subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + private addModel(AIModel: AiModel, isAdd = false): Observable { + return this.dialog.open(AIModelDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd, + AIModel + } + }).afterClosed().pipe(map(res => { + if (res) { + this.config.updateData(); + return res; + } else { + return null; + } + })); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.html b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.html new file mode 100644 index 0000000000..43f1dbf0db --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.html @@ -0,0 +1,21 @@ + +
+
ai-models.ai-models
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss new file mode 100644 index 0000000000..acb9e2dc87 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host{ + width: 100%; +} diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts new file mode 100644 index 0000000000..4fbc906b80 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts @@ -0,0 +1,33 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { EntityTableHeaderComponent } from '@home/components/entity/entity-table-header.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { AiModel } from '@shared/models/ai-model.models'; + +@Component({ + selector: 'tb-ai-model-table-header', + templateUrl: './ai-model-table-header.component.html', + styleUrls: ['./ai-model-table-header.component.scss'] +}) +export class AiModelTableHeaderComponent extends EntityTableHeaderComponent { + + constructor(protected store: Store) { + super(store); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model.module.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model.module.ts new file mode 100644 index 0000000000..bd20612e8a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model.module.ts @@ -0,0 +1,33 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { AiModelRoutingModule } from '@home/pages/ai-model/ai-model-routing.module'; +import { AiModelTableHeaderComponent } from '@home/pages/ai-model/ai-model-table-header.component'; + +@NgModule({ + declarations: [ + AiModelTableHeaderComponent + ], + imports: [ + CommonModule, + SharedModule, + AiModelRoutingModule + ] +}) +export class AiModelModule { } diff --git a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts index 39c442cc49..5bb3954cae 100644 --- a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts +++ b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts @@ -46,6 +46,7 @@ import { AccountModule } from '@home/pages/account/account.module'; import { ScadaSymbolModule } from '@home/pages/scada-symbol/scada-symbol.module'; import { GatewaysModule } from '@home/pages/gateways/gateways.module'; import { MobileModule } from '@home/pages/mobile/mobile.module'; +import { AiModelModule } from '@home/pages/ai-model/ai-model.module'; @NgModule({ exports: [ @@ -78,7 +79,8 @@ import { MobileModule } from '@home/pages/mobile/mobile.module'; UserModule, VcModule, AccountModule, - ScadaSymbolModule + ScadaSymbolModule, + AiModelModule, ] }) export class HomePagesModule { } diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html new file mode 100644 index 0000000000..1a8fae3b45 --- /dev/null +++ b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html @@ -0,0 +1,248 @@ + + +

{{ dialogTitle | translate }}

+ +
+ +
+ + +
+
+
+
+ + ai-models.name + + + + {{ 'ai-models.name-required' | translate }} + + + {{ 'ai-models.name-max-length' | translate }} + + +
+
+
+
ai-models.provider
+
+ + ai-models.ai-provider + + + {{providerTranslationMap.get(provider) | translate}} + + + +
+ @if (provider === aiProvider.GITHUB_MODELS) { + + ai-models.personal-access-token + + + + {{ 'ai-models.personal-access-token-required' | translate }} + + + } @else if (provider === aiProvider.GOOGLE_VERTEX_AI_GEMINI) { + + ai-models.project-id + + + {{ 'ai-models.project-id-required' | translate }} + + + + ai-models.location + + + {{ 'ai-models.location-required' | translate }} + + + + + } @else { + @if (provider === aiProvider.AZURE_OPENAI) { + + ai-models.endpoint + + + {{ 'ai-models.endpoint-required' | translate }} + + + + ai-models.service-version + + + } + + ai-models.api-key + + + + {{ 'ai-models.api-key-required' | translate }} + + + } +
+
+
+
+
ai-models.configuration
+
+
+ + +
+
+
+ {{ 'ai-models.temperature' | translate }} +
+ + + + warning + + +
+
+
+ {{ 'ai-models.top-p' | translate }} +
+ + + + warning + + +
+ + @if (provider === aiProvider.GOOGLE_AI_GEMINI || provider === aiProvider.GOOGLE_VERTEX_AI_GEMINI || provider === aiProvider.ANTHROPIC) { +
+
+ {{ 'ai-models.top-k' | translate }} +
+ + + + warning + + +
+ } + + @if (provider !== aiProvider.ANTHROPIC && provider !== aiProvider.AMAZON_BEDROCK) { +
+
+ {{ 'ai-models.presence-penalty' | translate }} +
+ + + +
+
+
+ {{ 'ai-models.frequency-penalty' | translate }} +
+ + + +
+ } + +
+
+ {{ 'ai-models.max-output-token' | translate }} +
+ + + + warning + + +
+
+
+
+
+
+
+ + + + +
diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.scss b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.scss new file mode 100644 index 0000000000..c55453c388 --- /dev/null +++ b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.scss @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +:host { + width: 850px; + height: 100%; + max-width: 100%; + max-height: 100vh; + display: grid; +} diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts new file mode 100644 index 0000000000..5dd63d3f5b --- /dev/null +++ b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts @@ -0,0 +1,168 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Observable } from 'rxjs'; +import { StepperOrientation } from '@angular/cdk/stepper'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { EntityType } from '@shared/models/entity-type.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AiModel, AiProvider, AiProviderTranslations, AiProviderWithApiKey } from '@shared/models/ai-model.models'; +import { AiModelService } from '@core/http/ai-model.service'; +import { CheckConnectivityDialogComponent } from '@shared/components/ai-model/check-connectivity-dialog.component'; + +export interface AIModelDialogData { + AIModel?: AiModel; + isAdd?: boolean; +} + +@Component({ + selector: 'tb-ai-model-dialog', + templateUrl: './ai-model-dialog.component.html', + styleUrls: ['./ai-model-dialog.component.scss'] +}) +export class AIModelDialogComponent extends DialogComponent { + + readonly entityType = EntityType; + + selectedIndex = 0; + + dialogTitle = 'ai-models.ai-model'; + + stepperOrientation: Observable; + + aiProvider = AiProvider; + providerMap: AiProvider[] = Object.keys(AiProvider) as AiProvider[]; + providerTranslationMap = AiProviderTranslations; + + aiProviderWithApiKey: AiProvider[] = AiProviderWithApiKey; + + provider: AiProvider = AiProvider.OPENAI; + + aiModelForms: FormGroup; + + isAdd = false; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: AIModelDialogData, + private fb: FormBuilder, + private aiModelService: AiModelService, + private dialog: MatDialog) { + super(store, router, dialogRef); + + if (this.data.isAdd) { + this.isAdd = true; + } + + this.provider = this.data.AIModel ? this.data.AIModel.configuration.provider : AiProvider.OPENAI; + + this.aiModelForms = this.fb.group({ + name: [this.data.AIModel ? this.data.AIModel.name : '', [Validators.required, Validators.maxLength(255)]], + modelType: ['CHAT'], + configuration: this.fb.group({ + provider: [this.provider, [Validators.required]], + providerConfig: this.fb.group({ + apiKey: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.apiKey : '', [Validators.required]], + personalAccessToken: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.personalAccessToken : '', [Validators.required]], + endpoint: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.endpoint : '', [Validators.required]], + serviceVersion: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.serviceVersion : ''], + projectId: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.projectId : '', [Validators.required]], + location: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.location : '', [Validators.required]], + serviceAccountKey: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.serviceAccountKey : '', [Validators.required]], + serviceAccountKeyFileName: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.serviceAccountKeyFileName : '', [Validators.required]], + }), + modelConfig: this.fb.group({ + modelId: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.modelId : '', [Validators.required]], + temperature: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.temperature : null, [Validators.min(0)]], + topP: this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.topP : [null, [Validators.min(0.1), Validators.max(1)]], + topK: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.topK : null, [Validators.min(0)]], + frequencyPenalty: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.frequencyPenalty : null], + presencePenalty: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.presencePenalty : null], + maxOutputTokens: [this.data.AIModel ? this.data.AIModel.configuration.modelConfig?.maxOutputTokens : null, [Validators.min(1)]] + }) + }) + }); + + this.aiModelForms.get('configuration.provider').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((provider: AiProvider) => { + this.provider = provider; + this.aiModelForms.get('configuration.modelConfig').reset({}); + this.aiModelForms.get('configuration.providerConfig').reset({}); + this.updateValidation(provider); + }) + + this.updateValidation(this.provider); + } + + private updateValidation(provider: AiProvider) { + const providerConfig = this.aiModelForms.get('configuration.providerConfig'); + if (this.aiProviderWithApiKey.includes(provider)) { + providerConfig.get('apiKey').enable(); + } else { + providerConfig.get('apiKey').disable(); + } + if (provider === AiProvider.GITHUB_MODELS) { + providerConfig.get('personalAccessToken').enable(); + } else { + providerConfig.get('personalAccessToken').disable(); + } + if (provider === AiProvider.GOOGLE_VERTEX_AI_GEMINI) { + providerConfig.get('projectId').enable(); + providerConfig.get('location').enable(); + providerConfig.get('serviceAccountKey').enable(); + providerConfig.get('serviceAccountKeyFileName').enable(); + } else { + providerConfig.get('projectId').disable(); + providerConfig.get('location').disable(); + providerConfig.get('serviceAccountKey').disable(); + providerConfig.get('serviceAccountKeyFileName').disable(); + } + if (provider === AiProvider.AZURE_OPENAI) { + providerConfig.get('endpoint').enable(); + providerConfig.get('serviceVersion').enable(); + } else { + providerConfig.get('endpoint').disable(); + providerConfig.get('serviceVersion').disable(); + } + } + + cancel(): void { + this.dialogRef.close(null); + } + + checkConnectivity() { + return this.dialog.open(CheckConnectivityDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + AIModel: this.aiModelForms.value + } + }).afterClosed(); + } + + add(): void { + const aiModel = {...this.data.AIModel, ...this.aiModelForms.value} as AiModel; + this.aiModelService.saveAiModel(aiModel).subscribe(aiModel => this.dialogRef.close(aiModel)); + } +} diff --git a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.html b/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.html new file mode 100644 index 0000000000..9ead874275 --- /dev/null +++ b/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.html @@ -0,0 +1,58 @@ + +
+

ai-models.check-connectivity

+ + +
+
+
+ + +
+
+ check_circle +
+ {{ "ai-models.check-connectivity-success" | translate }} +
+
+
+ cancel +
+
{{ "ai-models.check-connectivity-failed" | translate }}
+ + +
+
+
+
+ + +
diff --git a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.scss b/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.scss new file mode 100644 index 0000000000..1c6195e246 --- /dev/null +++ b/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.scss @@ -0,0 +1,71 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +:host { + width: 560px; + height: 100%; + max-width: 100%; + max-height: 100vh; + display: grid; + + .connectivity-title { + font-size: 18px; + font-weight: 500; + margin: 0; + padding-left: 16px; + } + + .connection-status { + font-weight: 500; + letter-spacing: 0.25px; + text-align: center; + } + + .connection-icon { + height: 32px; + font-size: 32px; + width: 32px; + margin-bottom: 4px; + } + + .error_msg { + text-align: center; + margin-top: 8px; + font-size: 14px; + line-height: 130%; + letter-spacing: 0.25px; + opacity: 0.9; + } + + .success { + color: #198038; + } + + .error { + color: #D12730; + } + + ::ng-deep { + .json-editor { + .tb-json-object-toolbar { + display: none; + } + .tb-json-panel { + margin: 0; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts b/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts new file mode 100644 index 0000000000..70896cdc6d --- /dev/null +++ b/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts @@ -0,0 +1,87 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { AiModel, AiModelWithUserMsg } from '@shared/models/ai-model.models'; +import { AiModelService } from '@core/http/ai-model.service'; + +export interface AIModelDialogData { + AIModel?: AiModel; +} + +@Component({ + selector: 'tb-check-connectivity-dialog', + templateUrl: './check-connectivity-dialog.component.html', + styleUrls: ['./check-connectivity-dialog.component.scss'] +}) +export class CheckConnectivityDialogComponent extends DialogComponent { + + showCheckSuccess = false; + checkErrMsg = ''; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: AIModelDialogData, + private aiModelService: AiModelService) { + super(store, router, dialogRef); + + if (this.data.AIModel) { + const aiModelWithMsg: AiModelWithUserMsg = { + userMessage: { + contents: [ + { + contentType: "TEXT", + text: "What is the capital of Ukraine?" + } + ] + }, + chatModel: { + modelType: "CHAT", + provider: this.data.AIModel.configuration.provider, + providerConfig: {...this.data.AIModel.configuration.providerConfig}, + modelConfig: { + modelId: this.data.AIModel.configuration.modelConfig.modelId, + maxRetries: 0, + timeoutSeconds: 20 + } + } + } + this.aiModelService.checkConnectivity(aiModelWithMsg, { + ignoreErrors: true, + ignoreLoading: true + }).subscribe({ + next: (result) => { + if (result.status === 'SUCCESS') { + this.showCheckSuccess = true; + } else { + this.checkErrMsg = JSON.parse(result.errorDetails); + } + }, + error: err => this.checkErrMsg = err.error.message + }); + } + } + + cancel(): void { + this.dialogRef.close(null); + } +} diff --git a/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.html b/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.html new file mode 100644 index 0000000000..d204013805 --- /dev/null +++ b/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.html @@ -0,0 +1,41 @@ + + + {{label}} + + + + {{errorText}} + + + + + + + diff --git a/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts b/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts new file mode 100644 index 0000000000..a5cbdb332d --- /dev/null +++ b/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts @@ -0,0 +1,161 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormControl, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { Observable } from 'rxjs'; +import { map, startWith, tap } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; +import { AiModelMap, AiProvider } from '@shared/models/ai-model.models'; + +@Component({ + selector: 'tb-models-list-autocomplete', + templateUrl: './models-list-autocomplete.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ModelsListAutocompleteComponent), + multi: true + } + ] +}) +export class ModelsListAutocompleteComponent implements ControlValueAccessor, OnInit, OnChanges { + + @ViewChild('nameInput', {static: true}) nameInput: ElementRef; + + @Input() + disabled: boolean; + + @Input() + @coerceBoolean() + required = false; + + @Input() + provider: AiProvider; + + @Input() + placeholderText: string = this.translate.instant('widget-config.set'); + + @Input() + subscriptSizing: SubscriptSizing = 'dynamic'; + + @Input() + appearance: MatFormFieldAppearance = 'outline'; + + @Input() + label: string; + + @Input() + errorText: string; + + selectionFormControl: FormControl; + modelValue: string | null; + + filteredOptions$: Observable>; + + searchText = ''; + + private dirty = false; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: FormBuilder, + private translate: TranslateService) { + } + + ngOnInit() { + this.selectionFormControl = this.fb.control('', this.required ? [Validators.required] : []); + this.setupFilteredOptions(); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.provider && !changes.provider.isFirstChange()) { + this.setupFilteredOptions(); + this.selectionFormControl.setValue(null, {emitEvent: false}); + this.modelValue = null; + this.propagateChange(null); + } + } + + private setupFilteredOptions() { + this.filteredOptions$ = this.selectionFormControl.valueChanges.pipe( + startWith(''), + tap(value => this.updateView(value)), + map(value => { + const search = value ? value.toLowerCase() : ''; + const options = this.provider ? AiModelMap.get(this.provider) || [] : []; + return search ? options.filter(option => option.toLowerCase().includes(search)) : options; + }) + ); + } + + writeValue(option?: string): void { + this.searchText = ''; + this.modelValue = option ? option : null; + + if (option) { + this.selectionFormControl.patchValue(option, { emitEvent: false }); + this.dirty = true; + } else { + this.selectionFormControl.patchValue(null, { emitEvent: false }); + this.dirty = true; + } + } + + onFocus() { + if (this.dirty) { + this.selectionFormControl.updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + updateView(value: string) { + this.searchText = value ? value : ''; + if (this.modelValue !== value && value) { + this.modelValue = value; + this.propagateChange(this.modelValue); + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.selectionFormControl.disable({emitEvent: false}); + } else { + this.selectionFormControl.enable({emitEvent: false}); + } + } + + clear() { + this.selectionFormControl.patchValue(null, {emitEvent: false}); + this.propagateChange(null); + this.modelValue = null; + setTimeout(() => { + this.nameInput.nativeElement.blur(); + this.nameInput.nativeElement.focus(); + }, 0); + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts index ac8054283a..403502d56a 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts @@ -14,16 +14,7 @@ /// limitations under the License. /// -import { - Component, - ElementRef, - EventEmitter, - forwardRef, - Input, - OnInit, - Output, - ViewChild -} from '@angular/core'; +import { Component, ElementRef, EventEmitter, forwardRef, Input, OnInit, Output, ViewChild } from '@angular/core'; import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { firstValueFrom, merge, Observable, of, Subject } from 'rxjs'; @@ -300,6 +291,12 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit this.entityRequiredText = 'notification.notification-recipient-required'; this.notFoundEntities = 'notification.no-recipients-text'; break; + case EntityType.AI_MODEL_SETTINGS: + this.entityText = 'ai-models.ai-model'; + this.noEntitiesMatchingText = 'ai-models.no-model-matching'; + this.entityRequiredText = 'ai-models.model-required'; + this.notFoundEntities = 'ai-models.no-model-text'; + break; case AliasEntityType.CURRENT_CUSTOMER: this.entityText = 'customer.default-customer'; this.noEntitiesMatchingText = 'customer.no-customers-matching'; diff --git a/ui-ngx/src/app/shared/models/ai-model.models.ts b/ui-ngx/src/app/shared/models/ai-model.models.ts new file mode 100644 index 0000000000..5f0d845cac --- /dev/null +++ b/ui-ngx/src/app/shared/models/ai-model.models.ts @@ -0,0 +1,159 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData } from '@shared/models/base-data'; +import { HasTenantId } from '@shared/models/entity.models'; +import { AiModelId } from '@shared/models/id/ai-model-id'; + +export interface AiModel extends Omit, 'label'>, HasTenantId { + modelType: string; + configuration: { + provider: AiProvider + providerConfig: { + apiKey?: string; + personalAccessToken?: string; + endpoint?: string; + serviceVersion?: string; + projectId?: string; + location?: string; + serviceAccountKey?: string; + serviceAccountKeyFileName?: string + }; + modelConfig: { + modelId: string; + temperature?: number | null; + topP?: number; + topK?: number; + frequencyPenalty?: number; + presencePenalty?: number; + maxOutputTokens?: number; + } + } +} + +export enum AiProvider { + OPENAI = 'OPENAI', + AZURE_OPENAI = 'AZURE_OPENAI', + GOOGLE_AI_GEMINI = 'GOOGLE_AI_GEMINI', + GOOGLE_VERTEX_AI_GEMINI = 'GOOGLE_VERTEX_AI_GEMINI', + MISTRAL_AI = 'MISTRAL_AI', + ANTHROPIC = 'ANTHROPIC', + AMAZON_BEDROCK = 'AMAZON_BEDROCK', + GITHUB_MODELS = 'GITHUB_MODELS' +} + +export const AiProviderWithApiKey: AiProvider[] = [ + AiProvider.OPENAI, + AiProvider.AZURE_OPENAI, + AiProvider.GOOGLE_AI_GEMINI, + AiProvider.MISTRAL_AI, + AiProvider.ANTHROPIC, + AiProvider.AMAZON_BEDROCK +] + +export const AiProviderTranslations = new Map( + [ + [AiProvider.OPENAI , 'ai-models.ai-providers.openai'], + [AiProvider.AZURE_OPENAI , 'ai-models.ai-providers.azure-openai'], + [AiProvider.GOOGLE_AI_GEMINI , 'ai-models.ai-providers.google-ai-gemini'], + [AiProvider.GOOGLE_VERTEX_AI_GEMINI , 'ai-models.ai-providers.google-vertex-ai-gemini'], + [AiProvider.MISTRAL_AI , 'ai-models.ai-providers.mistral-ai'], + [AiProvider.ANTHROPIC , 'ai-models.ai-providers.anthropic'], + [AiProvider.AMAZON_BEDROCK , 'ai-models.ai-providers.amazon-bedrock'], + [AiProvider.GITHUB_MODELS , 'ai-models.ai-providers.github-models'] + ] +); + +export const AiModelMap = new Map( + [ + [AiProvider.OPENAI , [ + 'o4-mini', + 'o3-pro', + 'o3', + 'o3-mini', + 'o1', + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'gpt-4o', + 'gpt-4o-mini' + ]], + [AiProvider.AZURE_OPENAI , []], + [AiProvider.GOOGLE_AI_GEMINI , [ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-2.0-flash-lite', + ]], + [AiProvider.GOOGLE_VERTEX_AI_GEMINI , [ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-2.0-flash-lite', + ]], + [AiProvider.MISTRAL_AI , [ + 'magistral-medium-latest', + 'magistral-small-latest', + 'mistral-large-latest', + 'mistral-medium-latest', + 'mistral-small-latest', + 'pixtral-large-latest', + 'ministral-8b-latest', + 'ministral-3b-latest', + 'open-mistral-nemo' + ]], + [AiProvider.ANTHROPIC , [ + 'claude-opus-4-0', + 'claude-sonnet-4-0', + 'claude-3-7-sonnet-latest', + 'claude-3-5-sonnet-latest', + 'claude-3-5-haiku-latest' + ]], + [AiProvider.AMAZON_BEDROCK , []], + [AiProvider.GITHUB_MODELS , []] + ] +); + +export interface AiModelWithUserMsg { + userMessage: { + contents: Array<{contentType: string; text: string}>; + } + chatModel: { + modelType: string; + provider: AiProvider + providerConfig: { + apiKey?: string; + personalAccessToken?: string; + endpoint?: string; + serviceVersion?: string; + projectId?: string; + location?: string; + serviceAccountKey?: string; + serviceAccountKeyFileName?: string + }; + modelConfig: { + modelId: string; + maxRetries: number; + timeoutSeconds: number; + } + } +} + + +export interface CheckConnectivityResult { + status: string; + errorDetails: string; +} diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index fbb3e73216..3406412324 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -200,6 +200,7 @@ export const HelpLinks = { mobileBundle: `${helpBaseUrl}/docs${docPlatformPrefix}/mobile-center/mobile-center/`, mobileQrCode: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/mobile-qr-code/`, calculatedField: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/calculated-fields/`, + aiModels: `${helpBaseUrl}/docs${docPlatformPrefix}/ai-models`, timewindowSettings: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/dashboards/#time-window`, trendzSettings: `${helpBaseUrl}/docs/trendz/` } diff --git a/ui-ngx/src/app/shared/models/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts index 02bae1576f..fa94c74041 100644 --- a/ui-ngx/src/app/shared/models/entity-type.models.ts +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -496,8 +496,13 @@ export const entityTypeTranslations = new MapAre you sure you want to leave this page?", @@ -2555,7 +2625,8 @@ "type-current-user-owner": "Current User Owner", "type-calculated-field": "Calculated field", "type-calculated-fields": "Calculated fields", - "type-ai-model-settings": "AI model settings", + "type-ai-model": "AI model", + "type-ai-models": "AI models", "type-widgets-bundle": "Widgets bundle", "type-widgets-bundles": "Widgets bundles", "list-of-widgets-bundles": "{ count, plural, =1 {One widgets bundle} other {List of # widget bundles} }", @@ -5356,6 +5427,30 @@ "html-text-description": "Allows you to use HTML tags for formatting, links and images in your mai body.", "dynamic-text-description": "Allows to use Plain Text or HTML body type dynamically based on templatization feature.", "after-template-evaluation-hint": "After template evaluation value should be true for HTML, and false for Plain text." + }, + "ai": { + "ai-model": "AI model", + "ai-model-hint": "Select the pre-configured AI model to process requests sent by this rule node, or use \"Create new\" to configure a new one.", + "prompt-settings": "Prompt settings", + "prompt-settings-hint": "Configure the instructions for the AI. The optional system prompt sets the AI's general role and constraints, while the user prompt defines the specific task to perform. Both fields also support rule node templates to include dynamic data (e.g., use $[*] to access the entire message body or ${*} to access all metadata).", + "system-prompt": "System prompt", + "system-prompt-max-length": "System prompt must be 10000 characters or less.", + "system-prompt-blank": "System prompt must not be blank.", + "user-prompt": "User prompt", + "user-prompt-required": "User prompt is required.", + "user-prompt-max-length": "User prompt must be 10000 characters or less.", + "user-prompt-blank": "User prompt must not be blank.", + "response-format": "Response format", + "response-format-hint": "The model is required to generate a JSON that matches the specific structure and data types defined in the provided schema. If the output is not a valid JSON object, it will be automatically wrapped within a JSON object under the \"response\" key.", + "response-text": "Text", + "response-json": "JSON", + "response-json-schema": "JSON Schema", + "response-json-schema-required": "JSON Schema is required", + "advanced-settings": "Advanced settings", + "timeout": "Timeout", + "timeout-hint": "Maximum time to wait for a response \nfrom the AI model before the request is terminated.", + "timeout-required": "Timeout is required", + "timeout-validation": "Must be from 1 second to 10 minutes." } }, "timezone": { diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index e516810895..9896e16013 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -819,4 +819,34 @@ } } } + + .tb-form-panel.outlined { + --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + --mdc-outlined-text-field-container-shape: 6px; + + box-shadow: none; + gap: 0; + padding-bottom: 0; + + &:not(.stroked) { + border-radius: 0; + } + + &:not(.mat-padding,.padding) { + padding: 0; + } + + .tb-form-panel-title { + margin-bottom: 16px; + } + + .tb-form-row { + height: 56px; + margin-bottom: 22px; + &.disabled { + border-color: var(--mdc-outlined-text-field-disabled-outline-color); + color: var(--mdc-outlined-text-field-disabled-input-text-color); + } + } + } } From 7860f4575f4668cc81c072c3bfcfc4cacbcff289 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Tue, 8 Jul 2025 17:46:18 +0300 Subject: [PATCH 2/8] UI: Add VC support --- ui-ngx/src/app/shared/models/ai-model.models.ts | 4 ++-- ui-ngx/src/app/shared/models/vc.models.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/ui-ngx/src/app/shared/models/ai-model.models.ts b/ui-ngx/src/app/shared/models/ai-model.models.ts index 5f0d845cac..1f9c81bb1e 100644 --- a/ui-ngx/src/app/shared/models/ai-model.models.ts +++ b/ui-ngx/src/app/shared/models/ai-model.models.ts @@ -14,11 +14,11 @@ /// limitations under the License. /// -import { BaseData } from '@shared/models/base-data'; +import { BaseData, ExportableEntity } from '@shared/models/base-data'; import { HasTenantId } from '@shared/models/entity.models'; import { AiModelId } from '@shared/models/id/ai-model-id'; -export interface AiModel extends Omit, 'label'>, HasTenantId { +export interface AiModel extends Omit, 'label'>, HasTenantId, ExportableEntity { modelType: string; configuration: { provider: AiProvider diff --git a/ui-ngx/src/app/shared/models/vc.models.ts b/ui-ngx/src/app/shared/models/vc.models.ts index 3795518ffc..3ce5b6ae3f 100644 --- a/ui-ngx/src/app/shared/models/vc.models.ts +++ b/ui-ngx/src/app/shared/models/vc.models.ts @@ -35,14 +35,16 @@ export const exportableEntityTypes: Array = [ EntityType.TB_RESOURCE, EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, - EntityType.NOTIFICATION_RULE + EntityType.NOTIFICATION_RULE, + EntityType.AI_MODEL_SETTINGS ]; export const entityTypesWithoutRelatedData: Set = new Set([ EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, - EntityType.TB_RESOURCE + EntityType.TB_RESOURCE, + EntityType.AI_MODEL_SETTINGS ]); export interface VersionCreateConfig { From ad7cfb94acdf11950131db08c6a5efe517f8ff38 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Wed, 9 Jul 2025 18:07:02 +0300 Subject: [PATCH 3/8] UI: Change entity type name --- ui-ngx/src/app/core/http/ai-model.service.ts | 8 +++---- ui-ngx/src/app/core/http/entity.service.ts | 4 ++-- .../external/ai-config.component.html | 2 +- .../ai-model/ai-model-table-config.resolve.ts | 8 +++---- .../ai-model/ai-model-dialog.component.html | 12 +++++----- .../ai-model/ai-model-dialog.component.ts | 18 +++++++-------- .../check-connectivity-dialog.component.ts | 10 ++++----- .../entity/entity-autocomplete.component.ts | 2 +- .../src/app/shared/models/ai-model.models.ts | 22 +++++++++---------- .../app/shared/models/entity-type.models.ts | 6 ++--- .../src/app/shared/models/id/ai-model-id.ts | 2 +- ui-ngx/src/app/shared/models/vc.models.ts | 4 ++-- 12 files changed, 46 insertions(+), 52 deletions(-) diff --git a/ui-ngx/src/app/core/http/ai-model.service.ts b/ui-ngx/src/app/core/http/ai-model.service.ts index b2f63ba583..64e003c30e 100644 --- a/ui-ngx/src/app/core/http/ai-model.service.ts +++ b/ui-ngx/src/app/core/http/ai-model.service.ts @@ -32,19 +32,19 @@ export class AiModelService { ) {} public saveAiModel(aiModel: AiModel, config?: RequestConfig): Observable { - return this.http.post('/api/ai/model/settings', aiModel, defaultHttpOptionsFromConfig(config)); + return this.http.post('/api/ai/model', aiModel, defaultHttpOptionsFromConfig(config)); } public getAiModels(pageLink: PageLink, config?: RequestConfig): Observable> { - return this.http.get>(`/api/ai/model/settings${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + return this.http.get>(`/api/ai/model${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); } public getAiModelById(aiModelId: string, config?: RequestConfig): Observable { - return this.http.get(`/api/ai/model/settings/${aiModelId}`, defaultHttpOptionsFromConfig(config)); + return this.http.get(`/api/ai/model/${aiModelId}`, defaultHttpOptionsFromConfig(config)); } public deleteAiModel(aiModelId: string, config?: RequestConfig) { - return this.http.delete(`/api/ai/model/settings/${aiModelId}`, defaultHttpOptionsFromConfig(config)); + return this.http.delete(`/api/ai/model/${aiModelId}`, defaultHttpOptionsFromConfig(config)); } public checkConnectivity(aiModelWithUserMsg: AiModelWithUserMsg, config?: RequestConfig): Observable { diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 17a755ad30..ac6e4b7a7a 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -185,7 +185,7 @@ export class EntityService { case EntityType.MOBILE_APP_BUNDLE: observable = this.mobileAppService.getMobileAppBundleInfoById(entityId, config); break; - case EntityType.AI_MODEL_SETTINGS: + case EntityType.AI_MODEL: observable = this.aiModelService.getAiModelById(entityId, config); break; } @@ -490,7 +490,7 @@ export class EntityService { pageLink.sortOrder.property = 'title'; entitiesObservable = this.mobileAppService.getTenantMobileAppBundleInfos(pageLink, config); break; - case EntityType.AI_MODEL_SETTINGS: + case EntityType.AI_MODEL: pageLink.sortOrder.property = 'name'; entitiesObservable = this.aiModelService.getAiModels(pageLink, config); break; diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html index f085913d5c..8846858a8d 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html @@ -28,7 +28,7 @@ appearance="outline" labelText="ai-models.ai-model" (entityChanged)="onEntityChange($event)" - [entityType]="entityType.AI_MODEL_SETTINGS" + [entityType]="entityType.AI_MODEL" (createNew)="createModelAi('modelSettingsId')" formControlName="modelSettingsId"> diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts index 9f135d002c..6e9bde0b60 100644 --- a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts @@ -46,11 +46,11 @@ export class AiModelsTableConfigResolver { private dialog: MatDialog ) { this.config.selectionEnabled = true; - this.config.entityType = EntityType.AI_MODEL_SETTINGS; + this.config.entityType = EntityType.AI_MODEL; this.config.addAsTextButton = true; this.config.detailsPanelEnabled = false; - this.config.entityTranslations = entityTypeTranslations.get(EntityType.AI_MODEL_SETTINGS); - this.config.entityResources = entityTypeResources.get(EntityType.AI_MODEL_SETTINGS); + this.config.entityTranslations = entityTypeTranslations.get(EntityType.AI_MODEL); + this.config.entityResources = entityTypeResources.get(EntityType.AI_MODEL); this.config.headerComponent = AiModelTableHeaderComponent; this.config.addDialogStyle = {width: '850px', maxHeight: '100vh'}; @@ -65,7 +65,7 @@ export class AiModelsTableConfigResolver { entity => this.translate.instant(AiProviderTranslations.get(entity.configuration.provider)) ), new EntityTableColumn('aiModel', 'ai-models.ai-model', '33%', - entity => entity.configuration.modelConfig.modelId, () => ({}), false + entity => entity.configuration.modelId, () => ({}), false ) ) diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html index 1a8fae3b45..97eeb79f02 100644 --- a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html +++ b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html @@ -117,7 +117,7 @@ -
+
ai-models.configuration
@@ -139,7 +139,7 @@ matTooltipPosition="above" matTooltipClass="tb-error-tooltip" [matTooltip]="'ai-models.temperature-min' | translate" - *ngIf="aiModelForms.get('configuration').get('modelConfig').get('temperature').hasError('min')" + *ngIf="aiModelForms.get('configuration').get('temperature').hasError('min')" class="tb-error"> warning @@ -156,8 +156,8 @@ matTooltipPosition="above" matTooltipClass="tb-error-tooltip" [matTooltip]="'ai-models.top-p-min-max' | translate" - *ngIf="aiModelForms.get('configuration').get('modelConfig').get('topP').hasError('min') || - aiModelForms.get('configuration').get('modelConfig').get('topP').hasError('max')" + *ngIf="aiModelForms.get('configuration').get('topP').hasError('min') || + aiModelForms.get('configuration').get('topP').hasError('max')" class="tb-error"> warning @@ -176,7 +176,7 @@ matTooltipPosition="above" matTooltipClass="tb-error-tooltip" [matTooltip]="'ai-models.top-k-min' | translate" - *ngIf="aiModelForms.get('configuration').get('modelConfig').get('topK').hasError('min')" + *ngIf="aiModelForms.get('configuration').get('topK').hasError('min')" class="tb-error"> warning @@ -216,7 +216,7 @@ matTooltipPosition="above" matTooltipClass="tb-error-tooltip" [matTooltip]="'ai-models.max-output-token-min' | translate" - *ngIf="aiModelForms.get('configuration').get('modelConfig').get('maxOutputTokens').hasError('min')" + *ngIf="aiModelForms.get('configuration').get('maxOutputTokens').hasError('min')" class="tb-error"> warning diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts index 5dd63d3f5b..04087dcb79 100644 --- a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts @@ -91,15 +91,13 @@ export class AIModelDialogComponent extends DialogComponent { this.provider = provider; - this.aiModelForms.get('configuration.modelConfig').reset({}); + // this.aiModelForms.get('configuration').reset({}); this.aiModelForms.get('configuration.providerConfig').reset({}); this.updateValidation(provider); }) diff --git a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts b/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts index 70896cdc6d..e7f0d8c174 100644 --- a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts @@ -54,15 +54,13 @@ export class CheckConnectivityDialogComponent extends DialogComponent, 'label'>, HasTenantId serviceAccountKey?: string; serviceAccountKeyFileName?: string }; - modelConfig: { - modelId: string; - temperature?: number | null; - topP?: number; - topK?: number; - frequencyPenalty?: number; - presencePenalty?: number; - maxOutputTokens?: number; - } + modelId: string; + temperature?: number | null; + topP?: number; + topK?: number; + frequencyPenalty?: number; + presencePenalty?: number; + maxOutputTokens?: number; } } @@ -131,7 +129,7 @@ export interface AiModelWithUserMsg { userMessage: { contents: Array<{contentType: string; text: string}>; } - chatModel: { + chatModelConfig: { modelType: string; provider: AiProvider providerConfig: { @@ -144,11 +142,11 @@ export interface AiModelWithUserMsg { serviceAccountKey?: string; serviceAccountKeyFileName?: string }; - modelConfig: { + // chatModelConfig: { modelId: string; maxRetries: number; timeoutSeconds: number; - } + // } } } diff --git a/ui-ngx/src/app/shared/models/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts index 3b9e5f5427..6e7ba24578 100644 --- a/ui-ngx/src/app/shared/models/entity-type.models.ts +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -51,7 +51,7 @@ export enum EntityType { MOBILE_APP_BUNDLE = 'MOBILE_APP_BUNDLE', MOBILE_APP = 'MOBILE_APP', CALCULATED_FIELD = 'CALCULATED_FIELD', - AI_MODEL_SETTINGS = 'AI_MODEL_SETTINGS', + AI_MODEL = 'AI_MODEL', } export enum AliasEntityType { @@ -496,7 +496,7 @@ export const entityTypeTranslations = new Map = [ EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, - EntityType.AI_MODEL_SETTINGS, + EntityType.AI_MODEL, ]; export const entityTypesWithoutRelatedData = new Set([ @@ -46,7 +46,7 @@ export const entityTypesWithoutRelatedData = new Set Date: Wed, 9 Jul 2025 20:05:06 +0300 Subject: [PATCH 4/8] UI: Refactoring --- ui-ngx/src/app/modules/common/modules-map.ts | 2 - .../check-connectivity-dialog.component.html | 0 .../check-connectivity-dialog.component.scss | 0 .../check-connectivity-dialog.component.ts | 4 +- .../home/components/home-components.module.ts | 3 + .../common/time-unit-input.component.html | 98 +++------ .../common/time-unit-input.component.ts | 29 ++- .../external/ai-config.component.html | 28 +-- .../rule-node/external/ai-config.component.ts | 36 ++-- .../ai-model/ai-model-table-config.resolve.ts | 11 +- .../ai-model-table-header.component.scss | 18 -- .../ai-model-table-header.component.ts | 2 +- .../ai-model/ai-model-dialog.component.html | 166 ++++++++------- .../ai-model/ai-model-dialog.component.ts | 54 ++--- .../models-list-autocomplete.component.ts | 4 +- .../src/app/shared/models/ai-model.models.ts | 201 ++++++++++++------ ui-ngx/src/app/shared/shared.module.ts | 3 - 17 files changed, 335 insertions(+), 324 deletions(-) rename ui-ngx/src/app/{shared => modules/home}/components/ai-model/check-connectivity-dialog.component.html (100%) rename ui-ngx/src/app/{shared => modules/home}/components/ai-model/check-connectivity-dialog.component.scss (100%) rename ui-ngx/src/app/{shared => modules/home}/components/ai-model/check-connectivity-dialog.component.ts (95%) delete mode 100644 ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index 997af07462..60a26e4211 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -337,7 +337,6 @@ import * as AggregationTypeSelectComponent from '@shared/components/time/aggrega import * as AggregationOptionsConfigComponent from '@shared/components/time/aggregation/aggregation-options-config-panel.component'; import * as IntervalOptionsConfigPanelComponent from '@shared/components/time/interval-options-config-panel.component'; import * as AIModelDialogComponent from '@shared/components/ai-model/ai-model-dialog.component'; -import * as CheckConnectivityDialogComponent from '@shared/components/ai-model/check-connectivity-dialog.component'; import * as ModelsListAutocompleteComponent from '@shared/components/ai-model/models-list-autocomplete.component'; import { IModulesMap } from '@modules/common/modules-map.models'; @@ -536,7 +535,6 @@ class ModulesMap implements IModulesMap { '@shared/components/image/multiple-gallery-image-input.component': MultipleGalleryImageInputComponent, '@shared/components/popover.service': TbPopoverService, '@shared/components/ai-model/ai-model-dialog.component': AIModelDialogComponent, - '@shared/components/ai-model/check-connectivity-dialog.component': CheckConnectivityDialogComponent, '@shared/components/ai-model/models-list-autocomplete.component': ModelsListAutocompleteComponent, diff --git a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html similarity index 100% rename from ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.html rename to ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html diff --git a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.scss b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.scss similarity index 100% rename from ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.scss rename to ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.scss diff --git a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts similarity index 95% rename from ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts rename to ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts index e7f0d8c174..3b6c96d426 100644 --- a/ui-ngx/src/app/shared/components/ai-model/check-connectivity-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts @@ -20,7 +20,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { Router } from '@angular/router'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { AiModel, AiModelWithUserMsg } from '@shared/models/ai-model.models'; +import { AiModel, AiModelWithUserMsg, ModelType } from '@shared/models/ai-model.models'; import { AiModelService } from '@core/http/ai-model.service'; export interface AIModelDialogData { @@ -55,7 +55,7 @@ export class CheckConnectivityDialogComponent extends DialogComponent
- @if (outlined) { -
-
{{ labelText }}
-
- - - - warning - - - warning - - - warning - - - - - @for (timeUnit of timeUnits; track timeUnit) { - {{ timeUnitTranslations.get(timeUnit) | translate }} - } - - -
+ + @if (labelText && !inlineField) { + {{ labelText }} + } + +
+
- } @else { - - {{ labelText }} - -
- -
+ @if (inlineField) { + + warning + + } @else { - - {{ requiredText }} + + {{ hasError }} - - {{ minErrorText }} - - - {{ maxErrorText }} - -
- + } + + + @if (!inlineField) { rule-node-config.units - - @for (timeUnit of timeUnits; track timeUnit) { - {{ timeUnitTranslations.get(timeUnit) | translate }} - } - - - } + } + + @for (timeUnit of timeUnits; track timeUnit) { + {{ timeUnitTranslations.get(timeUnit) | translate }} + } + +
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts index 2cd8e7d879..e31d9abf9e 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts @@ -30,7 +30,7 @@ import { isDefinedAndNotNull, isNumeric } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { coerceBoolean, coerceNumber } from '@shared/decorators/coercion'; import { DAY, HOUR, MINUTE, SECOND } from '@shared/models/time/time.models'; -import { SubscriptSizing } from '@angular/material/form-field'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; interface TimeUnitInputModel { time: number; @@ -55,9 +55,6 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, @Input() labelText: string; - @Input() - labelIconHintTooltipText: string; - @Input() @coerceBoolean() required: boolean; @@ -83,12 +80,11 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, subscriptSizing: SubscriptSizing = 'fixed'; @Input() - @coerceBoolean() - filterTimeUnitsByMaxTime = false; + appearance: MatFormFieldAppearance = 'fill'; @Input() @coerceBoolean() - outlined = false; + inlineField: boolean; timeUnits = Object.values(TimeUnit).filter(item => item !== TimeUnit.MILLISECONDS) as TimeUnit[]; @@ -115,12 +111,13 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, } ngOnInit() { - if (this.filterTimeUnitsByMaxTime && this.maxTime) { - if (this.maxTime < 60) { + if (this.maxTime) { + const maxTimeMs = this.maxTime * SECOND; + if (maxTimeMs < MINUTE) { this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.MINUTES && item !== TimeUnit.HOURS && item !== TimeUnit.DAYS); - } else if (this.maxTime < 3600) { + } else if (maxTimeMs < HOUR) { this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.HOURS && item !== TimeUnit.DAYS); - } else if (this.maxTime < 86400) { + } else if (maxTimeMs < DAY) { this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.DAYS); } } @@ -157,6 +154,16 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, }); } + get hasError(): string { + if (this.timeInputForm.get('time').hasError('required') && this.requiredText) { + return this.requiredText; + } else if (this.timeInputForm.get('time').hasError('min') && this.minErrorText) { + return this.minErrorText; + } else if (this.timeInputForm.get('time').hasError('max') && this.maxErrorText) { + return this.maxErrorText; + } + } + registerOnChange(fn: any) { this.propagateChange = fn; } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html index 8846858a8d..ef9dbe04a4 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html @@ -96,19 +96,21 @@ rule-node-config.ai.advanced-settings
- - +
+
{{ 'rule-node-config.ai.timeout' | translate }}
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts index 283abb636c..3b7f0a92d6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts @@ -20,15 +20,9 @@ import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/m import { EntityType } from '@shared/models/entity-type.models'; import { MatDialog } from '@angular/material/dialog'; import { AIModelDialogComponent, AIModelDialogData } from '@shared/components/ai-model/ai-model-dialog.component'; -import { AiModel, AiProvider } from '@shared/models/ai-model.models'; +import { AiModel, AiRuleNodeResponseFormatTypeOnlyText, ResponseFormat } from '@shared/models/ai-model.models'; import { deepTrim } from '@core/utils'; -enum ResponseFormat { - TEXT = 'TEXT', - JSON = 'JSON', - JSON_SCHEMA = 'JSON_SCHEMA' -} - @Component({ selector: 'tb-external-node-ai-config', templateUrl: './ai-config.component.html', @@ -53,14 +47,14 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { protected onConfigurationSet(configuration: RuleNodeConfiguration) { this.aiConfigForm = this.fb.group({ - modelSettingsId: [configuration ? configuration.modelSettingsId : null, [Validators.required]], - systemPrompt: [configuration ? configuration.systemPrompt : '', [Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], - userPrompt: [configuration ? configuration.userPrompt : '', [Validators.required, Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], + modelSettingsId: [configuration?.modelSettingsId ?? null, [Validators.required]], + systemPrompt: [configuration?.systemPrompt ?? '', [Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], + userPrompt: [configuration?.userPrompt ?? '', [Validators.required, Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], responseFormat: this.fb.group({ - type: [configuration ? configuration.responseFormat.type : ResponseFormat.JSON, []], - schema: [configuration ? configuration.responseFormat.schema : null, []], + type: [configuration?.responseFormat?.type ?? ResponseFormat.JSON, []], + schema: [configuration?.responseFormat?.schema ?? null, [Validators.required]], }), - timeoutSeconds: [configuration ? configuration.timeoutSeconds : 60, []] + timeoutSeconds: [configuration?.timeoutSeconds ?? 60, []] }); } @@ -69,15 +63,11 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { } protected updateValidators(emitEvent: boolean) { - const responseFormatType = this.aiConfigForm.get('responseFormat.type').value; - if (responseFormatType === ResponseFormat.JSON_SCHEMA) { - this.aiConfigForm.get('responseFormat.schema').setValidators([Validators.required]); - this.aiConfigForm.get('responseFormat.schema').enable(); + if (this.aiConfigForm.get('responseFormat.type').value === ResponseFormat.JSON_SCHEMA) { + this.aiConfigForm.get('responseFormat.schema').enable({emitEvent: false}); } else { - this.aiConfigForm.get('responseFormat.schema').setValidators([]); - this.aiConfigForm.get('responseFormat.schema').disable(); + this.aiConfigForm.get('responseFormat.schema').disable({emitEvent: false}); } - this.aiConfigForm.get('responseFormat.schema').updateValueAndValidity({emitEvent}); } protected prepareOutputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration { @@ -86,9 +76,7 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { onEntityChange($event: AiModel) { if ($event) { - if ($event.configuration.provider === AiProvider.AMAZON_BEDROCK || - $event.configuration.provider === AiProvider.ANTHROPIC || - $event.configuration.provider === AiProvider.GITHUB_MODELS) { + if (AiRuleNodeResponseFormatTypeOnlyText.includes($event.configuration.provider)) { this.aiConfigForm.get('responseFormat.type').patchValue(ResponseFormat.TEXT, {emitEvent: false}); this.aiConfigForm.get('responseFormat.type').disable({emitEvent: false}); } @@ -97,7 +85,7 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { } } - createModelAi(formControl: string,) { + createModelAi(formControl: string) { this.dialog.open(AIModelDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts index 6e9bde0b60..3f3ded2abb 100644 --- a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts @@ -79,6 +79,11 @@ export class AiModelsTableConfigResolver { this.config.entitiesFetchFunction = pageLink => this.aiModelService.getAiModels(pageLink); this.config.cellActionDescriptors = this.configureCellActions(); + + this.config.handleRowClick = ($event, model) => { + this.editModel(model); + return true; + }; } resolve(_route: ActivatedRouteSnapshot): EntityTableConfig { @@ -97,11 +102,7 @@ export class AiModelsTableConfigResolver { } private editModel(AIModel: AiModel): void { - this.addModel(AIModel, false).subscribe((res) => { - if (res) { - this.config.updateData(); - } - }); + this.addModel(AIModel, false).subscribe(); } private addModel(AIModel: AiModel, isAdd = false): Observable { diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss deleted file mode 100644 index acb9e2dc87..0000000000 --- a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.scss +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright © 2016-2025 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -:host{ - width: 100%; -} diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts index 4fbc906b80..48889dc877 100644 --- a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts @@ -23,7 +23,7 @@ import { AiModel } from '@shared/models/ai-model.models'; @Component({ selector: 'tb-ai-model-table-header', templateUrl: './ai-model-table-header.component.html', - styleUrls: ['./ai-model-table-header.component.scss'] + styleUrls: [] }) export class AiModelTableHeaderComponent extends EntityTableHeaderComponent { diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html index 97eeb79f02..2a5a34a2c9 100644 --- a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html +++ b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html @@ -56,7 +56,7 @@
- @if (provider === aiProvider.GITHUB_MODELS) { + @if (AiModelMap.get(provider).providerFieldsList.includes('personalAccessToken')) { ai-models.personal-access-token @@ -65,24 +65,29 @@ {{ 'ai-models.personal-access-token-required' | translate }} - } @else if (provider === aiProvider.GOOGLE_VERTEX_AI_GEMINI) { - + } + @if (AiModelMap.get(provider).providerFieldsList.includes('projectId')) { + ai-models.project-id {{ 'ai-models.project-id-required' | translate }} - + } + @if (AiModelMap.get(provider).providerFieldsList.includes('location')) { + ai-models.location {{ 'ai-models.location-required' | translate }} + } + @if (AiModelMap.get(provider).providerFieldsList.includes('serviceAccountKey')) { - } @else { - @if (provider === aiProvider.AZURE_OPENAI) { - - ai-models.endpoint - - - {{ 'ai-models.endpoint-required' | translate }} - - - - ai-models.service-version - - - } + } + @if (AiModelMap.get(provider).providerFieldsList.includes('endpoint')) { + + ai-models.endpoint + + + {{ 'ai-models.endpoint-required' | translate }} + + + } + @if (AiModelMap.get(provider).providerFieldsList.includes('serviceVersion')) { + + ai-models.service-version + + + } + @if (AiModelMap.get(provider).providerFieldsList.includes('apiKey')) { ai-models.api-key @@ -128,43 +136,46 @@ [errorText]="(provider === aiProvider.AZURE_OPENAI ? 'ai-models.deployment-name-required': 'ai-models.model-id-required') | translate">
-
-
- {{ 'ai-models.temperature' | translate }} -
- - - - warning - - -
-
-
- {{ 'ai-models.top-p' | translate }} + @if (AiModelMap.get(provider).modelFieldsList.includes('temperature')) { +
+
+ {{ 'ai-models.temperature' | translate }} +
+ + + + warning + +
- - - +
+ {{ 'ai-models.top-p' | translate }} +
+ + + - warning - - -
- - @if (provider === aiProvider.GOOGLE_AI_GEMINI || provider === aiProvider.GOOGLE_VERTEX_AI_GEMINI || provider === aiProvider.ANTHROPIC) { + class="tb-error"> + warning + + +
+ } + @if (AiModelMap.get(provider).modelFieldsList.includes('topK')) {
{{ 'ai-models.top-k' | translate }} @@ -183,45 +194,48 @@
} - - @if (provider !== aiProvider.ANTHROPIC && provider !== aiProvider.AMAZON_BEDROCK) { + @if (AiModelMap.get(provider).modelFieldsList.includes('presencePenalty')) {
{{ 'ai-models.presence-penalty' | translate }}
-
+ } + + @if (AiModelMap.get(provider).modelFieldsList.includes('frequencyPenalty')) {
{{ 'ai-models.frequency-penalty' | translate }}
-
} - -
-
- {{ 'ai-models.max-output-token' | translate }} + @if (AiModelMap.get(provider).modelFieldsList.includes('maxOutputTokens')) { +
+
+ {{ 'ai-models.max-output-token' | translate }} +
+ + + + warning + +
- - - - warning - - -
+ }
diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts index 04087dcb79..d9ae3eaa69 100644 --- a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts @@ -25,9 +25,16 @@ import { StepperOrientation } from '@angular/cdk/stepper'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { EntityType } from '@shared/models/entity-type.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { AiModel, AiProvider, AiProviderTranslations, AiProviderWithApiKey } from '@shared/models/ai-model.models'; +import { + AiModel, + AiModelMap, + AiProvider, + AiProviderTranslations, + ModelType, + ProviderFieldsAllList +} from '@shared/models/ai-model.models'; import { AiModelService } from '@core/http/ai-model.service'; -import { CheckConnectivityDialogComponent } from '@shared/components/ai-model/check-connectivity-dialog.component'; +import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component'; export interface AIModelDialogData { AIModel?: AiModel; @@ -53,7 +60,7 @@ export class AIModelDialogComponent extends DialogComponent { this.provider = provider; - // this.aiModelForms.get('configuration').reset({}); + this.aiModelForms.get('configuration.modelId').reset({}); this.aiModelForms.get('configuration.providerConfig').reset({}); this.updateValidation(provider); }) @@ -114,35 +121,10 @@ export class AIModelDialogComponent extends DialogComponent + this.aiModelForms.get('configuration.providerConfig') + .get(key)[AiModelMap.get(provider).providerFieldsList.includes(key) ? 'enable' : 'disable']() + ) } cancel(): void { diff --git a/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts b/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts index a5cbdb332d..3e3c0cbb66 100644 --- a/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts @@ -99,7 +99,7 @@ export class ModelsListAutocompleteComponent implements ControlValueAccessor, On tap(value => this.updateView(value)), map(value => { const search = value ? value.toLowerCase() : ''; - const options = this.provider ? AiModelMap.get(this.provider) || [] : []; + const options = this.provider ? AiModelMap.get(this.provider).modelList || [] : []; return search ? options.filter(option => option.toLowerCase().includes(search)) : options; }) ); @@ -150,7 +150,7 @@ export class ModelsListAutocompleteComponent implements ControlValueAccessor, On } clear() { - this.selectionFormControl.patchValue(null, {emitEvent: false}); + this.selectionFormControl.patchValue(null, {emitEvent: true}); this.propagateChange(null); this.modelValue = null; setTimeout(() => { diff --git a/ui-ngx/src/app/shared/models/ai-model.models.ts b/ui-ngx/src/app/shared/models/ai-model.models.ts index cd844f57e6..1eb69d867e 100644 --- a/ui-ngx/src/app/shared/models/ai-model.models.ts +++ b/ui-ngx/src/app/shared/models/ai-model.models.ts @@ -19,7 +19,7 @@ import { HasTenantId } from '@shared/models/entity.models'; import { AiModelId } from '@shared/models/id/ai-model-id'; export interface AiModel extends Omit, 'label'>, HasTenantId, ExportableEntity { - modelType: string; + modelType: ModelType; configuration: { provider: AiProvider providerConfig: { @@ -30,10 +30,10 @@ export interface AiModel extends Omit, 'label'>, HasTenantId projectId?: string; location?: string; serviceAccountKey?: string; - serviceAccountKeyFileName?: string + fileName?: string }; modelId: string; - temperature?: number | null; + temperature?: number; topP?: number; topK?: number; frequencyPenalty?: number; @@ -42,6 +42,10 @@ export interface AiModel extends Omit, 'label'>, HasTenantId } } +export enum ModelType { + CHAT = 'CHAT' +} + export enum AiProvider { OPENAI = 'OPENAI', AZURE_OPENAI = 'AZURE_OPENAI', @@ -53,15 +57,6 @@ export enum AiProvider { GITHUB_MODELS = 'GITHUB_MODELS' } -export const AiProviderWithApiKey: AiProvider[] = [ - AiProvider.OPENAI, - AiProvider.AZURE_OPENAI, - AiProvider.GOOGLE_AI_GEMINI, - AiProvider.MISTRAL_AI, - AiProvider.ANTHROPIC, - AiProvider.AMAZON_BEDROCK -] - export const AiProviderTranslations = new Map( [ [AiProvider.OPENAI , 'ai-models.ai-providers.openai'], @@ -75,55 +70,130 @@ export const AiProviderTranslations = new Map( ] ); -export const AiModelMap = new Map( +export const ProviderFieldsAllList = [ + 'apiKey', + 'personalAccessToken', + 'projectId', + 'location', + 'serviceAccountKey', + 'fileName', + 'endpoint', + 'serviceVersion' +]; + +export const ModelFieldsAllList = ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens']; + +export const AiModelMap = new Map([ [ - [AiProvider.OPENAI , [ - 'o4-mini', - 'o3-pro', - 'o3', - 'o3-mini', - 'o1', - 'gpt-4.1', - 'gpt-4.1-mini', - 'gpt-4.1-nano', - 'gpt-4o', - 'gpt-4o-mini' - ]], - [AiProvider.AZURE_OPENAI , []], - [AiProvider.GOOGLE_AI_GEMINI , [ - 'gemini-2.5-pro', - 'gemini-2.5-flash', - 'gemini-2.0-flash', - 'gemini-2.0-flash-lite', - ]], - [AiProvider.GOOGLE_VERTEX_AI_GEMINI , [ - 'gemini-2.5-pro', - 'gemini-2.5-flash', - 'gemini-2.0-flash', - 'gemini-2.0-flash-lite', - ]], - [AiProvider.MISTRAL_AI , [ - 'magistral-medium-latest', - 'magistral-small-latest', - 'mistral-large-latest', - 'mistral-medium-latest', - 'mistral-small-latest', - 'pixtral-large-latest', - 'ministral-8b-latest', - 'ministral-3b-latest', - 'open-mistral-nemo' - ]], - [AiProvider.ANTHROPIC , [ - 'claude-opus-4-0', - 'claude-sonnet-4-0', - 'claude-3-7-sonnet-latest', - 'claude-3-5-sonnet-latest', - 'claude-3-5-haiku-latest' - ]], - [AiProvider.AMAZON_BEDROCK , []], - [AiProvider.GITHUB_MODELS , []] - ] -); + AiProvider.OPENAI, + { + modelList: [ + 'o4-mini', + 'o3-pro', + 'o3', + 'o3-mini', + 'o1', + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'gpt-4o', + 'gpt-4o-mini', + ], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.AZURE_OPENAI, + { + modelList: [], + providerFieldsList: ['apiKey', 'endpoint', 'serviceVersion'], + modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.GOOGLE_AI_GEMINI, + { + modelList: [ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-2.0-flash-lite', + ], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.GOOGLE_VERTEX_AI_GEMINI, + { + modelList: [ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-2.0-flash-lite', + ], + providerFieldsList: ['projectId', 'location', 'serviceAccountKey', 'fileName'], + modelFieldsList: ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.MISTRAL_AI, + { + modelList: [ + 'magistral-medium-latest', + 'magistral-small-latest', + 'mistral-large-latest', + 'mistral-medium-latest', + 'mistral-small-latest', + 'pixtral-large-latest', + 'ministral-8b-latest', + 'ministral-3b-latest', + 'open-mistral-nemo', + ], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.ANTHROPIC, + { + modelList: [ + 'claude-opus-4-0', + 'claude-sonnet-4-0', + 'claude-3-7-sonnet-latest', + 'claude-3-5-sonnet-latest', + 'claude-3-5-haiku-latest', + ], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'topK', 'maxOutputTokens'], + }, + ], + [ + AiProvider.AMAZON_BEDROCK, + { + modelList: [], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'maxOutputTokens'], + }, + ], + [ + AiProvider.GITHUB_MODELS, + { + modelList: [], + providerFieldsList: ['personalAccessToken'], + modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], +]); + +export const AiRuleNodeResponseFormatTypeOnlyText: AiProvider[] = [AiProvider.AMAZON_BEDROCK, AiProvider.ANTHROPIC, AiProvider.GITHUB_MODELS]; + +export enum ResponseFormat { + TEXT = 'TEXT', + JSON = 'JSON', + JSON_SCHEMA = 'JSON_SCHEMA' +} export interface AiModelWithUserMsg { userMessage: { @@ -140,17 +210,14 @@ export interface AiModelWithUserMsg { projectId?: string; location?: string; serviceAccountKey?: string; - serviceAccountKeyFileName?: string + fileName?: string }; - // chatModelConfig: { - modelId: string; - maxRetries: number; - timeoutSeconds: number; - // } + modelId: string; + maxRetries: number; + timeoutSeconds: number; } } - export interface CheckConnectivityResult { status: string; errorDetails: string; diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 8f3de3c26a..0a559361c8 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -230,7 +230,6 @@ import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe'; import { MqttVersionSelectComponent } from '@shared/components/mqtt-version-select.component'; import { AIModelDialogComponent } from '@shared/components/ai-model/ai-model-dialog.component'; import { ModelsListAutocompleteComponent } from '@shared/components/ai-model/models-list-autocomplete.component'; -import { CheckConnectivityDialogComponent } from '@shared/components/ai-model/check-connectivity-dialog.component'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -448,7 +447,6 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) MqttVersionSelectComponent, AIModelDialogComponent, ModelsListAutocompleteComponent, - CheckConnectivityDialogComponent, ], imports: [ CommonModule, @@ -715,7 +713,6 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) MqttVersionSelectComponent, AIModelDialogComponent, ModelsListAutocompleteComponent, - CheckConnectivityDialogComponent, ] }) export class SharedModule { } From 64f0da33655ae8d7569b140649bfafff92b9530a Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Thu, 10 Jul 2025 13:32:39 +0300 Subject: [PATCH 5/8] UI: ref after review --- ui-ngx/src/app/core/services/menu.models.ts | 2 +- ui-ngx/src/app/modules/common/modules-map.ts | 8 +- .../ai-model/ai-model-dialog.component.html | 41 ++--- .../ai-model/ai-model-dialog.component.scss | 0 .../ai-model/ai-model-dialog.component.ts | 30 +++- .../check-connectivity-dialog.component.html | 9 +- .../check-connectivity-dialog.component.scss | 7 +- .../check-connectivity-dialog.component.ts | 6 +- .../home/components/home-components.module.ts | 3 + .../external/ai-config.component.html | 5 +- .../rule-node/external/ai-config.component.ts | 4 +- .../ai-model/ai-model-table-config.resolve.ts | 9 +- .../models-list-autocomplete.component.html | 41 ----- .../models-list-autocomplete.component.ts | 161 ------------------ .../json-object-edit.component.html | 7 + .../components/json-object-edit.component.ts | 2 + .../string-autocomplete.component.html | 2 +- .../string-autocomplete.component.ts | 3 + ui-ngx/src/app/shared/shared.module.ts | 6 - .../assets/locale/locale.constant-en_US.json | 1 + 20 files changed, 88 insertions(+), 259 deletions(-) rename ui-ngx/src/app/{shared => modules/home}/components/ai-model/ai-model-dialog.component.html (88%) rename ui-ngx/src/app/{shared => modules/home}/components/ai-model/ai-model-dialog.component.scss (100%) rename ui-ngx/src/app/{shared => modules/home}/components/ai-model/ai-model-dialog.component.ts (86%) delete mode 100644 ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.html delete mode 100644 ui-ngx/src/app/shared/components/ai-model/models-list-autocomplete.component.ts diff --git a/ui-ngx/src/app/core/services/menu.models.ts b/ui-ngx/src/app/core/services/menu.models.ts index dc568c148e..b660b0ab0d 100644 --- a/ui-ngx/src/app/core/services/menu.models.ts +++ b/ui-ngx/src/app/core/services/menu.models.ts @@ -794,6 +794,7 @@ const defaultUserMenuMap = new Map([ {id: MenuId.home}, {id: MenuId.alarms}, {id: MenuId.dashboards}, + {id: MenuId.ai_models}, { id: MenuId.entities, pages: [ @@ -852,7 +853,6 @@ const defaultUserMenuMap = new Map([ {id: MenuId.notification_rules} ] }, - {id: MenuId.ai_models}, { id: MenuId.mobile_center, pages: [ diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index 60a26e4211..0890b9e623 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -336,8 +336,7 @@ import * as DatapointsLimitComponent from '@shared/components/time/datapoints-li import * as AggregationTypeSelectComponent from '@shared/components/time/aggregation/aggregation-type-select.component'; import * as AggregationOptionsConfigComponent from '@shared/components/time/aggregation/aggregation-options-config-panel.component'; import * as IntervalOptionsConfigPanelComponent from '@shared/components/time/interval-options-config-panel.component'; -import * as AIModelDialogComponent from '@shared/components/ai-model/ai-model-dialog.component'; -import * as ModelsListAutocompleteComponent from '@shared/components/ai-model/models-list-autocomplete.component'; +import * as AIModelDialogComponent from '@home/components/ai-model/ai-model-dialog.component'; import { IModulesMap } from '@modules/common/modules-map.models'; import { Observable, of } from 'rxjs'; @@ -534,8 +533,6 @@ class ModulesMap implements IModulesMap { '@shared/components/image/gallery-image-input.component': GalleryImageInputComponent, '@shared/components/image/multiple-gallery-image-input.component': MultipleGalleryImageInputComponent, '@shared/components/popover.service': TbPopoverService, - '@shared/components/ai-model/ai-model-dialog.component': AIModelDialogComponent, - '@shared/components/ai-model/models-list-autocomplete.component': ModelsListAutocompleteComponent, '@home/components/alarm/alarm-filter-config.component': AlarmFilterConfigComponent, @@ -672,7 +669,8 @@ class ModulesMap implements IModulesMap { '@home/components/dashboard-page/dashboard-image-dialog.component': DashboardImageDialogComponent, '@home/components/widget/widget-container.component': WidgetContainerComponent, '@home/components/profile/queue/tenant-profile-queues.component': TenantProfileQueuesComponent, - '@home/components/queue/queue-form.component': QueueFormComponent + '@home/components/queue/queue-form.component': QueueFormComponent, + '@home/components/ai-model/ai-model-dialog.component': AIModelDialogComponent, }; init(): Observable { diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html similarity index 88% rename from ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html rename to ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html index 2a5a34a2c9..4201c5a418 100644 --- a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html @@ -56,7 +56,7 @@
- @if (AiModelMap.get(provider).providerFieldsList.includes('personalAccessToken')) { + @if (providerFieldsList.includes('personalAccessToken')) { ai-models.personal-access-token @@ -66,7 +66,7 @@ } - @if (AiModelMap.get(provider).providerFieldsList.includes('projectId')) { + @if (providerFieldsList.includes('projectId')) { ai-models.project-id @@ -75,7 +75,7 @@ } - @if (AiModelMap.get(provider).providerFieldsList.includes('location')) { + @if (providerFieldsList.includes('location')) { ai-models.location @@ -84,7 +84,7 @@ } - @if (AiModelMap.get(provider).providerFieldsList.includes('serviceAccountKey')) { + @if (providerFieldsList.includes('serviceAccountKey')) { } - @if (AiModelMap.get(provider).providerFieldsList.includes('endpoint')) { + @if (providerFieldsList.includes('endpoint')) { ai-models.endpoint @@ -106,13 +106,13 @@ } - @if (AiModelMap.get(provider).providerFieldsList.includes('serviceVersion')) { + @if (providerFieldsList.includes('serviceVersion')) { ai-models.service-version } - @if (AiModelMap.get(provider).providerFieldsList.includes('apiKey')) { + @if (providerFieldsList.includes('apiKey')) { ai-models.api-key @@ -129,14 +129,17 @@
ai-models.configuration
- - + +
- @if (AiModelMap.get(provider).modelFieldsList.includes('temperature')) { + @if (modelFieldsList.includes('temperature')) {
{{ 'ai-models.temperature' | translate }} @@ -155,7 +158,7 @@
} - @if (AiModelMap.get(provider).modelFieldsList.includes('topP')) { + @if (modelFieldsList.includes('topP')) {
{{ 'ai-models.top-p' | translate }} @@ -175,7 +178,7 @@
} - @if (AiModelMap.get(provider).modelFieldsList.includes('topK')) { + @if (modelFieldsList.includes('topK')) {
{{ 'ai-models.top-k' | translate }} @@ -194,7 +197,7 @@
} - @if (AiModelMap.get(provider).modelFieldsList.includes('presencePenalty')) { + @if (modelFieldsList.includes('presencePenalty')) {
{{ 'ai-models.presence-penalty' | translate }} @@ -206,7 +209,7 @@
} - @if (AiModelMap.get(provider).modelFieldsList.includes('frequencyPenalty')) { + @if (modelFieldsList.includes('frequencyPenalty')) {
{{ 'ai-models.frequency-penalty' | translate }} @@ -217,7 +220,7 @@
} - @if (AiModelMap.get(provider).modelFieldsList.includes('maxOutputTokens')) { + @if (modelFieldsList.includes('maxOutputTokens')) {
{{ 'ai-models.max-output-token' | translate }} diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.scss b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.scss similarity index 100% rename from ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.scss rename to ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.scss diff --git a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts similarity index 86% rename from ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts rename to ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts index d9ae3eaa69..a5fca8d122 100644 --- a/ui-ngx/src/app/shared/components/ai-model/ai-model-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts @@ -20,7 +20,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { Router } from '@angular/router'; import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { StepperOrientation } from '@angular/cdk/stepper'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { EntityType } from '@shared/models/entity-type.models'; @@ -35,6 +35,7 @@ import { } from '@shared/models/ai-model.models'; import { AiModelService } from '@core/http/ai-model.service'; import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component'; +import { map } from 'rxjs/operators'; export interface AIModelDialogData { AIModel?: AiModel; @@ -112,7 +113,7 @@ export class AIModelDialogComponent extends DialogComponent { this.provider = provider; - this.aiModelForms.get('configuration.modelId').reset({}); + this.aiModelForms.get('configuration.modelId').reset(''); this.aiModelForms.get('configuration.providerConfig').reset({}); this.updateValidation(provider); }) @@ -120,11 +121,28 @@ export class AIModelDialogComponent extends DialogComponent> { + const search = searchText ? searchText?.toLowerCase() : ''; + return of(this.provider ? AiModelMap.get(this.provider).modelList || [] : []).pipe( + map(name => name?.filter(option => option.toLowerCase().includes(search))), + ); + } + private updateValidation(provider: AiProvider) { - ProviderFieldsAllList.forEach(key => - this.aiModelForms.get('configuration.providerConfig') - .get(key)[AiModelMap.get(provider).providerFieldsList.includes(key) ? 'enable' : 'disable']() - ) + ProviderFieldsAllList.forEach(key => { + if (AiModelMap.get(provider).providerFieldsList.includes(key)) { + this.aiModelForms.get('configuration.providerConfig').get(key).enable(); + } else { + this.aiModelForms.get('configuration.providerConfig').get(key).disable(); + } + }) + } + + get providerFieldsList(): string[] { + return AiModelMap.get(this.provider).providerFieldsList; + } + get modelFieldsList(): string[] { + return AiModelMap.get(this.provider).modelFieldsList; } cancel(): void { diff --git a/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html index 9ead874275..afc881b10b 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html @@ -15,15 +15,15 @@ limitations under the License. --> -
-

ai-models.check-connectivity

+ +

ai-models.check-connectivity

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