Feel free to share demo-related thoughts here.
If you have technical questions, please create a support ticket in the DevExpress Support Center.
Thank you for the feedback!
If you have technical questions, please create a support ticket in the DevExpress Support Center.
Backend API
<div class="demo-container">
<dx-chat
[class.dx-chat-disabled]="isDisabled"
[dataSource]="dataSource"
[reloadOnChange]="false"
[showAvatar]="false"
[showDayHeaders]="false"
[user]="user"
height="710"
[typingUsers]="typingUsers$ | async"
[alerts]="alerts$ | async"
(onMessageEntered)="onMessageEntered($event)"
messageTemplate="messageTemplate"
>
<div *dxTemplate="let data of 'messageTemplate'">
<ng-container *ngIf="data.message.text === regenerationText">
<span>{{ regenerationText }}</span>
</ng-container>
<ng-container *ngIf="data.message.text !== regenerationText">
<div
class="dx-chat-messagebubble-text"
[innerHTML]="convertToHtml(data.message)"
>
</div>
<div class="dx-bubble-button-container">
<dx-button
[icon]="copyButtonIcon"
stylingMode="text"
hint="Copy"
(onClick)="onCopyButtonClick(data.message)"
>
</dx-button>
<dx-button
icon="refresh"
stylingMode="text"
hint="Regenerate"
(onClick)="onRegenerateButtonClick()"
>
</dx-button>
</div>
</ng-container>
</div>
</dx-chat>
</div>
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/data/data_source';
if (!/localhost/.test(document.location.host)) {
enableProdMode();
}
let modulePrefix = '';
// @ts-ignore
if (window && window.config?.packageConfigPaths) {
modulePrefix = '/app';
}
@Component({
selector: 'demo-app',
templateUrl: `app/app.component.html`,
styleUrls: [`app/app.component.css`],
})
export class AppComponent {
dataSource: DataSource;
user: User;
typingUsers$: Observable<User[]>;
alerts$: Observable<Alert[]>;
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);
.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;
}
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 from 'devextreme/data/data_source';
import CustomStore from 'devextreme/data/custom_store';
@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<User[]> = new BehaviorSubject([]);
alertsSubject: BehaviorSubject<Alert[]> = new BehaviorSubject([]);
constructor() {
this.chatService = new AzureOpenAI(this.AzureOpenAIConfig);
this.initDataSource()
this.typingUsersSubject.next([]);
this.alertsSubject.next([]);
}
get typingUsers$(): Observable<User[]> {
return this.typingUsersSubject.asObservable();
}
get alerts$(): Observable<Alert[]> {
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,
model: this.AzureOpenAIConfig.deployment,
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);
}
}
}
// In real applications, you should not transpile code in the browser.
// You can see how to create your own application with Angular and DevExtreme here:
// https://js.devexpress.com/Documentation/Guide/Angular_Components/Getting_Started/Create_a_DevExtreme_Application/
const componentNames = [
'accordion',
'action-sheet',
'autocomplete',
'bar-gauge',
'box',
'bullet',
'button-group',
'button',
'calendar',
'chart',
'chat',
'check-box',
'circular-gauge',
'color-box',
'context-menu',
'data-grid',
'date-box',
'date-range-box',
'defer-rendering',
'diagram',
'draggable',
'drawer',
'drop-down-box',
'drop-down-button',
'file-manager',
'file-uploader',
'filter-builder',
'form',
'funnel',
'gallery',
'gantt',
'html-editor',
'linear-gauge',
'list',
'load-indicator',
'load-panel',
'lookup',
'map',
'menu',
'multi-view',
'nested',
'number-box',
'pagination',
'pie-chart',
'pivot-grid-field-chooser',
'pivot-grid',
'polar-chart',
'popover',
'popup',
'progress-bar',
'radio-group',
'range-selector',
'range-slider',
'recurrence-editor',
'resizable',
'responsive-box',
'sankey',
'scheduler',
'scroll-view',
'select-box',
'slider',
'sortable',
'sparkline',
'speed-dial-action',
'splitter',
'switch',
'tab-panel',
'tabs',
'tag-box',
'text-area',
'text-box',
'tile-view',
'toast',
'toolbar',
'tooltip',
'tree-list',
'tree-map',
'tree-view',
'validation-group',
'validation-summary',
'validator',
'vector-map',
];
window.exports = window.exports || {};
window.config = {
transpiler: 'ts',
typescriptOptions: {
module: 'system',
emitDecoratorMetadata: true,
experimentalDecorators: true,
},
meta: {
'typescript': {
'exports': 'ts',
},
'devextreme/time_zone_utils.js': {
'esModule': true,
},
'devextreme/localization.js': {
'esModule': true,
},
'devextreme/viz/palette.js': {
'esModule': true,
},
'@angular/platform-browser-dynamic': {
'esModule': true,
},
'@angular/platform-browser': {
'esModule': true,
},
'@angular/core': {
'esModule': true,
},
'@angular/common': {
'esModule': true,
},
'@angular/common/http': {
'esModule': true,
},
'@angular/animations': {
'esModule': true,
},
'@angular/forms': {
'esModule': true,
},
'openai': {
'esModule': true,
},
},
paths: {
'npm:': 'https://cdn.jsdelivr.net/npm/',
'bundles:': '../../../../bundles/',
'externals:': '../../../../bundles/externals/',
},
map: {
'ts': 'npm:plugin-typescript@8.0.0/lib/plugin.js',
'typescript': 'npm:typescript@4.2.4/lib/typescript.js',
'jszip': 'npm:jszip@3.10.1/dist/jszip.min.js',
/* @angular */
'@angular/compiler': 'bundles:@angular/compiler.umd.js',
'@angular/platform-browser-dynamic': 'bundles:@angular/platform-browser-dynamic.umd.js',
'@angular/core': 'bundles:@angular/core.umd.js',
'@angular/core/primitives/signals': 'bundles:@angular/core.primitives.signals.umd.js',
'@angular/common': 'bundles:@angular/common.umd.js',
'@angular/common/http': 'bundles:@angular/common-http.umd.js',
'@angular/platform-browser': 'bundles:@angular/platform-browser.umd.js',
'@angular/platform-browser/animations': 'bundles:@angular/platform-browser.umd.js',
'@angular/forms': 'bundles:@angular/forms.umd.js',
/* devextreme */
'devextreme': 'npm:devextreme@24.2.7/cjs',
'@devextreme/runtime': 'npm:@devextreme/runtime@3.0.12',
'devextreme/bundles/dx.all': 'npm:devextreme@24.2.7/bundles/dx.all.js',
'devextreme-quill': 'npm:devextreme-quill@1.7.1/dist/dx-quill.min.js',
'devexpress-diagram': 'npm:devexpress-diagram@2.2.16',
'devexpress-gantt': 'npm:devexpress-gantt@4.1.60',
/* devextreme-angular umd maps */
'devextreme-angular': 'bundles:devextreme-angular/devextreme-angular.umd.js',
'devextreme-angular/core': 'bundles:devextreme-angular/devextreme-angular-core.umd.js',
'devextreme-angular/http': 'bundles:devextreme-angular/devextreme-angular-http.umd.js',
...componentNames.reduce((acc, name) => {
acc[`devextreme-angular/ui/${name}`] = `bundles:devextreme-angular/devextreme-angular-ui-${name}.umd.js`;
acc[`devextreme-angular/ui/${name}/nested`] = `bundles:devextreme-angular/devextreme-angular-ui-${name}-nested.umd.js`;
return acc;
}, {}),
'unified': 'externals:unified/unified.bundle.js',
'remark-parse': 'externals:unified/remark-parse.bundle.js',
'remark-rehype': 'externals:unified/remark-rehype.bundle.js',
'remark-stringify': 'externals:unified/remark-stringify.bundle.js',
'rehype-parse': 'externals:unified/rehype-parse.bundle.js',
'rehype-remark': 'externals:unified/rehype-remark.bundle.js',
'rehype-stringify': 'externals:unified/rehype-stringify.bundle.js',
'openai': 'externals:openai.bundle.js',
'tslib': 'npm:tslib/tslib.js',
'rxjs': 'npm:rxjs@7.5.3/dist/bundles/rxjs.umd.js',
'rxjs/operators': 'npm:rxjs@7.5.3/dist/cjs/operators/index.js',
'rrule': 'npm:rrule@2.6.4/dist/es5/rrule.js',
'luxon': 'npm:luxon@3.4.4/build/global/luxon.min.js',
'es6-object-assign': 'npm:es6-object-assign',
'inferno': 'npm:inferno@7.4.11/dist/inferno.min.js',
'inferno-compat': 'npm:inferno-compat/dist/inferno-compat.min.js',
'inferno-create-element': 'npm:inferno-create-element@7.4.11/dist/inferno-create-element.min.js',
'inferno-dom': 'npm:inferno-dom/dist/inferno-dom.min.js',
'inferno-hydrate': 'npm:inferno-hydrate/dist/inferno-hydrate.min.js',
'inferno-clone-vnode': 'npm:inferno-clone-vnode/dist/inferno-clone-vnode.min.js',
'inferno-create-class': 'npm:inferno-create-class/dist/inferno-create-class.min.js',
'inferno-extras': 'npm:inferno-extras/dist/inferno-extras.min.js',
// Prettier
'prettier/standalone': 'npm:prettier@2.8.8/standalone.js',
'prettier/parser-html': 'npm:prettier@2.8.8/parser-html.js',
},
packages: {
'app': {
main: './app.component.ts',
defaultExtension: 'ts',
},
'devextreme': {
defaultExtension: 'js',
},
'devextreme/events/utils': {
main: 'index',
},
'devextreme/common/core/events/utils': {
main: 'index',
},
'devextreme/events': {
main: 'index',
},
'es6-object-assign': {
main: './index.js',
defaultExtension: 'js',
},
'rxjs': {
defaultExtension: 'js',
},
'rxjs/operators': {
defaultExtension: 'js',
},
},
packageConfigPaths: [
'npm:@devextreme/*/package.json',
'npm:@devextreme/runtime@3.0.12/inferno/package.json',
'npm:rxjs@7.5.3/package.json',
'npm:rxjs@7.5.3/operators/package.json',
'npm:devexpress-diagram@2.2.16/package.json',
'npm:devexpress-gantt@4.1.60/package.json',
],
};
System.config(window.config);
// System.import('@angular/compiler').catch(console.error.bind(console));
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>DevExtreme Demo</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
<link rel="stylesheet" type="text/css" href="https://cdn3.devexpress.com/jslib/24.2.7/css/dx.light.css" />
<script src="https://cdn.jsdelivr.net/npm/core-js@2.6.12/client/shim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/zone.js@0.14.10/bundles/zone.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/reflect-metadata@0.1.13/Reflect.js"></script>
<script src="https://cdn.jsdelivr.net/npm/systemjs@0.21.3/dist/system.js"></script>
<script src="config.js"></script>
<script>
System.import("app").catch(console.error.bind(console));
</script>
</head>
<body class="dx-viewport">
<div class="demo-container">
<demo-app>Loading...</demo-app>
</div>
</body>
</html>
Custom Message Template
The Chat specifies a messageTemplate that displays "Copy" and "Regenerate" buttons in bot messages.
Response Format Conversion: Markdown to HTML
The AI model outputs responses in Markdown, while the Chat requires HTML output. This example uses the unified plugin library to convert response content. Review convertToHtml
function code for implementation details.
Default Caption Customization
The Chat component in this demo displays modified captions when the conversation is empty. The demo uses localization techniques to alter built-in text.