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...
+
+
+