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
import React, { useState } from 'react';
import Chat, { ChatTypes } from 'devextreme-react/chat';
import { AzureOpenAI } from 'openai';
import { MessageEnteredEvent } from 'devextreme/ui/chat';
import CustomStore from 'devextreme/data/custom_store';
import DataSource from 'devextreme/data/data_source';
import { loadMessages } from 'devextreme/localization';
import {
user,
assistant,
AzureOpenAIConfig,
REGENERATION_TEXT,
CHAT_DISABLED_CLASS,
ALERT_TIMEOUT
} from './data.ts';
import Message from './Message.tsx';
const store = [];
const messages = [];
loadMessages({
en: {
'dxChat-emptyListMessage': 'Chat is Empty',
'dxChat-emptyListPrompt': 'AI Assistant is ready to answer your questions.',
'dxChat-textareaPlaceholder': 'Ask AI Assistant...',
},
});
const chatService = new AzureOpenAI(AzureOpenAIConfig);
async function getAIResponse(messages) {
const params = {
messages,
model: AzureOpenAIConfig.deployment,
max_tokens: 1000,
temperature: 0.7,
};
const response = await chatService.chat.completions.create(params);
const data = { choices: response.choices };
return data.choices[0].message?.content;
}
function updateLastMessage(text = REGENERATION_TEXT) {
const items = dataSource.items();
const lastMessage = items.at(-1);
dataSource.store().push([{
type: 'update',
key: lastMessage.id,
data: { text },
}]);
}
function renderAssistantMessage(text) {
const message = {
id: Date.now(),
timestamp: new Date(),
author: assistant,
text,
};
dataSource.store().push([{ type: 'insert', data: message }]);
}
const customStore = new CustomStore({
key: 'id',
load: () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve([...store]);
}, 0);
});
},
insert: (message) => {
return new Promise((resolve) => {
setTimeout(() => {
store.push(message);
resolve(message);
});
});
},
});
const dataSource = new DataSource({
store: customStore,
paginate: false,
})
export default function App() {
const [alerts, setAlerts] = useState<ChatTypes.Alert[]>([]);
const [typingUsers, setTypingUsers] = useState<ChatTypes.User[]>([]);
const [classList, setClassList] = useState<string>('');
function alertLimitReached() {
setAlerts([{
message: 'Request limit reached, try again in a minute.'
}]);
setTimeout(() => {
setAlerts([]);
}, ALERT_TIMEOUT);
}
function toggleDisabledState(disabled: boolean, event = undefined) {
setClassList(disabled ? CHAT_DISABLED_CLASS : '');
if (disabled) {
event?.target.blur();
} else {
event?.target.focus();
}
};
async function processMessageSending(message, event) {
toggleDisabledState(true, event);
messages.push({ role: 'user', content: message.text });
setTypingUsers([assistant]);
try {
const aiResponse = await getAIResponse(messages);
setTimeout(() => {
setTypingUsers([]);
messages.push({ role: 'assistant', content: aiResponse });
renderAssistantMessage(aiResponse);
}, 200);
} catch {
setTypingUsers([]);
messages.pop();
alertLimitReached();
} finally {
toggleDisabledState(false, event);
}
}
async function regenerate() {
toggleDisabledState(true);
try {
const aiResponse = await getAIResponse(messages.slice(0, -1));
updateLastMessage(aiResponse);
messages.at(-1).content = aiResponse;
} catch {
updateLastMessage(messages.at(-1).content);
alertLimitReached();
} finally {
toggleDisabledState(false);
}
}
function onMessageEntered({ message, event }: MessageEnteredEvent) {
dataSource.store().push([{ type: 'insert', data: { id: Date.now(), ...message } }]);
if (!alerts.length) {
processMessageSending(message, event);
}
}
function onRegenerateButtonClick() {
updateLastMessage();
regenerate();
}
return (
<Chat
className={classList}
dataSource={dataSource}
reloadOnChange={false}
showAvatar={false}
showDayHeaders={false}
user={user}
height={710}
onMessageEntered={onMessageEntered}
alerts={alerts}
typingUsers={typingUsers}
messageRender={(data) => Message(data, onRegenerateButtonClick)}
/>
);
}
import React, { useState } from 'react';
import Chat from 'devextreme-react/chat';
import { AzureOpenAI } from 'openai';
import CustomStore from 'devextreme/data/custom_store';
import DataSource from 'devextreme/data/data_source';
import { loadMessages } from 'devextreme/localization';
import {
user,
assistant,
AzureOpenAIConfig,
REGENERATION_TEXT,
CHAT_DISABLED_CLASS,
ALERT_TIMEOUT,
} from './data.js';
import Message from './Message.js';
const store = [];
const messages = [];
loadMessages({
en: {
'dxChat-emptyListMessage': 'Chat is Empty',
'dxChat-emptyListPrompt': 'AI Assistant is ready to answer your questions.',
'dxChat-textareaPlaceholder': 'Ask AI Assistant...',
},
});
const chatService = new AzureOpenAI(AzureOpenAIConfig);
async function getAIResponse(messages) {
const params = {
messages,
model: AzureOpenAIConfig.deployment,
max_tokens: 1000,
temperature: 0.7,
};
const response = await chatService.chat.completions.create(params);
const data = { choices: response.choices };
return data.choices[0].message?.content;
}
function updateLastMessage(text = REGENERATION_TEXT) {
const items = dataSource.items();
const lastMessage = items.at(-1);
dataSource.store().push([
{
type: 'update',
key: lastMessage.id,
data: { text },
},
]);
}
function renderAssistantMessage(text) {
const message = {
id: Date.now(),
timestamp: new Date(),
author: assistant,
text,
};
dataSource.store().push([{ type: 'insert', data: message }]);
}
const customStore = new CustomStore({
key: 'id',
load: () => new Promise((resolve) => {
setTimeout(() => {
resolve([...store]);
}, 0);
}),
insert: (message) => new Promise((resolve) => {
setTimeout(() => {
store.push(message);
resolve(message);
});
}),
});
const dataSource = new DataSource({
store: customStore,
paginate: false,
});
export default function App() {
const [alerts, setAlerts] = useState([]);
const [typingUsers, setTypingUsers] = useState([]);
const [classList, setClassList] = useState('');
function alertLimitReached() {
setAlerts([
{
message: 'Request limit reached, try again in a minute.',
},
]);
setTimeout(() => {
setAlerts([]);
}, ALERT_TIMEOUT);
}
function toggleDisabledState(disabled, event = undefined) {
setClassList(disabled ? CHAT_DISABLED_CLASS : '');
if (disabled) {
event?.target.blur();
} else {
event?.target.focus();
}
}
async function processMessageSending(message, event) {
toggleDisabledState(true, event);
messages.push({ role: 'user', content: message.text });
setTypingUsers([assistant]);
try {
const aiResponse = await getAIResponse(messages);
setTimeout(() => {
setTypingUsers([]);
messages.push({ role: 'assistant', content: aiResponse });
renderAssistantMessage(aiResponse);
}, 200);
} catch {
setTypingUsers([]);
messages.pop();
alertLimitReached();
} finally {
toggleDisabledState(false, event);
}
}
async function regenerate() {
toggleDisabledState(true);
try {
const aiResponse = await getAIResponse(messages.slice(0, -1));
updateLastMessage(aiResponse);
messages.at(-1).content = aiResponse;
} catch {
updateLastMessage(messages.at(-1).content);
alertLimitReached();
} finally {
toggleDisabledState(false);
}
}
function onMessageEntered({ message, event }) {
dataSource.store().push([{ type: 'insert', data: { id: Date.now(), ...message } }]);
if (!alerts.length) {
processMessageSending(message, event);
}
}
function onRegenerateButtonClick() {
updateLastMessage();
regenerate();
}
return (
<Chat
className={classList}
dataSource={dataSource}
reloadOnChange={false}
showAvatar={false}
showDayHeaders={false}
user={user}
height={710}
onMessageEntered={onMessageEntered}
alerts={alerts}
typingUsers={typingUsers}
messageRender={(data) => Message(data, onRegenerateButtonClick)}
/>
);
}
import React, { useState } from 'react';
import Button from 'devextreme-react/button';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
import HTMLReactParser from 'html-react-parser';
import { REGENERATION_TEXT } from './data.ts';
function convertToHtml(value: string) {
const result = unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeStringify)
.processSync(value)
.toString();
return result;
}
function Message({ message }, onRegenerateButtonClick) {
const [icon, setIcon] = useState('copy');
if (message.text === REGENERATION_TEXT) {
return <span>{REGENERATION_TEXT}</span>;
}
function onCopyButtonClick() {
navigator.clipboard?.writeText(message.text);
setIcon('check');
setTimeout(() => {
setIcon('copy');
}, 2500);
}
return (
<React.Fragment>
<div
className='dx-chat-messagebubble-text'
>
{HTMLReactParser(convertToHtml(message.text))}
</div>
<div className='dx-bubble-button-container'>
<Button
icon={icon}
stylingMode='text'
hint='Copy'
onClick={onCopyButtonClick}
/>
<Button
icon='refresh'
stylingMode='text'
hint='Regenerate'
onClick={onRegenerateButtonClick}
/>
</div>
</React.Fragment>
)
}
export default Message;
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.tsx';
ReactDOM.render(
<App />,
document.getElementById('app'),
);
import { ChatTypes } from 'devextreme-react/chat';
export const AzureOpenAIConfig = {
dangerouslyAllowBrowser: true,
deployment: 'gpt-4o-mini',
apiVersion: '2024-02-01',
endpoint: 'https://public-api.devexpress.com/demo-openai',
apiKey: 'DEMO',
}
export const REGENERATION_TEXT = 'Regeneration...';
export const CHAT_DISABLED_CLASS = 'dx-chat-disabled';
export const ALERT_TIMEOUT = 1000 * 60;
export const user: ChatTypes.User = {
id: 'user',
};
export const assistant: ChatTypes.User = {
id: 'assistant',
name: 'Virtual Assistant',
};
window.exports = window.exports || {};
window.config = {
transpiler: 'ts',
typescriptOptions: {
module: 'system',
emitDecoratorMetadata: true,
experimentalDecorators: true,
jsx: 'react',
},
meta: {
'react': {
'esModule': true,
},
'typescript': {
'exports': 'ts',
},
'devextreme/time_zone_utils.js': {
'esModule': true,
},
'devextreme/localization.js': {
'esModule': true,
},
'devextreme/viz/palette.js': {
'esModule': true,
},
'openai': {
'esModule': true,
},
},
paths: {
'npm:': 'https://unpkg.com/',
'bundles:': '../../../../bundles/',
'externals:': '../../../../bundles/externals/',
},
defaultExtension: 'js',
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',
'react': 'npm:react@17.0.2/umd/react.development.js',
'react-dom': 'npm:react-dom@17.0.2/umd/react-dom.development.js',
'prop-types': 'npm:prop-types/prop-types.js',
'html-react-parser': 'npm:html-react-parser@1.4.14/dist/html-react-parser.min.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',
'devextreme': 'npm:devextreme@link:../../packages/devextreme/artifacts/npm/devextreme/cjs',
'devextreme-react': 'npm:devextreme-react@link:../../packages/devextreme-react/npm/cjs',
'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',
'devextreme-quill': 'npm:devextreme-quill@1.7.1/dist/dx-quill.min.js',
'devexpress-diagram': 'npm:devexpress-diagram@2.2.5/dist/dx-diagram.js',
'devexpress-gantt': 'npm:devexpress-gantt@4.1.54/dist/dx-gantt.js',
'@devextreme/runtime': 'npm:@devextreme/runtime@3.0.12',
'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',
'devextreme-cldr-data': 'npm:devextreme-cldr-data@1.0.3',
// SystemJS plugins
'plugin-babel': 'npm:systemjs-plugin-babel@0.0.25/plugin-babel.js',
'systemjs-babel-build': 'npm:systemjs-plugin-babel@0.0.25/systemjs-babel-browser.js',
// Prettier
'prettier/standalone': 'npm:prettier@2.8.8/standalone.js',
'prettier/parser-html': 'npm:prettier@2.8.8/parser-html.js',
},
packages: {
'devextreme': {
defaultExtension: 'js',
},
'devextreme-react': {
main: 'index.js',
},
'devextreme/events/utils': {
main: 'index',
},
'devextreme/localization/messages': {
format: 'json',
defaultExtension: 'json',
},
'devextreme/events': {
main: 'index',
},
'es6-object-assign': {
main: './index.js',
defaultExtension: 'js',
},
},
packageConfigPaths: [
'npm:@devextreme/*/package.json',
'npm:@devextreme/runtime@3.0.12/inferno/package.json',
],
babelOptions: {
sourceMaps: false,
stage0: true,
react: true,
},
};
System.config(window.config);
import React, { useState } from 'react';
import Button from 'devextreme-react/button';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
import HTMLReactParser from 'html-react-parser';
import { REGENERATION_TEXT } from './data.js';
function convertToHtml(value) {
const result = unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeStringify)
.processSync(value)
.toString();
return result;
}
function Message({ message }, onRegenerateButtonClick) {
const [icon, setIcon] = useState('copy');
if (message.text === REGENERATION_TEXT) {
return <span>{REGENERATION_TEXT}</span>;
}
function onCopyButtonClick() {
navigator.clipboard?.writeText(message.text);
setIcon('check');
setTimeout(() => {
setIcon('copy');
}, 2500);
}
return (
<React.Fragment>
<div className="dx-chat-messagebubble-text">
{HTMLReactParser(convertToHtml(message.text))}
</div>
<div className="dx-bubble-button-container">
<Button
icon={icon}
stylingMode="text"
hint="Copy"
onClick={onCopyButtonClick}
/>
<Button
icon="refresh"
stylingMode="text"
hint="Regenerate"
onClick={onRegenerateButtonClick}
/>
</div>
</React.Fragment>
);
}
export default Message;
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.js';
ReactDOM.render(<App />, document.getElementById('app'));
export const AzureOpenAIConfig = {
dangerouslyAllowBrowser: true,
deployment: 'gpt-4o-mini',
apiVersion: '2024-02-01',
endpoint: 'https://public-api.devexpress.com/demo-openai',
apiKey: 'DEMO',
};
export const REGENERATION_TEXT = 'Regeneration...';
export const CHAT_DISABLED_CLASS = 'dx-chat-disabled';
export const ALERT_TIMEOUT = 1000 * 60;
export const user = {
id: 'user',
};
export const assistant = {
id: 'assistant',
name: 'Virtual Assistant',
};
<!DOCTYPE html>
<html 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.3/css/dx.light.css" />
<link rel="stylesheet" type="text/css" href="styles.css" />
<script src="https://unpkg.com/core-js@2.6.12/client/shim.min.js"></script>
<script src="https://unpkg.com/systemjs@0.21.3/dist/system.js"></script>
<script type="text/javascript" src="config.js"></script>
<script type="text/javascript">
System.import("./index.tsx");
</script>
</head>
<body class="dx-viewport">
<div class="demo-container">
<div id="app"></div>
</div>
</body>
</html>
#app {
display: flex;
justify-content: center;
}
.dx-chat {
max-width: 900px;
}
.dx-chat-messagelist-empty-image {
display: none;
}
.dx-chat-messagelist-empty-message {
font-size: var(--dx-font-size-heading-5);
}
.dx-chat-messagebubble-content,
.dx-chat-messagebubble-text {
display: flex;
flex-direction: column;
}
.dx-bubble-button-container {
display: none;
}
.dx-button {
display: inline-block;
color: var(--dx-color-icon);
}
.dx-chat-messagegroup-alignment-start:last-child .dx-chat-messagebubble:last-child .dx-bubble-button-container {
display: flex;
gap: 4px;
margin-top: 8px;
}
.dx-chat-messagebubble-content > div > p:first-child {
margin-top: 0;
}
.dx-chat-messagebubble-content > div > p:last-child {
margin-bottom: 0;
}
.dx-chat-messagebubble-content ol,
.dx-chat-messagebubble-content ul {
white-space: normal;
}
.dx-chat-messagebubble-content h1,
.dx-chat-messagebubble-content h2,
.dx-chat-messagebubble-content h3,
.dx-chat-messagebubble-content h4,
.dx-chat-messagebubble-content h5,
.dx-chat-messagebubble-content h6 {
font-size: revert;
font-weight: revert;
}
.dx-chat-disabled .dx-chat-messagebox {
opacity: 0.5;
pointer-events: none;
}
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.