From 985bcd0f40645f9486f227026088e2c283d970d1 Mon Sep 17 00:00:00 2001 From: meta-d Date: Wed, 20 Dec 2023 21:19:53 +0800 Subject: [PATCH] feat: create ocap copilot component --- .../src/app/@core/services/copilot.service.ts | 2 +- .../@shared/copilot/chat/chat.component.ts | 3 + .../cloud/src/app/features/features.module.ts | 26 +- .../features/home/insight/insight.service.ts | 19 +- .../model/copilot/model-copilot.service.ts | 2 +- .../semantic-model/model/copilot/types.ts | 8 +- .../semantic-model/model/model.component.html | 7 +- .../semantic-model/model/model.component.ts | 12 +- .../semantic-model/model/model.module.ts | 45 +- .../story/toolbar/toolbar.component.html | 5 +- .../story/toolbar/toolbar.component.ts | 5 +- apps/cloud/src/assets/i18n/zh-Hans.json | 3 + apps/cloud/src/styles.scss | 9 +- apps/ng/src/app/app.component.ts | 20 +- .../src/app/dashboard/dashboard.component.ts | 13 +- .../src/app/dashboard/ocap-cache.service.ts | 11 + apps/ng/src/app/dashboard/s4-agent.service.ts | 45 +- apps/ng/src/index.html | 2 +- .../single-selection-table.component.ts | 3 + libs/component-angular/table/table.module.ts | 3 + .../table/table/table.component.ts | 3 + .../src/lib/services/copilot.service.ts | 3 + .../analytical-grid.component.stories.ts | 119 ++-- .../select/select/select.component.stories.ts | 31 +- packages/angular/common/table/index.ts | 3 +- .../common/table/table/table.component.html | 61 ++ .../common/table/table/table.component.scss | 16 + .../common/table/table/table.component.ts | 257 +++++++ packages/angular/common/table/types.ts | 9 + .../copilot/avatar/avatar.component.ts | 13 + .../angular/copilot/chat/chat.component.html | 263 ++++++++ .../angular/copilot/chat/chat.component.scss | 66 ++ .../angular/copilot/chat/chat.component.ts | 633 ++++++++++++++++++ .../copilot/enable/enable.component.html | 23 + .../copilot/enable/enable.component.scss | 3 + .../copilot/enable/enable.component.ts | 34 + .../copilot/global/global.component.html | 16 + .../copilot/global/global.component.scss | 0 .../copilot/global/global.component.ts | 40 ++ .../angular/copilot/global/global.service.ts | 7 + packages/angular/copilot/index.ts | 5 + .../copilot/services/copilot.service.ts | 72 ++ packages/angular/copilot/services/index.ts | 1 + .../angular/copilot/stories/chat.stories.ts | 107 +++ .../copilot/stories/not-enabled.stories.ts | 48 ++ .../angular/copilot/token/token.component.ts | 39 ++ packages/angular/copilot/types.ts | 4 + .../core/directives/density.stories.ts | 29 +- packages/angular/core/helpers.ts | 25 +- packages/angular/core/i18n/loader.spec.ts | 9 - packages/angular/i18n/zhHans.ts | 59 +- packages/angular/mock/index.ts | 3 + packages/angular/mock/logger.ts | 12 + packages/angular/mock/translate.ts | 36 + packages/copilot/package.json | 2 +- packages/copilot/src/lib/copilot.ts | 5 +- packages/copilot/src/lib/types.ts | 2 +- 57 files changed, 2075 insertions(+), 226 deletions(-) create mode 100644 apps/ng/src/app/dashboard/ocap-cache.service.ts create mode 100644 packages/angular/common/table/table/table.component.html create mode 100644 packages/angular/common/table/table/table.component.scss create mode 100644 packages/angular/common/table/table/table.component.ts create mode 100644 packages/angular/common/table/types.ts create mode 100644 packages/angular/copilot/avatar/avatar.component.ts create mode 100644 packages/angular/copilot/chat/chat.component.html create mode 100644 packages/angular/copilot/chat/chat.component.scss create mode 100644 packages/angular/copilot/chat/chat.component.ts create mode 100644 packages/angular/copilot/enable/enable.component.html create mode 100644 packages/angular/copilot/enable/enable.component.scss create mode 100644 packages/angular/copilot/enable/enable.component.ts create mode 100644 packages/angular/copilot/global/global.component.html create mode 100644 packages/angular/copilot/global/global.component.scss create mode 100644 packages/angular/copilot/global/global.component.ts create mode 100644 packages/angular/copilot/global/global.service.ts create mode 100644 packages/angular/copilot/index.ts create mode 100644 packages/angular/copilot/services/copilot.service.ts create mode 100644 packages/angular/copilot/services/index.ts create mode 100644 packages/angular/copilot/stories/chat.stories.ts create mode 100644 packages/angular/copilot/stories/not-enabled.stories.ts create mode 100644 packages/angular/copilot/token/token.component.ts create mode 100644 packages/angular/copilot/types.ts delete mode 100644 packages/angular/core/i18n/loader.spec.ts create mode 100644 packages/angular/mock/index.ts create mode 100644 packages/angular/mock/logger.ts create mode 100644 packages/angular/mock/translate.ts diff --git a/apps/cloud/src/app/@core/services/copilot.service.ts b/apps/cloud/src/app/@core/services/copilot.service.ts index c3f3fda19..ee2184542 100644 --- a/apps/cloud/src/app/@core/services/copilot.service.ts +++ b/apps/cloud/src/app/@core/services/copilot.service.ts @@ -61,7 +61,7 @@ export class CopilotService extends NgmCopilotService { this.openai = new OpenAIApi(this.configuration) }) - private async getOne(orgId: string) { + async getOne(orgId?: string) { const result = await firstValueFrom(this.httpClient.get<{ items: ICopilot[] }>(API_PREFIX + '/copilot')) this._copilot$.next(result.items[0]) return this._copilot$.value diff --git a/apps/cloud/src/app/@shared/copilot/chat/chat.component.ts b/apps/cloud/src/app/@shared/copilot/chat/chat.component.ts index 7bf413507..3a14236af 100644 --- a/apps/cloud/src/app/@shared/copilot/chat/chat.component.ts +++ b/apps/cloud/src/app/@shared/copilot/chat/chat.component.ts @@ -149,6 +149,9 @@ import { NgmSearchComponent } from '@metad/ocap-angular/common' // } // } +/** + * @deprecated use NgmCopilotChatComponent instead + */ @Component({ standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/apps/cloud/src/app/features/features.module.ts b/apps/cloud/src/app/features/features.module.ts index 2bc2ab352..f654308fb 100644 --- a/apps/cloud/src/app/features/features.module.ts +++ b/apps/cloud/src/app/features/features.module.ts @@ -1,5 +1,11 @@ import { CommonModule } from '@angular/common' import { NgModule } from '@angular/core' +import { PacAuthModule } from '@metad/cloud/auth' +import { NxTableModule } from '@metad/components/table' +import { ICopilot } from '@metad/copilot' +import { NgmFormlyModule } from '@metad/formly' +import { PACMaterialThemeModule } from '@metad/material-theme' +import { NgmCopilotService } from '@metad/ocap-angular/copilot' import { DensityDirective, NgmAgentService, @@ -9,13 +15,8 @@ import { } from '@metad/ocap-angular/core' import { NGM_WASM_AGENT_WORKER, WasmAgentService } from '@metad/ocap-angular/wasm-agent' import { DataSource, Type } from '@metad/ocap-core' -import { LetDirective } from '@ngrx/component' -import { PacAuthModule } from '@metad/cloud/auth' -import { NxTableModule } from '@metad/components/table' -import { NgmFormlyModule } from '@metad/formly' -import { NgmCopilotService } from '@metad/core' -import { PACMaterialThemeModule } from '@metad/material-theme' import { NX_STORY_FEED, NX_STORY_MODEL, NX_STORY_STORE } from '@metad/story/core' +import { LetDirective } from '@ngrx/component' import { NgxPopperjsModule } from 'ngx-popperjs' import { CopilotService, DirtyCheckGuard, LocalAgent, ServerAgent } from '../@core/index' import { AssetsComponent } from '../@shared/assets/assets.component' @@ -54,7 +55,7 @@ import { FeaturesComponent } from './features.component' CopilotGlobalComponent, // Formly - NgmFormlyModule.forRoot({}), + NgmFormlyModule.forRoot({}) ], providers: [ DirtyCheckGuard, @@ -117,9 +118,14 @@ import { FeaturesComponent } from './features.component' useClass: StoryFeedService }, { - provide: NgmCopilotService, - useExisting: CopilotService - } + // Provide CopilotConfig factory to NgmCopilotService + provide: NgmCopilotService.CopilotConfigFactoryToken, + useFactory: (copilotService: CopilotService) => (): Promise => { + return copilotService.getOne() + }, + deps: [CopilotService] + }, + NgmCopilotService, ] }) export class FeaturesModule {} diff --git a/apps/cloud/src/app/features/home/insight/insight.service.ts b/apps/cloud/src/app/features/home/insight/insight.service.ts index e2cbd20e1..caf33d915 100644 --- a/apps/cloud/src/app/features/home/insight/insight.service.ts +++ b/apps/cloud/src/app/features/home/insight/insight.service.ts @@ -12,7 +12,8 @@ import { DataSettings, EntityType, getEntityDimensions, - isEntityType + isEntityType, + getEntityHierarchy } from '@metad/ocap-core' import { TranslateService } from '@ngx-translate/core' import { convertNewSemanticModelResult, ModelsService, NgmSemanticModel } from '@metad/cloud/state' @@ -282,11 +283,11 @@ ${calcEntityTypePrompt(entityType)} } ) - let answer + let answer: any try { answer = getFunctionCall(choices[0].message) - const { chartAnnotation, slicers, limit, chartOptions } = transformCopilotChart(answer.arguments) + const { chartAnnotation, slicers, limit, chartOptions } = transformCopilotChart(answer.arguments, entityType) const answerMessage = { message: JSON.stringify(answer.arguments, null, 2), dataSettings: { @@ -545,7 +546,15 @@ ${JSON.stringify([ } -export function transformCopilotChart(answer) { + +/** + * Transform copilot answer to chart annotation + * + * @param answer Answer from copilot + * @param entityType Entity type of the cube + * @returns + */ +export function transformCopilotChart(answer: any, entityType: EntityType) { const chartAnnotation = {} as ChartAnnotation if (answer.chartType) { chartAnnotation.chartType = { @@ -562,6 +571,8 @@ export function transformCopilotChart(answer) { chartAnnotation.dimensions = dimensions.map((dimension) => ( { ...dimension, + // Determine dimension attr by hierarchy + dimension: getEntityHierarchy(entityType, dimension.hierarchy).dimension, zeroSuppression: true, chartOptions: { dataZoom: { diff --git a/apps/cloud/src/app/features/semantic-model/model/copilot/model-copilot.service.ts b/apps/cloud/src/app/features/semantic-model/model/copilot/model-copilot.service.ts index 285b4d15b..8faa2e1a7 100644 --- a/apps/cloud/src/app/features/semantic-model/model/copilot/model-copilot.service.ts +++ b/apps/cloud/src/app/features/semantic-model/model/copilot/model-copilot.service.ts @@ -18,7 +18,6 @@ import { SystemCommandFree, SystemCommands } from '@metad/copilot' -import { NgmCopilotService } from '@metad/core' import { pick } from '@metad/ocap-core' import { TranslateService } from '@ngx-translate/core' import { NGXLogger } from 'ngx-logger' @@ -27,6 +26,7 @@ import { getErrorMessage } from '../../../../@core' import { ModelEntityService } from '../entity/entity.service' import { SemanticModelService } from '../model.service' import { ModelCopilotChatConversation, ModelCopilotCommandArea } from './types' +import { NgmCopilotService } from '@metad/ocap-angular/copilot' export const I18N_MODEL_NAMESPACE = 'PAC.MODEL' diff --git a/apps/cloud/src/app/features/semantic-model/model/copilot/types.ts b/apps/cloud/src/app/features/semantic-model/model/copilot/types.ts index 8179f34b8..03c9d73b6 100644 --- a/apps/cloud/src/app/features/semantic-model/model/copilot/types.ts +++ b/apps/cloud/src/app/features/semantic-model/model/copilot/types.ts @@ -1,9 +1,9 @@ -import { NgmCopilotService } from '@metad/core' -import { EntityType } from '@metad/ocap-core' import { CopilotChatConversation } from '@metad/copilot' +import { NgmCopilotService } from '@metad/ocap-angular/copilot' +import { EntityType } from '@metad/ocap-core' import { NGXLogger } from 'ngx-logger' -import { SemanticModelService } from '../model.service' import { ModelEntityService } from '../entity/entity.service' +import { SemanticModelService } from '../model.service' export interface ModelCopilotChatConversation extends CopilotChatConversation { dataSource: string @@ -17,4 +17,4 @@ export interface ModelCopilotChatConversation extends CopilotChatConversation { sharedDimensionsPrompt: string } -export const ModelCopilotCommandArea = 'Model' \ No newline at end of file +export const ModelCopilotCommandArea = 'Model' diff --git a/apps/cloud/src/app/features/semantic-model/model/model.component.html b/apps/cloud/src/app/features/semantic-model/model/model.component.html index d9febdcea..b5ce49fc0 100644 --- a/apps/cloud/src/app/features/semantic-model/model/model.component.html +++ b/apps/cloud/src/app/features/semantic-model/model/model.component.html @@ -53,7 +53,7 @@ [color]="copilotDrawer.opened?'accent':''" (click)="copilotDrawer.toggle()" > - 🤖 +
🤖
@@ -231,11 +231,12 @@ - + >
- + >
diff --git a/apps/cloud/src/app/features/story/toolbar/toolbar.component.ts b/apps/cloud/src/app/features/story/toolbar/toolbar.component.ts index 23c70bd08..696c22489 100644 --- a/apps/cloud/src/app/features/story/toolbar/toolbar.component.ts +++ b/apps/cloud/src/app/features/story/toolbar/toolbar.component.ts @@ -49,7 +49,7 @@ import { StorySharesComponent } from '@metad/story/story' import { combineLatest, firstValueFrom } from 'rxjs' import { map } from 'rxjs/operators' import { ToastrService, tryHttp } from '../../../@core' -import { CopilotChatComponent, MaterialModule, ProjectFilesComponent } from '../../../@shared' +import { MaterialModule, ProjectFilesComponent } from '../../../@shared' import { StoryDesignerComponent } from '../designer' import { SaveAsTemplateComponent } from '../save-as-template/save-as-template.component' import { StoryDetailsComponent } from '../story-details/story-details.component' @@ -57,6 +57,7 @@ import { DeviceOrientation, DeviceZooms, EmulatedDevices, StoryScales, downloadS import { StoryToolbarService } from './toolbar.service' import { COMPONENTS, PAGES } from './types' import { CHARTS } from '@metad/story/widgets/analytical-card' +import { NgmCopilotChatComponent } from '@metad/ocap-angular/copilot' @Component({ @@ -72,7 +73,7 @@ import { CHARTS } from '@metad/story/widgets/analytical-card' AppearanceDirective, DensityDirective, StoryDesignerComponent, - CopilotChatComponent, + NgmCopilotChatComponent, NgmInputComponent ], selector: 'pac-story-toolbar', diff --git a/apps/cloud/src/assets/i18n/zh-Hans.json b/apps/cloud/src/assets/i18n/zh-Hans.json index 104dae9c8..890e68df6 100644 --- a/apps/cloud/src/assets/i18n/zh-Hans.json +++ b/apps/cloud/src/assets/i18n/zh-Hans.json @@ -920,6 +920,9 @@ "CubeAlreadyExists": "多维数据集已存在!", "DimensionAlreadyExists": "维度已存在!", "MeasureAlreadyExists": "度量已存在!" + }, + "Copilot": { + "InstructionExecutionComplete": "指令执行完成" } }, "BUSINESS_AREA": { diff --git a/apps/cloud/src/styles.scss b/apps/cloud/src/styles.scss index bdea275d3..0550218ac 100644 --- a/apps/cloud/src/styles.scss +++ b/apps/cloud/src/styles.scss @@ -52,9 +52,14 @@ body { font-size: 14px; } +$text-font: Lato, 'Noto Serif SC', monospace; + body { - --mdc-dialog-subhead-font: Lato, 'Noto Serif SC', monospace; - --mdc-dialog-supporting-text-font: Lato, 'Noto Serif SC', monospace; + --mdc-dialog-subhead-font: $text-font; + --mdc-dialog-supporting-text-font: $text-font; + --mat-table-row-item-label-text-font: $text-font; + --mat-table-header-headline-font: $text-font; + display: flex; margin: 0; diff --git a/apps/ng/src/app/app.component.ts b/apps/ng/src/app/app.component.ts index 96c671d5d..55925c946 100644 --- a/apps/ng/src/app/app.component.ts +++ b/apps/ng/src/app/app.component.ts @@ -2,14 +2,21 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core' import { SmartFilterOptions } from '@metad/ocap-angular/controls' import { DisplayDensity, NgmAppearance, NgmDSCoreService, NgmSmartFilterBarService } from '@metad/ocap-angular/core' import { WasmAgentService } from '@metad/ocap-angular/wasm-agent' -import { AgentStatus, AgentType, DataSettings, DisplayBehaviour, FilterSelectionType, MemberSource } from '@metad/ocap-core' -import { ANALYTICAL_CARDS, CARTESIAN_CARDS, DUCKDB_COVID19_DAILY_MODEL, DUCKDB_FOODMART_MODEL, DUCKDB_TOP_SUBSCRIBED_MODEL, DUCKDB_UNEMPLOYMENT_MODEL, DUCKDB_WASM_MODEL } from '@metad/ocap-duckdb' +import { AgentStatus, DataSettings, DisplayBehaviour, FilterSelectionType, MemberSource } from '@metad/ocap-core' +import { + ANALYTICAL_CARDS, + DUCKDB_COVID19_DAILY_MODEL, + DUCKDB_FOODMART_MODEL, + DUCKDB_TOP_SUBSCRIBED_MODEL, + DUCKDB_UNEMPLOYMENT_MODEL, + DUCKDB_WASM_MODEL +} from '@metad/ocap-duckdb' import { cloneDeep } from 'lodash-es' import { Observable } from 'rxjs' @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - selector: 'metad-ocap-root', + selector: 'ngm-ocap-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'], providers: [NgmSmartFilterBarService] @@ -41,7 +48,7 @@ export class AppComponent implements OnInit { smartFilterOptions: SmartFilterOptions = { dimension: { dimension: '[product]' - }, + } } productFilterOptions: SmartFilterOptions = { dimension: { @@ -107,7 +114,7 @@ export class AppComponent implements OnInit { ] error: string - + public readonly status$ = this.wasmAgent.selectStatus() as Observable store @@ -151,14 +158,13 @@ export class AppComponent implements OnInit { // ignoreUnknownProperty: true // }, // }) - + await this.wasmAgent.registerModel(DUCKDB_WASM_MODEL) await this.wasmAgent.registerModel(DUCKDB_COVID19_DAILY_MODEL) await this.wasmAgent.registerModel(DUCKDB_FOODMART_MODEL) await this.wasmAgent.registerModel(DUCKDB_UNEMPLOYMENT_MODEL) await this.wasmAgent.registerModel(DUCKDB_TOP_SUBSCRIBED_MODEL) - // await this.wasmAgent.registerModel({ // ...DUCKDB_FOODMART_MODEL, // name: 'ERROR', diff --git a/apps/ng/src/app/dashboard/dashboard.component.ts b/apps/ng/src/app/dashboard/dashboard.component.ts index 92982408c..6e5983bd6 100644 --- a/apps/ng/src/app/dashboard/dashboard.component.ts +++ b/apps/ng/src/app/dashboard/dashboard.component.ts @@ -3,13 +3,14 @@ import { Component, inject } from '@angular/core' import { FormsModule } from '@angular/forms' import { MatButtonModule } from '@angular/material/button' import { AnalyticalCardModule } from '@metad/ocap-angular/analytical-card' -import { DensityDirective, NgmAgentService, NgmDSCoreService, OCAP_AGENT_TOKEN, OCAP_DATASOURCE_TOKEN } from '@metad/ocap-angular/core' +import { DensityDirective, NgmAgentService, NgmDSCacheService, NgmDSCoreService, OCAP_AGENT_TOKEN, OCAP_DATASOURCE_TOKEN } from '@metad/ocap-angular/core' import { AgentType, DataSource, Syntax, Type } from '@metad/ocap-core' import { S4ServerAgent } from './s4-agent.service' +import { ZngOcapCacheService } from './ocap-cache.service' @Component({ standalone: true, - selector: 'ngm-dashboard', + selector: 'ngm-ocap-dashboard', templateUrl: 'dashboard.component.html', styles: [ ` @@ -19,6 +20,10 @@ import { S4ServerAgent } from './s4-agent.service' ` ], providers: [ + { + provide: NgmDSCacheService, + useClass: ZngOcapCacheService + }, NgmDSCoreService, NgmAgentService, S4ServerAgent, @@ -55,9 +60,5 @@ export class DashboardComponent { dialect: 'SAP', catalog: '$INFOCUBE' }) - - // this.#dsCoreService.getDataSource('S4CDS').subscribe((ds) => { - // console.log(`============================`) - // }) } } diff --git a/apps/ng/src/app/dashboard/ocap-cache.service.ts b/apps/ng/src/app/dashboard/ocap-cache.service.ts new file mode 100644 index 000000000..af474e2a3 --- /dev/null +++ b/apps/ng/src/app/dashboard/ocap-cache.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@angular/core' +import { DSCacheService } from '@metad/ocap-core' + +@Injectable() +export class ZngOcapCacheService extends DSCacheService { + constructor() { + super() + // Don't cache + super.changeCacheLevel(-1) + } +} diff --git a/apps/ng/src/app/dashboard/s4-agent.service.ts b/apps/ng/src/app/dashboard/s4-agent.service.ts index a018ca496..1ede0b247 100644 --- a/apps/ng/src/app/dashboard/s4-agent.service.ts +++ b/apps/ng/src/app/dashboard/s4-agent.service.ts @@ -1,39 +1,56 @@ + import { Injectable } from '@angular/core' import { Agent, AgentStatus, AgentStatusEnum, AgentType, DataSourceOptions } from '@metad/ocap-core' -import { Observable } from 'rxjs' - - -export interface PacServerAgentDefaultOptions { - modelBaseUrl: string -} +import { EMPTY, Observable, of } from 'rxjs' @Injectable() export class S4ServerAgent implements Agent { type = AgentType.Server selectStatus(): Observable { - throw new Error('Method not implemented.') + return of({ + status: AgentStatusEnum.ONLINE, + payload: null + }) } + selectError(): Observable { - throw new Error('Method not implemented.') + return EMPTY } + error(err: any): void { console.error(err) } - async request(dataSource: DataSourceOptions, options: any): Promise { - console.log(dataSource, options) - const result = await fetch(`/sap/bw/xml/soap/xmla?sap-client=400`, { + /** + * Redirect dataSource request to current S4 backend system + * + * @param dataSource DataSource options of model + * @param request Request options + * @returns response text + */ + async request(dataSource: DataSourceOptions, request: {headers: any; body: string}): Promise { + const result = await fetch(`/sap/bw/xml/soap/xmla`, { method: 'POST', headers: { - ...options.headers, + ...request.headers, + // 'Accept-Language': this.#translateService.lang() || '' }, - body: options.body, + body: request.body }) return await result.text() } + + /** + * + * @todo new api + * @param dataSource + * @param options + * @returns + */ + /* eslint-disable @typescript-eslint/no-unused-vars */ _request?(dataSource: DataSourceOptions, options: any): Observable { - throw new Error('Method not implemented.') + return EMPTY } } diff --git a/apps/ng/src/index.html b/apps/ng/src/index.html index b2f3f2764..2d83c2390 100644 --- a/apps/ng/src/index.html +++ b/apps/ng/src/index.html @@ -9,6 +9,6 @@ - + diff --git a/libs/component-angular/table/single-selection-table/single-selection-table.component.ts b/libs/component-angular/table/single-selection-table/single-selection-table.component.ts index 55690b86e..73f5e9efd 100644 --- a/libs/component-angular/table/single-selection-table/single-selection-table.component.ts +++ b/libs/component-angular/table/single-selection-table/single-selection-table.component.ts @@ -8,6 +8,9 @@ import { MatTableDataSource } from '@angular/material/table' import { DisplayDensity } from '@metad/ocap-angular/core' import get from 'lodash-es/get' +/** + * @deprecated move to `@metad/ocap-angular` + */ @Component({ selector: 'ngm-single-selection-table', templateUrl: './single-selection-table.component.html', diff --git a/libs/component-angular/table/table.module.ts b/libs/component-angular/table/table.module.ts index 9f04f7f97..3c21112ce 100644 --- a/libs/component-angular/table/table.module.ts +++ b/libs/component-angular/table/table.module.ts @@ -16,6 +16,9 @@ import { NxSingleSelectionTableComponent } from './single-selection-table/single import { MyCustomPaginatorIntl, NxTableComponent } from './table/table.component' import { MatInputModule } from '@angular/material/input' +/** + * @deprecated use NgmTableComponent + */ @NgModule({ declarations: [NxSingleSelectionTableComponent, NxTableComponent], imports: [ diff --git a/libs/component-angular/table/table/table.component.ts b/libs/component-angular/table/table/table.component.ts index 66fa4addd..3f782ba03 100644 --- a/libs/component-angular/table/table/table.component.ts +++ b/libs/component-angular/table/table/table.component.ts @@ -81,6 +81,9 @@ export class MyCustomPaginatorIntl implements MatPaginatorIntl { } } +/** + * @deprecated use NgmTableComponent + */ @UntilDestroy({ checkProperties: true }) @Component({ changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/libs/core-angular/src/lib/services/copilot.service.ts b/libs/core-angular/src/lib/services/copilot.service.ts index 6657759f1..60f7aadab 100644 --- a/libs/core-angular/src/lib/services/copilot.service.ts +++ b/libs/core-angular/src/lib/services/copilot.service.ts @@ -1,6 +1,9 @@ import { Injectable } from '@angular/core' import { CopilotService } from '@metad/copilot' +/** + * @deprecated use `NgmCopilotService` from `@metad/ocap-angular` + */ @Injectable() export class NgmCopilotService extends CopilotService { constructor() { diff --git a/packages/angular/analytical-grid/analytical-grid.component.stories.ts b/packages/angular/analytical-grid/analytical-grid.component.stories.ts index 062255c36..e13ae2eb2 100644 --- a/packages/angular/analytical-grid/analytical-grid.component.stories.ts +++ b/packages/angular/analytical-grid/analytical-grid.component.stories.ts @@ -1,38 +1,33 @@ -import { BrowserAnimationsModule } from '@angular/platform-browser/animations' +import { CommonModule } from '@angular/common' +import { provideHttpClient } from '@angular/common/http' +import { Component, importProvidersFrom } from '@angular/core' +import { MatButtonModule } from '@angular/material/button' +import { provideAnimations } from '@angular/platform-browser/animations' import { - NgmMissingTranslationHandler, - OcapCoreModule, + DisplayDensity, + NgmDSCoreService, OCAP_AGENT_TOKEN, OCAP_DATASOURCE_TOKEN, OCAP_MODEL_TOKEN, - NgmDSCoreService, - DisplayDensity + OcapCoreModule } from '@metad/ocap-angular/core' import { AgentType, C_MEASURES, DataSource, Type } from '@metad/ocap-core' -import { MissingTranslationHandler, TranslateLoader, TranslateModule } from '@ngx-translate/core' -import { Meta, moduleMetadata, StoryObj } from '@storybook/angular' +import { Meta, StoryObj, applicationConfig, moduleMetadata } from '@storybook/angular' +import { provideTranslate } from '../mock' import { CUBE_SALES_ORDER, MockAgent } from '../mock/agent-mock.service' import { AnalyticalGridComponent } from './analytical-grid.component' import { AnalyticalGridModule } from './analytical-grid.module' -import { CustomTranslateLoader } from '../core/i18n/loader.spec' -import { Component } from '@angular/core' -import { CommonModule } from '@angular/common' -import { MatButtonModule } from '@angular/material/button' @Component({ standalone: true, - imports: [ - CommonModule, - MatButtonModule, - AnalyticalGridModule - ], + imports: [CommonModule, MatButtonModule, AnalyticalGridModule], selector: 'ngm-story-component-switch-grid', template: ` - - `, + ` }) class GridsComponent { a = { @@ -87,22 +82,11 @@ export default { title: 'AnalyticalGridComponent', component: AnalyticalGridComponent, decorators: [ + applicationConfig({ + providers: [provideAnimations(), provideHttpClient(), provideTranslate(), importProvidersFrom(OcapCoreModule)] + }), moduleMetadata({ - imports: [ - BrowserAnimationsModule, - TranslateModule.forRoot({ - missingTranslationHandler: { - provide: MissingTranslationHandler, - useClass: NgmMissingTranslationHandler - }, - loader: { provide: TranslateLoader, useClass: CustomTranslateLoader }, - defaultLanguage: 'zh-Hans' - }), - AnalyticalGridModule, - OcapCoreModule, - - GridsComponent - ], + imports: [AnalyticalGridModule, GridsComponent], providers: [ NgmDSCoreService, { @@ -501,41 +485,40 @@ export const Sortable = { export const MultipleMeasures = { render, args: { - title: 'Multiple Measures Grid', - appearance: {}, - dataSettings: { - dataSource: 'Sales', - entitySet: 'SalesOrder', - analytics: { - rows: [ - { - dimension: '[Product]' - } - ], - columns: [ - { - dimension: '[Department]' - }, - { - dimension: C_MEASURES, - measure: 'Sales' - }, - { - dimension: C_MEASURES, - measure: 'Cost' - } - ] - } - }, - options: {} -}} + title: 'Multiple Measures Grid', + appearance: {}, + dataSettings: { + dataSource: 'Sales', + entitySet: 'SalesOrder', + analytics: { + rows: [ + { + dimension: '[Product]' + } + ], + columns: [ + { + dimension: '[Department]' + }, + { + dimension: C_MEASURES, + measure: 'Sales' + }, + { + dimension: C_MEASURES, + measure: 'Cost' + } + ] + } + }, + options: {} + } +} export const SwitchTemplate = { render: (args) => ({ props: args, - template: ``, + template: `` }), - args: { - - } + args: {} } diff --git a/packages/angular/common/select/select/select.component.stories.ts b/packages/angular/common/select/select/select.component.stories.ts index 04be4af80..8c18e130a 100644 --- a/packages/angular/common/select/select/select.component.stories.ts +++ b/packages/angular/common/select/select/select.component.stories.ts @@ -1,31 +1,17 @@ -import { importProvidersFrom } from '@angular/core' +import { MatButtonModule } from '@angular/material/button' +import { MatIconModule } from '@angular/material/icon' import { provideAnimations } from '@angular/platform-browser/animations' -import { NgmMissingTranslationHandler, OcapCoreModule } from '@metad/ocap-angular/core' -import { CustomTranslateLoader } from '@metad/ocap-angular/core/i18n/loader.spec' -import { MissingTranslationHandler, TranslateLoader, TranslateModule } from '@ngx-translate/core' +import { OcapCoreModule } from '@metad/ocap-angular/core' +import { provideTranslate } from '@metad/ocap-angular/mock' import { Meta, StoryObj, applicationConfig, moduleMetadata } from '@storybook/angular' -import { NgmSelectComponent } from './select.component' import { NgmSelectModule } from '../select.module' -import { MatButtonModule } from '@angular/material/button' -import { MatIconModule } from '@angular/material/icon' +import { NgmSelectComponent } from './select.component' const meta: Meta = { component: NgmSelectComponent, decorators: [ applicationConfig({ - providers: [ - provideAnimations(), - importProvidersFrom( - TranslateModule.forRoot({ - missingTranslationHandler: { - provide: MissingTranslationHandler, - useClass: NgmMissingTranslationHandler - }, - loader: { provide: TranslateLoader, useClass: CustomTranslateLoader }, - defaultLanguage: 'zh-Hans' - }) - ) - ] + providers: [provideAnimations(), provideTranslate()] }), moduleMetadata({ declarations: [], @@ -87,7 +73,6 @@ export const Suffix = { } } - export const SuffixSearchable = { render: (args) => ({ props: args, @@ -179,6 +164,6 @@ export const Density: Story = { ` }), args: { - selectOptions: TREE_NODE_DATA, + selectOptions: TREE_NODE_DATA } -} \ No newline at end of file +} diff --git a/packages/angular/common/table/index.ts b/packages/angular/common/table/index.ts index 575f2cfad..90133bceb 100644 --- a/packages/angular/common/table/index.ts +++ b/packages/angular/common/table/index.ts @@ -1,4 +1,5 @@ export * from './fixed-size-table-virtual-scroll-strategy' export * from './table-data-source' export * from './table-item-size.directive' -export * from './table.module' \ No newline at end of file +export * from './table.module' +export * from './table/table.component' \ No newline at end of file diff --git a/packages/angular/common/table/table/table.component.html b/packages/angular/common/table/table/table.component.html new file mode 100644 index 000000000..c7451888a --- /dev/null +++ b/packages/angular/common/table/table/table.component.html @@ -0,0 +1,61 @@ +
+ + + + + + + + + + + + + + + + +
+ + + + + + +
+ {{column.caption || column.name}} + +
+ + + + +
+ +
+
+ + + + + {{ column.pipe ? column.pipe(getValue(data, column.name)) : getValue(data, column.name) }} + +
+
+ + diff --git a/packages/angular/common/table/table/table.component.scss b/packages/angular/common/table/table/table.component.scss new file mode 100644 index 000000000..ff5a1f2b6 --- /dev/null +++ b/packages/angular/common/table/table/table.component.scss @@ -0,0 +1,16 @@ +:host { + display: flex; + flex-direction: column; +} + +.searchable .mat-mdc-input-element { + @apply bg-transparent; +} + +.mat-mdc-cell, .mat-mdc-header-cell { + white-space: nowrap; + + &.number { + text-align: right; + } +} \ No newline at end of file diff --git a/packages/angular/common/table/table/table.component.ts b/packages/angular/common/table/table/table.component.ts new file mode 100644 index 000000000..a5943549f --- /dev/null +++ b/packages/angular/common/table/table/table.component.ts @@ -0,0 +1,257 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion' +import { SelectionModel } from '@angular/cdk/collections' +import { DragDropModule } from '@angular/cdk/drag-drop' +import { CommonModule } from '@angular/common' +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + EventEmitter, + Injectable, + Input, + OnChanges, + Output, + SimpleChanges, + ViewChild, + inject +} from '@angular/core' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatCheckboxModule } from '@angular/material/checkbox' +import { MatIconModule } from '@angular/material/icon' +import { MatInputModule } from '@angular/material/input' +import { + MatPaginator, + MatPaginatorDefaultOptions, + MatPaginatorIntl, + MatPaginatorModule +} from '@angular/material/paginator' +import { MatSort, MatSortModule } from '@angular/material/sort' +import { MatTableDataSource, MatTableModule } from '@angular/material/table' +import { DisplayDensity, OcapCoreModule } from '@metad/ocap-angular/core' +import { TranslateModule, TranslateService } from '@ngx-translate/core' +import get from 'lodash-es/get' +import { Subject } from 'rxjs' +import { NgmSearchComponent } from '../../search/search.component' +import { TableColumn } from '../types' + +@Injectable() +export class MyCustomPaginatorIntl implements MatPaginatorIntl { + #translateService = inject(TranslateService) + + changes = new Subject() + + #tranSub = this.#translateService + .stream('COMPONENTS.Table', { + Default: { + firstPageLabel: 'First page', + itemsPerPageLabel: 'Items per page:', + lastPageLabel: 'Last page', + nextPageLabel: 'Next page', + previousPageLabel: 'Previous page', + rangeLabel0: 'Page 1 of 1', + pageLabel: 'Page', + ofLabel: 'of' + } + }) + .pipe(takeUntilDestroyed()) + .subscribe((table) => { + if (table) { + this.firstPageLabel = table.firstPageLabel + this.itemsPerPageLabel = table.itemsPerPageLabel + this.lastPageLabel = table.lastPageLabel + this.nextPageLabel = table.nextPageLabel + this.previousPageLabel = table.previousPageLabel + this.rangeLabel0 = table.rangeLabel0 + this.pageLabel = table.pageLabel + this.ofLabel = table.ofLabel + } + }) + + // For internationalization, the `$localize` function from + // the `@angular/localize` package can be used. + firstPageLabel = `First page` + itemsPerPageLabel = `Items per page:` + lastPageLabel = `Last page` + + // You can set labels to an arbitrary string too, or dynamically compute + // it through other third-party internationalization libraries. + nextPageLabel = 'Next page' + previousPageLabel = 'Previous page' + + rangeLabel0 = `Page 1 of 1` + pageLabel = 'Page' + ofLabel = 'of' + + getRangeLabel(page: number, pageSize: number, length: number): string { + if (length === 0) { + return this.rangeLabel0 + } + const amountPages = Math.ceil(length / pageSize) + return `${this.pageLabel} ${page + 1} ${this.ofLabel} ${amountPages}` + } +} + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'ngm-table', + templateUrl: './table.component.html', + styleUrls: [`table.component.scss`], + host: { + class: 'ngm-table' + }, + imports: [ + CommonModule, + DragDropModule, + FormsModule, + ReactiveFormsModule, + MatCheckboxModule, + MatTableModule, + MatPaginatorModule, + MatIconModule, + MatButtonModule, + MatSortModule, + MatInputModule, + TranslateModule, + + //OCAP Modules + OcapCoreModule, + NgmSearchComponent + ] +}) +export class NgmTableComponent implements OnChanges, AfterViewInit { + @Input() columns: Array + @Input() get data(): Array { + return this._data + } + set data(value) { + this._data = value + this.dataSource.data = value ?? [] + } + private _data + + @Input() get paging(): boolean { + return this._paging + } + set paging(value: string | boolean) { + this._paging = coerceBooleanProperty(value) + } + private _paging: boolean + + @Input() pageSizeOptions: MatPaginatorDefaultOptions['pageSizeOptions'] = [20, 50, 100] + + @Input() get grid() { + return this._grid + } + set grid(value: string | boolean) { + this._grid = coerceBooleanProperty(value) + } + private _grid = false + + @Input() get selectable() { + return this._selectable + } + set selectable(value: string | boolean) { + this._selectable = coerceBooleanProperty(value) + } + private _selectable = false + + @Input() displayDensity: DisplayDensity | string = DisplayDensity.compact + + /** + * A cell or row was selected. + */ + // @Output() select: EventEmitter = new EventEmitter() + @Output() rowSelectionChanging = new EventEmitter() + + @ViewChild(MatPaginator) paginator: MatPaginator + @ViewChild(MatSort) sort: MatSort + + displayedColumns = [] + + dataSource = new MatTableDataSource() + + searchControl = new FormControl('') + searchingColumn = '' + selection = new SelectionModel(true, []) + + private _searchValueSub = this.searchControl.valueChanges.subscribe((value) => { + this.dataSource.filter = value + }) + constructor() { + this.selection.changed.subscribe(() => this.rowSelectionChanging.emit(this.selection.selected)) + } + + ngOnChanges({ columns }: SimpleChanges): void { + if (columns?.currentValue) { + this.displayedColumns = columns.currentValue.map(({ name }) => name) + if (this.selectable) { + this.displayedColumns.unshift('select') + } + } + } + + ngAfterViewInit() { + this.dataSource.paginator = this.paginator + this.dataSource.sort = this.sort + // If the user changes the sort order, reset back to the first page. + this.sort?.sortChange.subscribe((sort) => { + this.paginator.pageIndex = 0 + }) + + this.dataSource.filterPredicate = (data: any, filter: string): boolean => { + // Transform the data into a lowercase string of all property values. + const dataStr = ('' + data[this.searchingColumn]).toLowerCase() + + // Transform the filter by converting it to lowercase and removing whitespace. + const transformedFilter = filter.trim().toLowerCase() + + return dataStr.indexOf(transformedFilter) != -1 + } + } + + _context(data: Record, column: TableColumn) { + return { + ...data, + $implicit: get(data, column.name) + } + } + + getValue(row: any, name: string) { + return get(row, name) + } + + escapeSearching(event) { + if (event.key === 'Escape') { + this.searchingColumn = '' + this.searchControl.setValue('') + } + } + + /** Whether the number of selected elements matches the total number of rows. */ + isAllSelected() { + const numSelected = this.selection.selected.length + const numRows = this.dataSource.data.length + return numSelected === numRows + } + + /** Selects all rows if they are not all selected; otherwise clear selection. */ + toggleAllRows() { + if (this.isAllSelected()) { + this.selection.clear() + return + } + + this.selection.select(...this.dataSource.data) + } + + /** The label for the checkbox on the passed row */ + checkboxLabel(row?: any): string { + if (!row) { + return `${this.isAllSelected() ? 'deselect' : 'select'} all` + } + return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row ${row.position + 1}` + } +} diff --git a/packages/angular/common/table/types.ts b/packages/angular/common/table/types.ts new file mode 100644 index 000000000..2058a2421 --- /dev/null +++ b/packages/angular/common/table/types.ts @@ -0,0 +1,9 @@ +import { TemplateRef } from "@angular/core" +import { Property } from "@metad/ocap-core" + +export interface TableColumn extends Property { + width?: string + cellTemplate?: TemplateRef, + pipe?: (value: any) => any + searching?: boolean +} \ No newline at end of file diff --git a/packages/angular/copilot/avatar/avatar.component.ts b/packages/angular/copilot/avatar/avatar.component.ts new file mode 100644 index 000000000..094583873 --- /dev/null +++ b/packages/angular/copilot/avatar/avatar.component.ts @@ -0,0 +1,13 @@ +import { CommonModule } from '@angular/common' +import { Component, Input } from '@angular/core' +import { IUser } from '../types' + +@Component({ + standalone: true, + selector: 'ngm-copilopt-user-avatar', + template: `{{ user?.name }}`, + imports: [CommonModule] +}) +export class UserAvatarComponent { + @Input() user?: IUser +} diff --git a/packages/angular/copilot/chat/chat.component.html b/packages/angular/copilot/chat/chat.component.html new file mode 100644 index 000000000..c0b5eac35 --- /dev/null +++ b/packages/angular/copilot/chat/chat.component.html @@ -0,0 +1,263 @@ +
+ + {{ 'Ngm.Copilot.Copilot' | translate: {Default: 'Copilot'} }} + + + @if (copilotEngine?.name) { + :{{copilotEngine.name}} + } +
+ +
+ @for (conversation of (enabled && hasKey ? conversations : _mockConversations); track $index) { +
+
+
+
🤖
+
+
+
+ +
+ +
+ 🙈 + {{ conversation.error }} +
+ + + + +
+ +
+
+ + @if (conversation.role === CopilotChatMessageRoleEnum.User) { +
+
+
+ @if (conversation.data) { + + } @else { +
+
{{ conversation.content }}
+
+ + } + + @if (conversation.content) { + + } + + +
+ + +
+
+ } + + @if (conversation.role === CopilotChatMessageRoleEnum.Info) { +
+
+
+ {{ conversation.content }} +
+
+
+ } + } + +
+ +
+ 💡{{ 'Ngm.Copilot.AskAICopilot' | translate: {Default: 'Ask AI Copilot Questions'} }} +
+
+ /Command + {{ 'Ngm.Copilot.Prompt' | translate: {Default: 'prompt'} }} +
+
+ CTRL + Enter {{ 'Ngm.Copilot.SendPrompt' | translate: {Default: 'send prompt'} }} +
+
+
+ + + +
+ +
+ +
+ +
+ +
+ + + + + +
{{prompt}}
+
+
+ +
+ + + + + +
+
+
+ +@if (copilotNotEnabled()) { + +} + + +
+
+ {{ 'Ngm.Copilot.Options' | translate: {Default: 'Options'} }} +
+ +
+ + {{ 'Ngm.Copilot.UseSystemPrompt' | translate: {Default: 'Use System Prompt'} }} + +
+ +
+ + + +
+ + + + + {{model.label}} + + + + + + + + + + + + + + +
+
+ + + + + diff --git a/packages/angular/copilot/chat/chat.component.scss b/packages/angular/copilot/chat/chat.component.scss new file mode 100644 index 000000000..59c5b0875 --- /dev/null +++ b/packages/angular/copilot/chat/chat.component.scss @@ -0,0 +1,66 @@ +:host { + --ngm-copilot-bg-color: theme('colors.white'); + background-color: var(--ngm-copilot-bg-color); + @apply flex flex-col relative text-sm text-neutral-500 dark:text-neutral-300; +} + +.copilot-code-container { + @apply bg-gray-200 rounded-lg text-xs text-gray-500; +} + +.copilot-code-titlebar { + @apply flex justify-between items-center border-b-2 p-2; +} + +.copilot-code-copy-button { + @apply flex items-center; +} + +.copilot-code-content { + @apply overflow-x-auto overflow-y-hidden p-2 whitespace-pre; +} + +.copilot-message-stop { + .emoji-loader { + width: 1.5rem; + } + .emoji-loader::before { + font-size: 1rem; + line-height: 1rem; + } +} + +.ngm-copilot__message-remove.mat-mdc-icon-button { + background-color: var(--ngm-copilot-bg-color); + @apply absolute z-[101] text-slate-500 hover:text-red-500 hover:bg-slate-50; +} +.pac-colpilot__clear-messages { + @apply border-dashed; +} + +.ngm-copilot__resubmit { + @apply my-2 rounded-full; +} + +.mat-mdc-input-element { + @apply outline-none bg-transparent; +} + +.ngm-copilot__user-message { + @apply bg-bluegray-100/50 text-black; +} + +.pac-colpilot__active { + @apply shadow-lg; +} + +:host::ng-deep { + markdown p:first-child { + @apply indent-4; + } + + // popper + .ngxp__container.ngxp__animation { + @apply p-0 shadow-none border-0; + } +} diff --git a/packages/angular/copilot/chat/chat.component.ts b/packages/angular/copilot/chat/chat.component.ts new file mode 100644 index 000000000..704921ba1 --- /dev/null +++ b/packages/angular/copilot/chat/chat.component.ts @@ -0,0 +1,633 @@ +import { ClipboardModule } from '@angular/cdk/clipboard' +import { TextFieldModule } from '@angular/cdk/text-field' +import { CommonModule } from '@angular/common' +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + computed, + effect, + ElementRef, + EventEmitter, + inject, + Input, + Output, + signal, + ViewChild +} from '@angular/core' +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop' +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms' +import { MatAutocomplete, MatAutocompleteActivatedEvent, MatAutocompleteModule } from '@angular/material/autocomplete' +import { MatButtonModule } from '@angular/material/button' +import { MatIconModule } from '@angular/material/icon' +import { MatListModule } from '@angular/material/list' +import { MatProgressBarModule } from '@angular/material/progress-bar' +import { MatSliderModule } from '@angular/material/slider' +import { MatTooltipModule } from '@angular/material/tooltip' +import { MatInputModule } from '@angular/material/input' +import { RouterModule } from '@angular/router' +import { CopilotChatMessage, CopilotChatMessageRoleEnum, CopilotEngine } from '@metad/copilot' +import { NgmSearchComponent, NgmTableComponent } from '@metad/ocap-angular/common' +import { DensityDirective, getErrorMessage } from '@metad/ocap-angular/core' +import { isString, pick } from '@metad/ocap-core' +import { TranslateModule, TranslateService } from '@ngx-translate/core' +import { MarkdownModule } from 'ngx-markdown' +import { + NgxPopperjsContentComponent, + NgxPopperjsModule, + NgxPopperjsPlacements, + NgxPopperjsTriggers +} from 'ngx-popperjs' +import { CreateChatCompletionRequest } from 'openai' +import { BehaviorSubject, combineLatest, delay, firstValueFrom, map, scan, startWith, Subscription, tap } from 'rxjs' +import { CopilotEnableComponent } from '../enable/enable.component' +import { NgmCopilotService } from '../services/' +import { CopilotChatTokenComponent } from '../token/token.component' +import { UserAvatarComponent } from '../avatar/avatar.component' +import { IUser } from '../types' + + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'ngm-copilot-chat', + templateUrl: 'chat.component.html', + styleUrls: ['chat.component.scss'], + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + RouterModule, + TextFieldModule, + ClipboardModule, + MatInputModule, + MatIconModule, + MatButtonModule, + MatTooltipModule, + MatAutocompleteModule, + MatProgressBarModule, + MatListModule, + MatSliderModule, + TranslateModule, + NgxPopperjsModule, + MarkdownModule, + + DensityDirective, + NgmSearchComponent, + NgmTableComponent, + + CopilotChatTokenComponent, + CopilotEnableComponent, + UserAvatarComponent, + ], + host: { + class: 'ngm-copilot-chat' + } +}) +export class NgmCopilotChatComponent { + NgxPopperjsPlacements = NgxPopperjsPlacements + NgxPopperjsTriggers = NgxPopperjsTriggers + CopilotChatMessageRoleEnum = CopilotChatMessageRoleEnum + + private popperjsContentComponent = inject(NgxPopperjsContentComponent, { optional: true }) + private translateService = inject(TranslateService) + private _cdr = inject(ChangeDetectorRef) + private copilotService = inject(NgmCopilotService) + + @Input() welcomeTitle: string + @Input() welcomeSubTitle: string + @Input() get systemPrompt(): string { + return this.copilotEngine ? this.copilotEngine.systemPrompt : this._systemPrompt() + } + set systemPrompt(value: string) { + this._systemPrompt.set(value) + } + private readonly _systemPrompt = signal(null) + + @Input() get conversations(): CopilotChatMessage[] { + return this.copilotEngine ? this.copilotEngine.conversations : this._conversations() + } + set conversations(value) { + if (this.copilotEngine) { + this.copilotEngine.conversations = value + } else { + this._conversations.set(value) + } + } + private readonly _conversations = signal([]) + + @Input() copilotEngine: CopilotEngine + @Input() user: IUser + + @Output() copy = new EventEmitter() + @Output() conversationsChange = new EventEmitter() + + @ViewChild('chatsContent') chatsContent: ElementRef + @ViewChild('copilotOptions') copilotOptions: NgxPopperjsContentComponent + + get enabled() { + return this.copilotService.enabled + } + get hasKey() { + return this.copilotService.hasKey + } + + get prompts() { + return this.copilotEngine?.prompts + } + get placeholder() { + return this.copilotEngine?.placeholder + } + + _mockConversations: CopilotChatMessage[] = [ + { + role: CopilotChatMessageRoleEnum.User, + content: '你好' + }, + { + role: CopilotChatMessageRoleEnum.Assistant, + content: '你好!有什么我可以帮忙的吗?' + }, + { + role: CopilotChatMessageRoleEnum.User, + content: '你是谁' + }, + { + role: CopilotChatMessageRoleEnum.Assistant, + content: + '我是ChatGPT,一个由OpenAI训练的自然语言处理模型。我可以回答各种问题并提供各种帮助。请问有什么我可以为您做的吗?' + }, + { + role: CopilotChatMessageRoleEnum.User, + content: '假如你是我的 AI pair programmer' + }, + { + role: CopilotChatMessageRoleEnum.Assistant, + content: `如果我是您的 AI pair programmer,那么我会与您合作编写代码,并提供技术支持和建议。我可以帮助您识别和纠正代码中的错误,优化代码性能,同时也可以为您提供实用的编程技巧和最佳实践。在与您的合作中,我将尽力提高我们的生产力和效率,并确保我们在团队合作中最大化我们的技能和资源。 + 然而,需要注意的是,我只是一个机器人,并不能像人类程序员一样创造独特的解决方案或应对具有挑战性的技术问题。我的工作方式是基于预设的算法和模型,因此在与我合作时,您可能需要提供更多的背景信息和指导,以确保我们的工作结果达到您的期望。` + } + ] + // examplesOpened = true + // Copilot + private openaiOptions = { + model: 'gpt-3.5-turbo', + useSystemPrompt: true + } as CreateChatCompletionRequest & { useSystemPrompt?: boolean } + get aiOptions() { + return this.copilotEngine?.aiOptions ?? this.openaiOptions + } + get useSystemPrompt() { + return this.aiOptions.useSystemPrompt + } + set useSystemPrompt(value) { + if (this.copilotEngine) { + this.copilotEngine.aiOptions = { ...this.aiOptions, useSystemPrompt: value } + } else { + this.openaiOptions.useSystemPrompt = value + } + } + get model() { + return this.aiOptions.model + } + set model(value) { + if (this.copilotEngine) { + this.copilotEngine.aiOptions = { ...this.aiOptions, model: value } + } else { + this.openaiOptions.model = value + } + } + + selectedModel = [this.aiOptions.model] + + get temperature() { + return this.aiOptions.temperature + } + set temperature(value) { + if (this.copilotEngine) { + this.copilotEngine.aiOptions = { ...this.aiOptions, temperature: value } + } else { + this.openaiOptions.temperature = value + } + } + get n() { + return this.aiOptions.n + } + set n(value) { + if (this.copilotEngine) { + this.copilotEngine.aiOptions = { ...this.aiOptions, n: value } + } else { + this.openaiOptions.n = value + } + } + + /** + * 当前 Asking prompt + */ + public promptControl = new FormControl('') + get prompt() { + return this.promptControl.value + } + set prompt(value) { + this.promptControl.setValue(value) + } + + private activatedPrompt = '' + + readonly answering = signal(false) + + readonly historyQuestions = signal([]) + private readonly historyIndex = signal(-1) + + askController: AbortController + askSubscriber: Subscription + + // Available models + private readonly _models$ = new BehaviorSubject<{ id: string; label: string }[]>([ + { + id: 'gpt-3.5-turbo', + label: 'gpt-3.5-turbo' + }, + { + id: 'gpt-4', + label: 'gpt-4' + }, + { + id: 'gpt-4-32k', + label: 'gpt-4-32k' + } + ]) + searchModel = new FormControl('') + public readonly models = toSignal( + combineLatest([this._models$, this.searchModel.valueChanges.pipe(startWith(''))]).pipe( + map(([_models, text]) => (text ? _models.filter((item) => item.label.includes(text)) : _models)) + ), + { initialValue: [] } + ) + + public readonly copilotNotEnabled = toSignal(this.copilotService.notEnabled$) + + private readonly lastConversation = computed(() => { + // Get last conversation messages + const lastMessages = [] + let lastUserMessage = null + for (let i = this.conversations.length - 1; i >= 0; i--) { + if (this.conversations[i].end) { + break + } + if (this.conversations[i].role === CopilotChatMessageRoleEnum.User) { + if (lastUserMessage) { + lastUserMessage.content = this.conversations[i].content + '\n' + lastUserMessage.content + } else { + lastUserMessage = { + role: CopilotChatMessageRoleEnum.User, + content: this.conversations[i].content + } + } + } else { + if (lastUserMessage) { + lastMessages.push(lastUserMessage) + lastUserMessage = null + } + lastMessages.push(this.conversations[i]) + } + } + if (lastUserMessage) { + lastMessages.push(lastUserMessage) + } + return lastMessages.reverse() + }) + + public readonly filteredPrompts = toSignal( + this.promptControl.valueChanges.pipe( + startWith(''), + map((text) => (text ? this.prompts?.filter((item) => item.includes(text)) ?? [] : [])), + tap(() => (this.activatedPrompt = null)) + ) + ) + + // Subscribers + private _copilotSub = this.copilotService.copilot$.pipe(delay(1000), takeUntilDestroyed()).subscribe(() => { + this._cdr.detectChanges() + }) + + constructor() { + effect( + () => { + this.answering() ? this.promptControl.disable() : this.promptControl.enable() + }, + { allowSignalWrites: true } + ) + } + + refreshModels() { + this.copilotService.getModels().subscribe((res) => { + this._models$.next(res.data.map((model) => ({ id: model.id, label: model.id }))) + }) + } + + changeSelectedModel(values) { + this.model = values[0] + } + + async askPredefinedPrompt(prompt: string) { + this.stopGenerating() + prompt = await firstValueFrom(this.translateService.get('PAC.Copilot.Prompts.' + prompt, { Default: prompt })) + await this.askCopilotStream(prompt, true) + } + + async askCopilotStream(prompt: string, newConversation?: boolean) { + // Reset history index + this.historyIndex.set(-1) + // Add to history + this.historyQuestions.set([prompt, ...this.historyQuestions()]) + // Clear prompt in input + this.prompt = '' + + // Get last conversation messages + const lastConversation = this.lastConversation() + + // Append user question message + this.conversations = this.conversations ?? [] + this.conversations = [ + ...this.conversations, + { + role: CopilotChatMessageRoleEnum.User, + content: prompt + } + ] + + // Assistant message + const assistant: CopilotChatMessage = { + role: CopilotChatMessageRoleEnum.Assistant, + content: '' + } + + // Answering + this.answering.set(true) + // 由其他引擎接手处理 + if (this.copilotEngine) { + if (lastConversation.length > 0) { + // 无论是否为新对话,都将最新的连续的提问消息内容汇总 + if (lastConversation[lastConversation.length - 1]?.role === CopilotChatMessageRoleEnum.User) { + prompt = lastConversation[lastConversation.length - 1].content + '\n' + prompt + lastConversation.splice(lastConversation.length - 1, 1) + } + + // 如果是新会话,清空上一次的会话 + if (newConversation) { + lastConversation.splice(0, lastConversation.length) + } + } + + const assistantIndex = this.conversations.length + this.conversations = [...this.conversations, assistant] + + try { + this.askSubscriber = this.copilotEngine.process({ prompt, messages: lastConversation }).subscribe({ + next: (result) => { + const conversations = [...this.conversations] + if (isString(result)) { + conversations[assistantIndex] = { ...conversations[assistantIndex] } + conversations[assistantIndex].content = result + // 为什么要 end 对话? + // conversations[assistantIndex].end = true + } else { + conversations.splice(this.conversations.length, 0, ...result) + } + + this.conversations = conversations + this._cdr.detectChanges() + this.scrollBottom() + }, + error: (err) => { + console.error(err) + const conversations = [...this.conversations] + conversations[assistantIndex] = { ...conversations[assistantIndex] } + conversations[assistantIndex].content = null + conversations[assistantIndex].error = getErrorMessage(err) + this.answering.set(false) + this.conversations = conversations + this.conversationsChange.emit(this.conversations) + this._cdr.detectChanges() + }, + complete: () => { + this.answering.set(false) + // Not cleared + if (this.conversations.length) { + const conversations = [...this.conversations] + if (!conversations[assistantIndex].content) { + conversations.splice(assistantIndex, 1) + } + if (this.conversations[this.conversations.length - 1].role === CopilotChatMessageRoleEnum.Info) { + conversations.splice(conversations.length - 1, 1) + } + this.conversations = conversations + this.conversationsChange.emit(this.conversations) + this._cdr.detectChanges() + } + } + }) + } catch (err) { + const conversations = [...this.conversations] + conversations[assistantIndex] = { ...conversations[assistantIndex] } + conversations[assistantIndex].content = null + conversations[assistantIndex].error = getErrorMessage(err) + this.answering.set(false) + this.conversations = conversations + this.conversationsChange.emit(this.conversations) + this._cdr.detectChanges() + } + + return + } + + // 系统提示 + const messages: CopilotChatMessage[] = + this.openaiOptions.useSystemPrompt && this.systemPrompt + ? [ + { + role: CopilotChatMessageRoleEnum.System, + content: this.systemPrompt + } + ] + : [] + // 合并连续的提问消息:如数据表和提问合并 + messages.push( + ...this.conversations + .filter((item) => !item.error && !!item.content) + .reduceRight((prev, curr) => { + if (!prev.length) { + return [pick(curr, 'role', 'content')] + } + + if (curr.role === prev[prev.length - 1].role) { + prev[prev.length - 1].content = [curr.content, prev[prev.length - 1].content].join('\n') + } else { + prev.push(pick(curr, 'role', 'content')) + } + return prev + }, []) + .reverse() + ) + + this.conversations = [...this.conversations, assistant] + + this.scrollBottom() + + this.askSubscriber = this.copilotService + .chatStream(messages) + .pipe( + scan((acc, value: any) => acc + (value?.choices?.[0]?.delta?.content ?? ''), ''), + map((content) => content.trim()) + ) + .subscribe({ + next: (content) => { + assistant.content = content + this._cdr.detectChanges() + + this.scrollBottom() + }, + error: (err) => { + this.answering.set(false) + assistant.content = null + assistant.error = getErrorMessage(err) + + this.conversationsChange.emit(this.conversations) + this._cdr.detectChanges() + }, + complete: () => { + this.answering.set(false) + this.conversationsChange.emit(this.conversations) + this._cdr.detectChanges() + } + }) + } + + stopGenerating() { + this.askController?.abort() + this.askSubscriber?.unsubscribe() + this.answering.set(false) + this.conversationsChange.emit(this.conversations) + + this.scrollBottom() + } + + onCopy(copyButton) { + copyButton.copied = true + setTimeout(() => { + copyButton.copied = false + }, 3000) + } + + scrollBottom() { + setTimeout(() => { + this.chatsContent.nativeElement.scrollTo({ + top: this.chatsContent.nativeElement.scrollHeight, + left: 0, + behavior: 'smooth' + }) + }, 300) + } + + async send(text: string) { + this.prompt = text + // await this.askCopilot(this.prompt) + } + + async run(event?: KeyboardEvent) { + console.log(event) + await navigator.clipboard.readText() + } + + async clear() { + this.conversations = [] + this.conversationsChange.emit(this.conversations) + } + + closePopper() { + this.popperjsContentComponent?.toggleVisibility(false) + } + + async addMessage(message: CopilotChatMessage) { + this.conversations ??= [] + this.conversations = [...this.conversations, message] + this.scrollBottom() + this._cdr.detectChanges() + } + + deleteMessage(message: CopilotChatMessage) { + const index = this.conversations.indexOf(message) + if (index > -1) { + const conversations = [...this.conversations] + conversations.splice(index, 1) + this.conversations = conversations + this.conversationsChange.emit(this.conversations) + } + } + + async resubmitMessage(message: CopilotChatMessage, content: string) { + const index = this.conversations.indexOf(message) + if (index > -1) { + const conversations = [...this.conversations] + // 删除答案 + if (conversations[index + 1]?.role === CopilotChatMessageRoleEnum.Assistant) { + conversations.splice(index + 1, 1) + } + // 删除提问 + conversations.splice(index, 1) + + this.conversations = conversations + await this.askCopilotStream(content) + } + } + + onMessageFocus() { + this._cdr.detectChanges() + } + + isFoucs(target: HTMLDivElement | HTMLTextAreaElement) { + return document.activeElement === target + } + + triggerFun(event: KeyboardEvent, autocomplete: MatAutocomplete) { + if (event.ctrlKey && event.key === 'Enter') { + this.askCopilotStream(this.prompt) + } + + // Tab 键补全提示语 + if (event.key === 'Tab') { + event.preventDefault() + const activatedPrompt = this.activatedPrompt || this.filteredPrompts()[0] + if (activatedPrompt) { + this.prompt = activatedPrompt + } + } + + if (!autocomplete.isOpen && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) { + event.preventDefault() + + const historyQuestions = this.historyQuestions() + if (historyQuestions.length) { + if (event.key === 'ArrowUp' && this.historyIndex() < historyQuestions.length - 1) { + this.historyIndex.set(this.historyIndex() + 1) + } else if (event.key === 'ArrowDown' && this.historyIndex() > -1) { + this.historyIndex.set(this.historyIndex() - 1) + } else { + return + } + + this.prompt = historyQuestions[this.historyIndex()] ?? '' + } + } + } + + onPromptActivated(event: MatAutocompleteActivatedEvent) { + this.activatedPrompt = event.option?.value + } + + dropCopilot(event) { + if (this.copilotEngine) { + this.copilotEngine.dropCopilot(event) + } + } +} diff --git a/packages/angular/copilot/enable/enable.component.html b/packages/angular/copilot/enable/enable.component.html new file mode 100644 index 000000000..8f41ddcd1 --- /dev/null +++ b/packages/angular/copilot/enable/enable.component.html @@ -0,0 +1,23 @@ +@if (!copilotConfig()?.enabled || !copilotConfig()?.apiKey) { +
+
+ {{ title }} +
+
+ {{ subTitle }} +
+ + @if (copilotConfig()?.enabled && !copilotConfig()?.apiKey) { +
+ {{ 'Ngm.Copilot.ProvideOpenaiApiKey' | translate: {Default: 'Please provide openai api key!'} }} +
+ } + + +
+} diff --git a/packages/angular/copilot/enable/enable.component.scss b/packages/angular/copilot/enable/enable.component.scss new file mode 100644 index 000000000..c69cba653 --- /dev/null +++ b/packages/angular/copilot/enable/enable.component.scss @@ -0,0 +1,3 @@ +:host { + @apply flex absolute w-full h-full top-0 left-0; +} diff --git a/packages/angular/copilot/enable/enable.component.ts b/packages/angular/copilot/enable/enable.component.ts new file mode 100644 index 000000000..db8b0e9fb --- /dev/null +++ b/packages/angular/copilot/enable/enable.component.ts @@ -0,0 +1,34 @@ +import { CommonModule } from '@angular/common' +import { ChangeDetectionStrategy, Component, EventEmitter, inject, Input, Output } from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +import { MatButtonModule } from '@angular/material/button' +import { MatIconModule } from '@angular/material/icon' +import { RouterModule } from '@angular/router' +import { DensityDirective } from '@metad/ocap-angular/core' +import { TranslateModule } from '@ngx-translate/core' +import { NgmCopilotService } from '../services' + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'ngm-copilot-enable', + templateUrl: 'enable.component.html', + styleUrls: ['enable.component.scss'], + imports: [CommonModule, RouterModule, TranslateModule, MatIconModule, MatButtonModule, DensityDirective], + host: { + class: 'ngm-copilot-enable' + } +}) +export class CopilotEnableComponent { + private copilotService = inject(NgmCopilotService) + + @Input() title: string + @Input() subTitle: string + @Output() toConfig = new EventEmitter() + + readonly copilotConfig = toSignal(this.copilotService.copilot$) + + navigateToConfig() { + this.toConfig.emit() + } +} diff --git a/packages/angular/copilot/global/global.component.html b/packages/angular/copilot/global/global.component.html new file mode 100644 index 000000000..3793641ec --- /dev/null +++ b/packages/angular/copilot/global/global.component.html @@ -0,0 +1,16 @@ + + + + + \ No newline at end of file diff --git a/packages/angular/copilot/global/global.component.scss b/packages/angular/copilot/global/global.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/packages/angular/copilot/global/global.component.ts b/packages/angular/copilot/global/global.component.ts new file mode 100644 index 000000000..315df2bc5 --- /dev/null +++ b/packages/angular/copilot/global/global.component.ts @@ -0,0 +1,40 @@ +import { CommonModule } from '@angular/common' +import { ChangeDetectionStrategy, Component, Input, inject } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { MatTooltipModule } from '@angular/material/tooltip' +import { DensityDirective, DisplayDensity } from '@metad/ocap-angular/core' +import { TranslateModule } from '@ngx-translate/core' +import { NgxPopperjsModule, NgxPopperjsPlacements, NgxPopperjsTriggers } from 'ngx-popperjs' +import { NgmCopilotChatComponent } from '../chat/chat.component' +import { NgmCopilotService } from '../services' +import { CopilotGlobalService } from './global.service' + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'ngm-copilot-global', + templateUrl: 'global.component.html', + styleUrls: ['global.component.scss'], + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + TranslateModule, + NgxPopperjsModule, + DensityDirective, + MatTooltipModule, + + NgmCopilotChatComponent + ], + host: { + class: 'ngm-copilot-global' + } +}) +export class CopilotGlobalComponent { + NgxPopperjsPlacements = NgxPopperjsPlacements + NgxPopperjsTriggers = NgxPopperjsTriggers + private copilotService = inject(NgmCopilotService) + public copilotGlobalService = inject(CopilotGlobalService) + + @Input() displayDensity: DisplayDensity | string +} diff --git a/packages/angular/copilot/global/global.service.ts b/packages/angular/copilot/global/global.service.ts new file mode 100644 index 000000000..f9dc058b5 --- /dev/null +++ b/packages/angular/copilot/global/global.service.ts @@ -0,0 +1,7 @@ +import { Injectable } from '@angular/core' +import { CopilotEngine } from '@metad/copilot' + +@Injectable({ providedIn: 'root' }) +export class CopilotGlobalService { + copilotEngine: CopilotEngine +} diff --git a/packages/angular/copilot/index.ts b/packages/angular/copilot/index.ts new file mode 100644 index 000000000..3d7f9e476 --- /dev/null +++ b/packages/angular/copilot/index.ts @@ -0,0 +1,5 @@ +export * from './global/global.component' +export * from './chat/chat.component' +export * from './global/global.service' +export * from './enable/enable.component' +export * from './services/' \ No newline at end of file diff --git a/packages/angular/copilot/services/copilot.service.ts b/packages/angular/copilot/services/copilot.service.ts new file mode 100644 index 000000000..7e25aafdb --- /dev/null +++ b/packages/angular/copilot/services/copilot.service.ts @@ -0,0 +1,72 @@ +import { Injectable, InjectionToken, inject } from '@angular/core' +import { CopilotService, ICopilot } from '@metad/copilot' +import type { AxiosRequestConfig } from 'axios' +import { Configuration, CreateCompletionRequest, CreateEditRequest, OpenAIApi } from 'openai' +import { BehaviorSubject } from 'rxjs' +import { map } from 'rxjs/operators' + + +@Injectable() +export class NgmCopilotService extends CopilotService { + static CopilotConfigFactoryToken = new InjectionToken<() => Promise>('CopilotConfigFactoryToken') + + #copilotConfigFactory: () => Promise = inject(NgmCopilotService.CopilotConfigFactoryToken) + + private _copilot$ = new BehaviorSubject(null) + public copilot$ = this._copilot$.asObservable() + public notEnabled$ = this.copilot$.pipe(map((copilot) => !(copilot?.enabled && copilot?.apiKey))) + + get enabled() { + return this.copilot?.enabled + } + get hasKey() { + return !!this.copilot?.apiKey + } + + configuration: Configuration + openai: OpenAIApi + + constructor() { + super() + + // Init copilot config + this.#copilotConfigFactory().then((copilot) => { + console.log(copilot) + this.copilot = copilot + this.configuration = new Configuration({ + apiKey: copilot.apiKey + }) + this.openai = new OpenAIApi(this.configuration) + this._copilot$.next(copilot) + }) + } + + async createCompletion( + prompt: string, + options?: { completionRequest?: CreateCompletionRequest; axiosConfig?: AxiosRequestConfig } + ) { + const { completionRequest, axiosConfig } = options ?? {} + const completion = await this.openai.createCompletion( + { + model: 'text-davinci-003', + prompt: prompt, + temperature: 0.6, + max_tokens: 1000, + ...(completionRequest ?? {}) + }, + // 由于本项目用到 Axios 与 openAi APi 中用到的 Axios 版本不一样,导致 AxiosRequestConfig 中的 method 类型有所不同 + axiosConfig as any + ) + + return completion.data.choices + } + + async createEdit(editRequest: Partial) { + const edit = await this.openai.createEdit({ + ...editRequest, + model: 'code-davinci-edit-001' + } as CreateEditRequest) + + return edit.data.choices + } +} diff --git a/packages/angular/copilot/services/index.ts b/packages/angular/copilot/services/index.ts new file mode 100644 index 000000000..22bd2c999 --- /dev/null +++ b/packages/angular/copilot/services/index.ts @@ -0,0 +1 @@ +export * from './copilot.service' \ No newline at end of file diff --git a/packages/angular/copilot/stories/chat.stories.ts b/packages/angular/copilot/stories/chat.stories.ts new file mode 100644 index 000000000..a2a49cc65 --- /dev/null +++ b/packages/angular/copilot/stories/chat.stories.ts @@ -0,0 +1,107 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { CommonModule } from '@angular/common' +import { provideHttpClient } from '@angular/common/http' +import { importProvidersFrom } from '@angular/core' +import { provideAnimations } from '@angular/platform-browser/animations' +import { AIOptions, CopilotChatMessage, CopilotChatResponseChoice, CopilotEngine } from '@metad/copilot' +import { OcapCoreModule } from '@metad/ocap-angular/core' +import { Meta, StoryObj, applicationConfig, argsToTemplate, moduleMetadata } from '@storybook/angular' +import { MarkdownModule } from 'ngx-markdown' +import { Observable, of } from 'rxjs' +import { provideTranslate, zhHansLanguage } from '../../mock/' +import { NgmCopilotChatComponent } from '../chat/chat.component' +import { NgmCopilotService } from '../services' + +export default { + title: 'Copilot/Chat', + component: NgmCopilotChatComponent, + decorators: [ + applicationConfig({ + providers: [ + provideAnimations(), + provideHttpClient(), + provideTranslate(zhHansLanguage), + importProvidersFrom(OcapCoreModule), + importProvidersFrom(MarkdownModule.forRoot()) + ] + }), + moduleMetadata({ + imports: [CommonModule, NgmCopilotChatComponent], + providers: [ + NgmCopilotService, + { + provide: NgmCopilotService.CopilotConfigFactoryToken, + useValue: () => + Promise.resolve({ + enabled: true, + apiKey: 'sk-xxxxxxxxxxxxxxx' + }) + } + ] + }) + ] +} as Meta + +type Story = StoryObj + +export const Primary: Story = { + args: { + welcomeTitle: 'Welcome to My AI Copilot' + } +} + +export const Size: Story = { + render: (args) => ({ + props: args, + template: `` + }), + args: { + welcomeTitle: 'Welcome to My AI Copilot' + }, + parameters: { + background: { default: 'dark' }, + actions: { argTypesRegex: '^conversations.*' } + } +} + +class StorybookCopilotEngine implements CopilotEngine { + name?: string = 'Storybook custom engine' + aiOptions: AIOptions = { + model: '', + messages: [] + } + systemPrompt?: string + prompts: string[] = ['/d {name} {age}'] + conversations: CopilotChatMessage[] = [] + placeholder?: string + + process( + data: { prompt: string; messages?: CopilotChatMessage[] }, + options?: { action?: string } + ): Observable { + if (data.prompt === '/d {name} {age}') { + const name = options?.action || 'John' + const age = options?.action || '18' + return of(`My name is ${name}, I am ${age} years old.`) + } + + return of('Non') + } + preprocess?(prompt: string, options?: any) { + // + } + postprocess?(prompt: string, choices: CopilotChatResponseChoice[]): Observable { + throw new Error('Method not implemented.') + } + dropCopilot?(event: never) { + throw new Error('Method not implemented.') + } +} + +export const CustomEngine: Story = { + args: { + copilotEngine: new StorybookCopilotEngine() + } +} diff --git a/packages/angular/copilot/stories/not-enabled.stories.ts b/packages/angular/copilot/stories/not-enabled.stories.ts new file mode 100644 index 000000000..470604d51 --- /dev/null +++ b/packages/angular/copilot/stories/not-enabled.stories.ts @@ -0,0 +1,48 @@ +import { CommonModule } from '@angular/common' +import { provideHttpClient } from '@angular/common/http' +import { importProvidersFrom } from '@angular/core' +import { provideAnimations } from '@angular/platform-browser/animations' +import { OcapCoreModule } from '@metad/ocap-angular/core' +import { Meta, applicationConfig, moduleMetadata } from '@storybook/angular' +import { MarkdownModule } from 'ngx-markdown' +import { provideTranslate, zhHansLanguage } from '../../mock/' +import { NgmCopilotChatComponent } from '../chat/chat.component' +import { NgmCopilotService } from '../services' + +export default { + title: 'Copilot/NotEnabled', + component: NgmCopilotChatComponent, + decorators: [ + applicationConfig({ + providers: [ + provideAnimations(), + provideHttpClient(), + provideTranslate(zhHansLanguage), + importProvidersFrom(OcapCoreModule), + importProvidersFrom(MarkdownModule.forRoot()) + ] + }), + moduleMetadata({ + imports: [CommonModule, NgmCopilotChatComponent], + providers: [ + NgmCopilotService, + { + provide: NgmCopilotService.CopilotConfigFactoryToken, + useValue: () => + Promise.resolve({ + enabled: false, + apiKey: '' + }) + } + ] + }) + ] +} as Meta + +export const Primary = { + args: { + title: 'Primary', + welcomeTitle: 'Welcome to My AI Copilot', + welcomeSubTitle: 'Your AI Copilot' + } +} diff --git a/packages/angular/copilot/token/token.component.ts b/packages/angular/copilot/token/token.component.ts new file mode 100644 index 000000000..5f98c5961 --- /dev/null +++ b/packages/angular/copilot/token/token.component.ts @@ -0,0 +1,39 @@ +import { CommonModule } from '@angular/common' +import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core' +import { MatTooltipModule } from '@angular/material/tooltip' +import { TranslateModule } from '@ngx-translate/core' + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'ngm-copilot-token', + template: ` + + + {{ characterLength }} + `, + styles: [ + ` + :host { + } + ` + ], + imports: [CommonModule, MatTooltipModule, TranslateModule], + host: { + class: 'ngm-copilot-token' + } +}) +export class CopilotChatTokenComponent implements OnChanges { + @Input() content: string + + characterLength = 0 + + ngOnChanges({ content }: SimpleChanges): void { + if (content) { + this.characterLength = content.currentValue?.length + } + } +} diff --git a/packages/angular/copilot/types.ts b/packages/angular/copilot/types.ts new file mode 100644 index 000000000..f9cc9020f --- /dev/null +++ b/packages/angular/copilot/types.ts @@ -0,0 +1,4 @@ +export type IUser = { + name?: string + imageUrl?: string +} \ No newline at end of file diff --git a/packages/angular/core/directives/density.stories.ts b/packages/angular/core/directives/density.stories.ts index f63997f1e..8e48e2266 100644 --- a/packages/angular/core/directives/density.stories.ts +++ b/packages/angular/core/directives/density.stories.ts @@ -1,27 +1,22 @@ +import { provideHttpClient } from '@angular/common/http' import { MatButtonModule } from '@angular/material/button' import { MatCheckboxModule } from '@angular/material/checkbox' -import {MatChipsModule} from '@angular/material/chips'; +import { MatChipsModule } from '@angular/material/chips' import { MatIconModule } from '@angular/material/icon' +import { provideAnimations } from '@angular/platform-browser/animations' import { NgmSearchComponent } from '@metad/ocap-angular/common' -import { NgmMissingTranslationHandler, OcapCoreModule } from '@metad/ocap-angular/core' -import { MissingTranslationHandler, TranslateLoader, TranslateModule } from '@ngx-translate/core' -import { moduleMetadata } from '@storybook/angular' -import { CustomTranslateLoader } from '../i18n/loader.spec' +import { OcapCoreModule } from '@metad/ocap-angular/core' +import { provideTranslate } from '@metad/ocap-angular/mock' +import { applicationConfig, moduleMetadata } from '@storybook/angular' export default { title: 'DisplayDensity', decorators: [ + applicationConfig({ + providers: [provideAnimations(), provideHttpClient(), provideTranslate()] + }), moduleMetadata({ - imports: [ - TranslateModule.forRoot({ - missingTranslationHandler: { - provide: MissingTranslationHandler, - useClass: NgmMissingTranslationHandler - }, - loader: { provide: TranslateLoader, useClass: CustomTranslateLoader }, - defaultLanguage: 'zh-Hans' - }), - OcapCoreModule, MatIconModule, MatButtonModule, MatChipsModule, MatCheckboxModule, NgmSearchComponent], + imports: [OcapCoreModule, MatIconModule, MatButtonModule, MatChipsModule, MatCheckboxModule, NgmSearchComponent], providers: [] }) ] @@ -85,7 +80,5 @@ const Template = (args: any) => ({ }) export const Primary = Template.bind({ - args: { - - } + args: {} }) diff --git a/packages/angular/core/helpers.ts b/packages/angular/core/helpers.ts index 98ef9b198..c0fbfe4e9 100644 --- a/packages/angular/core/helpers.ts +++ b/packages/angular/core/helpers.ts @@ -1,5 +1,5 @@ import { isPlatformBrowser } from '@angular/common' -import { HttpParams } from '@angular/common/http' +import { HttpErrorResponse, HttpParams } from '@angular/common/http' import { Inject, Injectable, PLATFORM_ID, DebugElement, EventEmitter } from '@angular/core' import { takeUntilDestroyed } from '@angular/core/rxjs-interop' import { isNil, negate, isEqual, isEmpty, merge, isString, includes } from 'lodash-es' @@ -359,7 +359,7 @@ export function makeid(length) { const charactersLength = characters.length // 首字母为英文字符 let result = chars.charAt(Math.floor(Math.random() * chars.length)) - for (var i = 0; i < length - 1; i++) { + for (let i = 0; i < length - 1; i++) { result += characters.charAt(Math.floor(Math.random() * charactersLength)) } return result @@ -515,3 +515,24 @@ export function effectAction< }); }) as unknown) as ReturnType; } + +/** + * Try get error message from any error object + */ +export function getErrorMessage(err: any): string { + let error: string + if (typeof err === 'string') { + error = err + } else if (err instanceof HttpErrorResponse) { + error = err?.error?.message ?? err.message + } else if (err instanceof Error) { + error = err?.message + } else if (err?.error instanceof Error) { + error = err?.error?.message + } else if (err) { + // 实在没办法则转成 JSON string + error = JSON.stringify(err) + } + + return error +} diff --git a/packages/angular/core/i18n/loader.spec.ts b/packages/angular/core/i18n/loader.spec.ts deleted file mode 100644 index ab5f5fd9d..000000000 --- a/packages/angular/core/i18n/loader.spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ZhHans } from '@metad/ocap-angular/i18n' -import { TranslateLoader } from '@ngx-translate/core' -import { Observable, of } from 'rxjs' - -export class CustomTranslateLoader implements TranslateLoader { - getTranslation(lang: string): Observable { - return of(ZhHans) - } -} diff --git a/packages/angular/i18n/zhHans.ts b/packages/angular/i18n/zhHans.ts index 402298e99..6a2b3f8fd 100644 --- a/packages/angular/i18n/zhHans.ts +++ b/packages/angular/i18n/zhHans.ts @@ -24,23 +24,6 @@ export const ZhHans = { None: '无', Default: '默认' }, - Controls: { - ValueHelp: { - Title: '为{{value}}设置过滤器', - AvailableMembers: '可选成员', - DisplayBehaviour: '展现形式', - SelectedMembers: '选中成员', - ClearSelection: '清空选择', - ShowUnbookedMembers: '显示未分配成员', - ShowAllMember: '显示‘所有’成员', - ShowOnlyLeaves: '只显示叶子节点', - ExcludeSelectedMembers: '排除选中成员', - SelectionType: '选择类型', - Presentation: '展现形式', - HierarchySelectionMode: '层级选择模式', - Hierarchy: '层次结构' - } - }, AnalyticalCard: { Screenshot: '截图', DataDownload: '下载数据', @@ -63,6 +46,48 @@ export const ZhHans = { SelectAll: '选择所有', Pin: '固定' }, + Controls: { + ValueHelp: { + Title: '为{{value}}设置过滤器', + AvailableMembers: '可选成员', + DisplayBehaviour: '展现形式', + SelectedMembers: '选中成员', + ClearSelection: '清空选择', + ShowUnbookedMembers: '显示未分配成员', + ShowAllMember: '显示‘所有’成员', + ShowOnlyLeaves: '只显示叶子节点', + ExcludeSelectedMembers: '排除选中成员', + SelectionType: '选择类型', + Presentation: '展现形式', + HierarchySelectionMode: '层级选择模式', + Hierarchy: '层次结构' + } + }, + Copilot: { + Copilot: '副驾驶', + AICopilot: 'AI 副驾驶', + EnableCopilot: '启用 Copilot', + GetYourApiKey: '获取你的 API Key', + ProvideOpenaiApiKey: '请提供 Openai API Key?', + Provider: '提供商', + YourAIPairProgrammer: '你的 AI 配对程序员!', + LetYourAIPairProgrammerEdits: '让你的 AI 配对程序员修改一下!', + SelectSomeCode: '选择代码,让你的 AI 配对程序员修改一下!', + ThinkingHard: '努力思考中...', + StopGenerating: '停止生成', + ClearMessages: '清空消息', + CharacterLength: '字数', + Resubmit: '重新提交', + Model: '模型', + Options: '选项', + UseSystemPrompt: '使用系统提示', + ExamplesOfPrompts: '提示语示例', + APIKey: 'API 密钥', + APIHost: 'API 主机', + AskAICopilot: '向 AI 副驾驶询问问题', + Prompt: '提示', + SendPrompt: '发送提问' + }, Entity: { SelectEntities: '选择数据集' }, diff --git a/packages/angular/mock/index.ts b/packages/angular/mock/index.ts new file mode 100644 index 000000000..5ac08cca1 --- /dev/null +++ b/packages/angular/mock/index.ts @@ -0,0 +1,3 @@ +export * from './agent-mock.service' +export * from './logger' +export * from './translate' \ No newline at end of file diff --git a/packages/angular/mock/logger.ts b/packages/angular/mock/logger.ts new file mode 100644 index 000000000..46e80dcde --- /dev/null +++ b/packages/angular/mock/logger.ts @@ -0,0 +1,12 @@ +import { EnvironmentProviders, importProvidersFrom } from '@angular/core' +import { LoggerModule, NgxLoggerLevel } from 'ngx-logger' + +export function provideLogger(): EnvironmentProviders { + return importProvidersFrom( + LoggerModule.forRoot({ + // serverLoggingUrl: '/api/logs', + level: NgxLoggerLevel.DEBUG, + serverLogLevel: NgxLoggerLevel.ERROR + }) + ) +} diff --git a/packages/angular/mock/translate.ts b/packages/angular/mock/translate.ts new file mode 100644 index 000000000..fbb8da49d --- /dev/null +++ b/packages/angular/mock/translate.ts @@ -0,0 +1,36 @@ +import { HttpClient } from '@angular/common/http' +import { EnvironmentProviders, importProvidersFrom } from '@angular/core' +import { MissingTranslationHandler, TranslateLoader, TranslateModule } from '@ngx-translate/core' +import { Observable, of } from 'rxjs' +import { NgmMissingTranslationHandler } from '../core' +import { ZhHans } from '../i18n' + +export const zhHansLanguage = 'zh-Hans' + +export class CustomTranslateLoader implements TranslateLoader { + getTranslation(lang: string): Observable { + console.log(lang) + if (lang === zhHansLanguage) { + return of(ZhHans) + } else { + return of(null) + } + } +} + +export function provideTranslate(defaultLanguage?: string): EnvironmentProviders { + return importProvidersFrom( + TranslateModule.forRoot({ + missingTranslationHandler: { + provide: MissingTranslationHandler, + useClass: NgmMissingTranslationHandler + }, + loader: { + provide: TranslateLoader, + useClass: CustomTranslateLoader, + deps: [HttpClient] + }, + defaultLanguage: defaultLanguage + }) + ) +} diff --git a/packages/copilot/package.json b/packages/copilot/package.json index 2ba8fe92a..00c19f88a 100644 --- a/packages/copilot/package.json +++ b/packages/copilot/package.json @@ -1,6 +1,6 @@ { "name": "@metad/copilot", - "version": "0.0.1", + "version": "1.17.0", "dependencies": { "openai": "^3.2.1" } diff --git a/packages/copilot/src/lib/copilot.ts b/packages/copilot/src/lib/copilot.ts index 06367aa82..d981d8fa1 100644 --- a/packages/copilot/src/lib/copilot.ts +++ b/packages/copilot/src/lib/copilot.ts @@ -27,7 +27,7 @@ export class CopilotService { } get chatCompletionsUrl() { return ( - (this.copilot.apiHost || AI_PROVIDERS[this.copilot.provider].apiHost) + + (this.copilot.apiHost || AI_PROVIDERS[this.copilot.provider]?.apiHost) + AI_PROVIDERS[this.copilot.provider].chatCompletionsUrl ) } @@ -181,6 +181,9 @@ export class CopilotService { } } +/** + * Copilot engine + */ export interface CopilotEngine { /** * Copilot engine name diff --git a/packages/copilot/src/lib/types.ts b/packages/copilot/src/lib/types.ts index db6d2e415..e6ef1973f 100644 --- a/packages/copilot/src/lib/types.ts +++ b/packages/copilot/src/lib/types.ts @@ -8,7 +8,7 @@ import { CopilotService } from './copilot' export interface ICopilot { enabled?: boolean - provider: string + provider?: string apiKey?: string apiHost?: string }