UI: Add new resource type text

This commit is contained in:
ArtemDzhereleiko 2025-09-02 14:30:09 +03:00 committed by Vladyslav_Prykhodko
parent 5a6ddce8f5
commit b9a9348eac
17 changed files with 310 additions and 21 deletions

View File

@ -100,6 +100,7 @@ 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';
import { ResourceType } from "@shared/models/resource.models";
@Injectable({
providedIn: 'root'
@ -297,6 +298,11 @@ export class EntityService {
(id) => this.ruleChainService.getRuleChain(id, config),
entityIds);
break;
case EntityType.TB_RESOURCE:
observable = this.getEntitiesByIdsObservable(
(id) => this.resourceService.getResource(id, config),
entityIds);
break;
}
return observable;
}
@ -472,7 +478,7 @@ export class EntityService {
break;
case EntityType.TB_RESOURCE:
pageLink.sortOrder.property = 'title';
entitiesObservable = this.resourceService.getTenantResources(pageLink, config);
entitiesObservable = this.resourceService.getTenantResources(pageLink, subType as ResourceType, config);
break;
case EntityType.QUEUE_STATS:
pageLink.sortOrder.property = 'createdTime';

View File

@ -47,8 +47,12 @@ export class ResourceService {
return this.http.get<PageData<ResourceInfo>>(url, defaultHttpOptionsFromConfig(config));
}
public getTenantResources(pageLink: PageLink, config?: RequestConfig): Observable<PageData<ResourceInfo>> {
return this.http.get<PageData<ResourceInfo>>(`/api/resource/tenant${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config));
public getTenantResources(pageLink: PageLink, resourceType?: ResourceType, config?: RequestConfig): Observable<PageData<ResourceInfo>> {
let url = `/api/resource${pageLink.toQuery()}`;
if (isNotEmptyStr(resourceType)) {
url += `&resourceType=${resourceType}`;
}
return this.http.get<PageData<ResourceInfo>>(url, defaultHttpOptionsFromConfig(config));
}
public getResource(resourceId: string, config?: RequestConfig): Observable<Resource> {

View File

@ -205,6 +205,8 @@ import {
} from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component';
import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component';
import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialog.component';
import { ResourcesDialogComponent } from "@home/components/resources/resources-dialog.component";
import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component";
@NgModule({
declarations:
@ -358,6 +360,8 @@ import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialo
CalculatedFieldTestArgumentsComponent,
CheckConnectivityDialogComponent,
AIModelDialogComponent,
ResourcesDialogComponent,
ResourcesLibraryComponent,
],
imports: [
CommonModule,
@ -505,6 +509,8 @@ import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialo
CalculatedFieldTestArgumentsComponent,
CheckConnectivityDialogComponent,
AIModelDialogComponent,
ResourcesDialogComponent,
ResourcesLibraryComponent,
],
providers: [
WidgetComponentService,

View File

@ -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.
-->
<form (ngSubmit)="save()" style="width: 600px;">
<mat-toolbar color="primary">
<h2>{{ 'resource.add' | translate }}</h2>
<span class="flex-1"></span>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content>
<tb-resources-library #resourcesComponent
[standalone]="true"
[entity]="resources"
[defaultResourceType]="ResourceType.TEXT"
[resourceTypes]="[ResourceType.TEXT]"
[isEdit]="true">
</tb-resources-library>
</div>
<div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary"
type="button"
cdkFocusInitial
[disabled]="(isLoading$ | async)"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async) || resourcesComponent.entityForm?.invalid || !resourcesComponent.entityForm?.dirty">
{{ (isAdd ? 'action.add' : 'action.save') | translate }}
</button>
</div>
</form>

View File

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

View File

@ -0,0 +1,113 @@
///
/// 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 { AfterViewInit, Component, Inject, SkipSelf, ViewChild } 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 { FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { EntityType } from '@shared/models/entity-type.models';
import { map } from 'rxjs/operators';
import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component";
import { ErrorStateMatcher } from "@angular/material/core";
import { Resource, ResourceType } from "@shared/models/resource.models";
import { ResourceService } from "@core/http/resource.service";
export interface ResourcesDialogData {
resources?: Resource;
isAdd?: boolean;
}
@Component({
selector: 'tb-resources-dialog',
templateUrl: './resources-dialog.component.html',
providers: [{provide: ErrorStateMatcher, useExisting: ResourcesDialogComponent}],
styleUrls: ['./resources-dialog.component.scss']
})
export class ResourcesDialogComponent extends DialogComponent<ResourcesDialogComponent, Resource> implements ErrorStateMatcher, AfterViewInit {
readonly entityType = EntityType;
ResourceType = ResourceType;
isAdd = false;
submitted = false;
resources: Resource;
@ViewChild('resourcesComponent', {static: true}) resourcesComponent: ResourcesLibraryComponent;
constructor(protected store: Store<AppState>,
protected router: Router,
protected dialogRef: MatDialogRef<ResourcesDialogComponent, Resource>,
@Inject(MAT_DIALOG_DATA) public data: ResourcesDialogData,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher,
private resourceService: ResourceService) {
super(store, router, dialogRef);
if (this.data.isAdd) {
this.isAdd = true;
}
if (this.data.resources) {
this.resources = this.data.resources;
}
}
ngAfterViewInit(): void {
if (this.isAdd) {
setTimeout(() => {
this.resourcesComponent.entityForm.markAsDirty();
}, 0);
}
}
isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted);
return originalErrorState || customErrorState;
}
cancel(): void {
this.dialogRef.close(null);
}
save(): void {
this.submitted = true;
if (this.resourcesComponent.entityForm.valid) {
const resource = {...this.resourcesComponent.entityFormValue()};
if (Array.isArray(resource.data)) {
const resources = [];
resource.data.forEach((data, index) => {
resources.push({
resourceType: resource.resourceType,
data,
fileName: resource.fileName[index],
title: resource.title
});
});
this.resourceService.saveResources(resources, {resendRequest: true}).pipe(
map((response) => response[0])
).subscribe(result => this.dialogRef.close(result));
} else {
this.resourceService.saveResource(resource).subscribe(result => this.dialogRef.close(result));
}
}
}
}

View File

@ -15,7 +15,7 @@
limitations under the License.
-->
<div class="tb-details-buttons xs:flex xs:flex-col">
<div class="tb-details-buttons xs:flex xs:flex-col" *ngIf="!standalone">
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'open')"

View File

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, Inject, Input, OnDestroy, OnInit, Optional } from '@angular/core';
import { Subject } from 'rxjs';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
@ -40,8 +40,16 @@ import { getCurrentAuthState } from '@core/auth/auth.selectors';
})
export class ResourcesLibraryComponent extends EntityComponent<Resource> implements OnInit, OnDestroy {
@Input()
standalone = false;
@Input()
resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.TEXT];
@Input()
defaultResourceType = ResourceType.LWM2M_MODEL;
readonly resourceType = ResourceType;
readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS];
readonly resourceTypesTranslationMap = ResourceTypeTranslationMap;
readonly maxResourceSize = getCurrentAuthState(this.store).maxResourceSize;
@ -49,8 +57,8 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
constructor(protected store: Store<AppState>,
protected translate: TranslateService,
@Inject('entity') protected entityValue: Resource,
@Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<Resource>,
@Optional() @Inject('entity') protected entityValue: Resource,
@Optional() @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<Resource>,
public fb: FormBuilder,
protected cd: ChangeDetectorRef) {
super(store, fb, entityValue, entitiesTableConfigValue, cd);
@ -138,7 +146,7 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
private observeResourceTypeChange(): void {
this.entityForm.get('resourceType').valueChanges.pipe(
startWith(ResourceType.LWM2M_MODEL),
startWith(this.defaultResourceType || ResourceType.LWM2M_MODEL),
takeUntil(this.destroy$)
).subscribe((type: ResourceType) => this.onResourceTypeChange(type));
}

View File

@ -70,6 +70,16 @@
{{ 'rule-node-config.ai.user-prompt-blank' | translate }}
</mat-error>
</mat-form-field>
<tb-entity-list
class="flex-1"
allowCreateNew
placeholderText="{{ 'rule-node-config.ai.ai-resources' | translate }}"
[inlineField]="true"
[entityType]="EntityType.TB_RESOURCE"
[subType]="ResourceType.TEXT"
(createNew)="createAiResources($event, 'resourceIds')"
formControlName="resourceIds">
</tb-entity-list>
</div>
</mat-expansion-panel>
</div>

View File

@ -24,6 +24,8 @@ import { AiModel, AiRuleNodeResponseFormatTypeOnlyText, ResponseFormat } from '@
import { deepTrim } from '@core/utils';
import { TranslateService } from '@ngx-translate/core';
import { jsonRequired } from '@shared/components/json-object-edit.component';
import { Resource, ResourceType } from "@shared/models/resource.models";
import { ResourcesDialogComponent, ResourcesDialogData } from "@home/components/resources/resources-dialog.component";
@Component({
selector: 'tb-external-node-ai-config',
@ -38,6 +40,9 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent {
responseFormat = ResponseFormat;
EntityType = EntityType;
ResourceType = ResourceType;
constructor(private fb: UntypedFormBuilder,
private translate: TranslateService,
private dialog: MatDialog) {
@ -53,6 +58,7 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent {
modelId: [configuration?.modelId ?? null, [Validators.required]],
systemPrompt: [configuration?.systemPrompt ?? '', [Validators.maxLength(500_000), Validators.pattern(/.*\S.*/)]],
userPrompt: [configuration?.userPrompt ?? '', [Validators.required, Validators.maxLength(500_000), Validators.pattern(/.*\S.*/)]],
resourceIds: [configuration?.resourceIds ?? []],
responseFormat: this.fb.group({
type: [configuration?.responseFormat?.type ?? ResponseFormat.JSON, []],
schema: [configuration?.responseFormat?.schema ?? null, [jsonRequired]],
@ -116,5 +122,23 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent {
this.aiConfigForm.get(formControl).markAsDirty();
}
});
};
createAiResources(name: string, formControl: string) {
this.dialog.open<ResourcesDialogComponent, ResourcesDialogData, Resource>(ResourcesDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
resources: {title: name, resourceType: ResourceType.TEXT},
isAdd: true
}
}).afterClosed()
.subscribe((resource) => {
if (resource) {
const resourceIds = [...(this.aiConfigForm.get(formControl).value || []), resource.id.id];
this.aiConfigForm.get(formControl).patchValue(resourceIds);
this.aiConfigForm.get(formControl).markAsDirty();
}
});
}
}

View File

@ -26,7 +26,6 @@ import { HomeComponentsModule } from '@modules/home/components/home-components.m
import { SmsProviderComponent } from '@home/pages/admin/sms-provider.component';
import { SendTestSmsDialogComponent } from '@home/pages/admin/send-test-sms-dialog.component';
import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component';
import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component';
import { ResourceTabsComponent } from '@home/pages/admin/resource/resource-tabs.component';
import { ResourcesTableHeaderComponent } from '@home/pages/admin/resource/resources-table-header.component';
import { QueueComponent } from '@home/pages/admin/queue/queue.component';
@ -49,7 +48,6 @@ import { ResourceLibraryTabsComponent } from '@home/pages/admin/resource/resourc
SendTestSmsDialogComponent,
SecuritySettingsComponent,
HomeSettingsComponent,
ResourcesLibraryComponent,
ResourceTabsComponent,
ResourceLibraryTabsComponent,
ResourcesTableHeaderComponent,

View File

@ -32,7 +32,7 @@ import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Authority } from '@shared/models/authority.enum';
import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component';
import { ResourcesLibraryComponent } from '@home/components/resources/resources-library.component';
import { PageLink } from '@shared/models/page/page-link';
import { EntityAction } from '@home/models/entity/entity-component.models';
import { map } from 'rxjs/operators';

View File

@ -28,7 +28,7 @@ import { PageLink } from '@shared/models/page/page-link';
})
export class ResourcesTableHeaderComponent extends EntityTableHeaderComponent<Resource, PageLink, ResourceInfo> {
readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS];
readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.TEXT];
readonly resourceTypesTranslationMap = ResourceTypeTranslationMap;
constructor(protected store: Store<AppState>) {

View File

@ -40,6 +40,12 @@
[matAutocompleteConnectedTo]="origin"
[matAutocomplete]="entityAutocomplete"
[matChipInputFor]="chipList">
<button mat-button color="primary" matSuffix
type="button"
*ngIf="allowCreateNew && !disabled"
(click)="createNewEntity($event)">
<span style="white-space: nowrap">{{ 'entity.create-new' | translate }}</span>
</button>
</mat-chip-grid>
<mat-autocomplete #entityAutocomplete="matAutocomplete"
class="tb-autocomplete"
@ -56,6 +62,11 @@
<span>
{{ 'entity.no-entities-matching' | translate: {entity: searchText} }}
</span>
@if (allowCreateNew) {
<span>
<a translate (click)="createNewEntity($event, searchText)">entity.create-new-key</a>
</span>
}
</ng-template>
</div>
</mat-option>

View File

@ -14,7 +14,18 @@
/// limitations under the License.
///
import { Component, ElementRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core';
import {
Component,
ElementRef,
EventEmitter,
forwardRef,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
ViewChild
} from '@angular/core';
import {
ControlValueAccessor,
NG_VALIDATORS,
@ -93,6 +104,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan
}
@Input()
@coerceBoolean()
disabled: boolean;
@Input()
@ -109,6 +121,13 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan
@coerceBoolean()
inlineField: boolean;
@Input()
@coerceBoolean()
allowCreateNew: boolean;
@Output()
createNew = new EventEmitter<string>();
@ViewChild('entityInput') entityInput: ElementRef<HTMLInputElement>;
@ViewChild('entityAutocomplete') matAutocomplete: MatAutocomplete;
@ViewChild('chipList', {static: true}) chipList: MatChipGrid;
@ -136,6 +155,11 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan
this.entityListFormGroup.get('entities').updateValueAndValidity();
}
createNewEntity($event: Event, searchText?: string) {
$event.stopPropagation();
this.createNew.emit(searchText);
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
@ -201,6 +225,9 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan
this.modelValue = null;
}
this.dirty = true;
if (this.entityInput) {
this.entityInput.nativeElement.value = '';
}
}
validate(): ValidationErrors | null {

View File

@ -24,7 +24,8 @@ export enum ResourceType {
LWM2M_MODEL = 'LWM2M_MODEL',
PKCS_12 = 'PKCS_12',
JKS = 'JKS',
JS_MODULE = 'JS_MODULE'
JS_MODULE = 'JS_MODULE',
TEXT = 'TEXT',
}
export enum ResourceSubType {
@ -57,7 +58,8 @@ export const ResourceTypeTranslationMap = new Map<ResourceType, string>(
[ResourceType.LWM2M_MODEL, 'resource.type.lwm2m-model'],
[ResourceType.PKCS_12, 'resource.type.pkcs-12'],
[ResourceType.JKS, 'resource.type.jks'],
[ResourceType.JS_MODULE, 'resource.type.js-module']
[ResourceType.JS_MODULE, 'resource.type.js-module'],
[ResourceType.TEXT, 'resource.type.text'],
]
);
@ -76,8 +78,8 @@ export interface TbResourceInfo<D> extends Omit<BaseData<TbResourceId>, 'name' |
title?: string;
resourceType: ResourceType;
resourceSubType?: ResourceSubType;
fileName: string;
public: boolean;
fileName?: string;
public?: boolean;
publicResourceKey?: string;
readonly link?: string;
readonly publicLink?: string;
@ -87,7 +89,7 @@ export interface TbResourceInfo<D> extends Omit<BaseData<TbResourceId>, 'name' |
export type ResourceInfo = TbResourceInfo<any>;
export interface Resource extends ResourceInfo {
data: string;
data?: string;
name?: string;
}

View File

@ -4488,7 +4488,8 @@
"jks": "JKS",
"js-module": "JS module",
"lwm2m-model": "LWM2M model",
"pkcs-12": "PKCS #12"
"pkcs-12": "PKCS #12",
"text": "Text"
},
"resource-sub-type": "Sub-type",
"sub-type": {
@ -5467,7 +5468,8 @@
"timeout-required": "Timeout is required",
"timeout-validation": "Must be from 1 second to 10 minutes.",
"force-acknowledgement": "Force acknowledgement",
"force-acknowledgement-hint": "If enabled, the incoming message is acknowledged immediately. The model's response is then enqueued as a separate, new message."
"force-acknowledgement-hint": "If enabled, the incoming message is acknowledged immediately. The model's response is then enqueued as a separate, new message.",
"ai-resources": "AI resources"
}
},
"timezone": {