diff --git a/apps/demos/Demos/Chat/AIAndChatbotIntegration/Angular/app/app.component.css b/apps/demos/Demos/Chat/AIAndChatbotIntegration/Angular/app/app.component.css new file mode 100644 index 00000000000..eab5cdc6e20 --- /dev/null +++ b/apps/demos/Demos/Chat/AIAndChatbotIntegration/Angular/app/app.component.css @@ -0,0 +1,65 @@ +.demo-container { + display: flex; + justify-content: center; +} + +::ng-deep .dx-chat { + max-width: 900px; +} + +::ng-deep .dx-chat-messagelist-empty-image { + display: none; +} + +::ng-deep .dx-chat-messagelist-empty-message { + font-size: var(--dx-font-size-heading-5); +} + +::ng-deep .dx-chat-messagebubble-content, +::ng-deep .dx-chat-messagebubble-text { + display: flex; + flex-direction: column; +} + +::ng-deep .dx-bubble-button-container { + display: none; +} + +::ng-deep .dx-button { + display: inline-block; + color: var(--dx-color-icon); +} + +::ng-deep .dx-chat-messagegroup-alignment-start:last-child .dx-chat-messagebubble:last-child .dx-bubble-button-container { + display: flex; + gap: 4px; + margin-top: 8px; +} + +::ng-deep .dx-template-wrapper > div > p:first-child { + margin-top: 0; +} + +::ng-deep .dx-template-wrapper > div > p:last-child { + margin-bottom: 0; +} + +::ng-deep .dx-chat-messagebubble-content ol, +::ng-deep .dx-chat-messagebubble-content ul { + white-space: normal; +} + +::ng-deep .dx-chat-messagebubble-content h1, +::ng-deep .dx-chat-messagebubble-content h2, +::ng-deep .dx-chat-messagebubble-content h3, +::ng-deep .dx-chat-messagebubble-content h4, +::ng-deep .dx-chat-messagebubble-content h5, +::ng-deep .dx-chat-messagebubble-content h6 { + font-size: revert; + font-weight: revert; +} + +::ng-deep .dx-chat-disabled .dx-chat-messagebox { + opacity: 0.5; + pointer-events: none; +} diff --git a/apps/demos/Demos/Chat/AIAndChatbotIntegration/Angular/app/app.component.html b/apps/demos/Demos/Chat/AIAndChatbotIntegration/Angular/app/app.component.html new file mode 100644 index 00000000000..6895eefd74a --- /dev/null +++ b/apps/demos/Demos/Chat/AIAndChatbotIntegration/Angular/app/app.component.html @@ -0,0 +1,44 @@ +
+ +
+ + {{ regenerationText }} + + +
+
+
+ + + + +
+
+
+
+
diff --git a/apps/demos/Demos/Chat/AIAndChatbotIntegration/Angular/app/app.component.ts b/apps/demos/Demos/Chat/AIAndChatbotIntegration/Angular/app/app.component.ts new file mode 100644 index 00000000000..dcb678531d0 --- /dev/null +++ b/apps/demos/Demos/Chat/AIAndChatbotIntegration/Angular/app/app.component.ts @@ -0,0 +1,120 @@ +import { NgModule, Component, enableProdMode } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { DxChatModule } from 'devextreme-angular'; +import { DxButtonModule } from 'devextreme-angular'; +import { + User, + Alert, + Message, + MessageEnteredEvent +} from 'devextreme/ui/chat'; +import { Observable } from 'rxjs'; +import { AppService } from './app.service'; +import { loadMessages } from 'devextreme/localization'; +import { DataSource } from 'devextreme/common/data'; + +if (!/localhost/.test(document.location.host)) { + enableProdMode(); +} + +let modulePrefix = ''; +// @ts-ignore +if (window && window.config.packageConfigPaths) { + modulePrefix = '/app'; +} + +@Component({ + selector: 'demo-app', + templateUrl: `.${modulePrefix}/app.component.html`, + styleUrls: [`.${modulePrefix}/app.component.css`], +}) +export class AppComponent { + dataSource: DataSource; + + user: User; + + typingUsers$: Observable; + + alerts$: Observable; + + regenerationText: string; + + copyButtonIcon: string; + + isDisabled: boolean; + + constructor(private readonly appService: AppService) { + loadMessages(this.appService.getDictionary()); + + this.dataSource = this.appService.dataSource; + this.user = this.appService.user; + this.alerts$ = this.appService.alerts$; + this.typingUsers$ = this.appService.typingUsers$; + this.regenerationText = this.appService.REGENERATION_TEXT; + this.copyButtonIcon = 'copy'; + this.isDisabled = false; + } + + convertToHtml(message: Message): string { + return this.appService.convertToHtml(message.text); + } + + toggleDisabledState(disabled: boolean, event = undefined) { + this.isDisabled = disabled; + + if (disabled) { + event?.target.blur(); + } else { + event?.target.focus(); + } + }; + + async onMessageEntered(e: MessageEnteredEvent) { + if (!this.appService.alerts.length) { + this.toggleDisabledState(true, e.event); + } + + try { + await this.appService.onMessageEntered(e); + } finally { + this.toggleDisabledState(false, e.event); + } + } + + onCopyButtonClick(message: Message) { + navigator.clipboard?.writeText(message.text); + + this.copyButtonIcon = 'check'; + + setTimeout(() => { + this.copyButtonIcon = 'copy'; + }, 2500); + } + + async onRegenerateButtonClick() { + this.appService.updateLastMessage(); + this.toggleDisabledState(true); + + try { + await this.appService.regenerate(); + } finally { + this.toggleDisabledState(false); + } + } +} + +@NgModule({ + imports: [ + BrowserModule, + DxChatModule, + DxButtonModule, + ], + declarations: [AppComponent], + bootstrap: [AppComponent], + providers: [AppService], +}) +export class AppModule { } + +platformBrowserDynamic().bootstrapModule(AppModule); diff --git a/apps/demos/Demos/Chat/AIAndChatbotIntegration/Angular/app/app.service.ts b/apps/demos/Demos/Chat/AIAndChatbotIntegration/Angular/app/app.service.ts new file mode 100644 index 00000000000..463e986e9ed --- /dev/null +++ b/apps/demos/Demos/Chat/AIAndChatbotIntegration/Angular/app/app.service.ts @@ -0,0 +1,204 @@ +import { Injectable } from '@angular/core'; +import { Observable, BehaviorSubject } from 'rxjs'; +import { AzureOpenAI } from 'openai'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkRehype from 'remark-rehype'; +import rehypeStringify from 'rehype-stringify'; +import { + User, + Alert, + MessageEnteredEvent +} from 'devextreme/ui/chat'; +import { DataSource, CustomStore } from 'devextreme/common/data'; + +@Injectable({ + providedIn: 'root', +}) + +export class AppService { + chatService: AzureOpenAI; + + AzureOpenAIConfig = { + dangerouslyAllowBrowser: true, + deployment: 'gpt-4o-mini', + apiVersion: '2024-02-01', + endpoint: 'https://public-api.devexpress.com/demo-openai', + apiKey: 'DEMO', + } + + REGENERATION_TEXT = 'Regeneration...'; + ALERT_TIMEOUT = 1000 * 60; + + user: User = { + id: 'user', + }; + + assistant: User = { + id: 'assistant', + name: 'Virtual Assistant', + }; + + store: any[] = []; + messages: any[] = []; + alerts: Alert[] = []; + + customStore: CustomStore; + + dataSource: DataSource; + + typingUsersSubject: BehaviorSubject = new BehaviorSubject([]); + + alertsSubject: BehaviorSubject = new BehaviorSubject([]); + + constructor() { + this.chatService = new AzureOpenAI(this.AzureOpenAIConfig); + this.initDataSource() + this.typingUsersSubject.next([]); + this.alertsSubject.next([]); + } + + get typingUsers$(): Observable { + return this.typingUsersSubject.asObservable(); + } + + get alerts$(): Observable { + return this.alertsSubject.asObservable(); + } + + getDictionary() { + return { + en: { + 'dxChat-emptyListMessage': 'Chat is Empty', + 'dxChat-emptyListPrompt': 'AI Assistant is ready to answer your questions.', + 'dxChat-textareaPlaceholder': 'Ask AI Assistant...', + } + } + } + + initDataSource() { + this.customStore = new CustomStore({ + key: 'id', + load: () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve([...this.store]); + }, 0); + }); + }, + insert: (message) => { + return new Promise((resolve) => { + setTimeout(() => { + this.store.push(message); + resolve(message); + }); + }); + }, + }); + + this.dataSource = new DataSource({ + store: this.customStore, + paginate: false, + }); + } + + async getAIResponse(messages) { + const params = { + messages, + max_tokens: 1000, + temperature: 0.7, + }; + + const response = await this.chatService.chat.completions.create(params); + const data = { choices: response.choices }; + + return data.choices[0].message?.content; + } + + async processMessageSending(message, event) { + this.messages.push({ role: 'user', content: message.text }); + this.typingUsersSubject.next([this.assistant]); + + try { + const aiResponse = await this.getAIResponse(this.messages); + + setTimeout(() => { + this.typingUsersSubject.next([]); + this.messages.push({ role: 'assistant', content: aiResponse }); + this.renderAssistantMessage(aiResponse); + }, 200); + } catch { + this.typingUsersSubject.next([]); + this.messages.pop(); + this.alertLimitReached(); + } + } + + updateLastMessage(text = this.REGENERATION_TEXT) { + const items = this.dataSource.items(); + const lastMessage = items.at(-1); + + this.dataSource.store().push([{ + type: 'update', + key: lastMessage.id, + data: { text }, + }]); + } + + renderAssistantMessage(text: string) { + const message = { + id: Date.now(), + timestamp: new Date(), + author: this.assistant, + text, + }; + + this.dataSource.store().push([{ type: 'insert', data: message }]); + } + + alertLimitReached() { + this.setAlerts([{ + message: 'Request limit reached, try again in a minute.' + }]); + + setTimeout(() => { + this.setAlerts([]); + }, this.ALERT_TIMEOUT); + } + + setAlerts(alerts: Alert[]) { + this.alerts = alerts; + this.alertsSubject.next(alerts); + } + + async regenerate() { + try { + const aiResponse = await this.getAIResponse(this.messages.slice(0, -1)); + + this.updateLastMessage(aiResponse); + this.messages.at(-1).content = aiResponse; + } catch { + this.updateLastMessage(this.messages.at(-1).content); + this.alertLimitReached(); + } + } + + convertToHtml(value: string) { + const result = unified() + .use(remarkParse) + .use(remarkRehype) + .use(rehypeStringify) + .processSync(value) + .toString(); + + return result; + } + + async onMessageEntered({ message, event }: MessageEnteredEvent) { + this.dataSource.store().push([{ type: 'insert', data: { id: Date.now(), ...message } }]); + + if (!this.alerts.length) { + await this.processMessageSending(message, event); + } + } +} diff --git a/apps/demos/Demos/Chat/AIAndChatbotIntegration/Angular/index.html b/apps/demos/Demos/Chat/AIAndChatbotIntegration/Angular/index.html new file mode 100644 index 00000000000..1ab1fb54a1d --- /dev/null +++ b/apps/demos/Demos/Chat/AIAndChatbotIntegration/Angular/index.html @@ -0,0 +1,26 @@ + + + + DevExtreme Demo + + + + + + + + + + + + + + + +
+ Loading... +
+ +