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
x
import React, { useCallback, useState } from 'react';
import Chat, { ChatTypes } from 'devextreme-react/chat';
import { MessageEnteredEvent } from 'devextreme/ui/chat';
import { loadMessages } from 'devextreme/localization';
import {
user,
assistant,
CHAT_DISABLED_CLASS,
} from './data.ts';
import Message from './Message.tsx';
import { dataSource, useApi } from './useApi.ts';
loadMessages({
en: {
'dxChat-emptyListMessage': 'Chat is Empty',
'dxChat-emptyListPrompt': 'AI Assistant is ready to answer your questions.',
'dxChat-textareaPlaceholder': 'Ask AI Assistant...',
},
});
export default function App() {
const {
alerts, insertMessage, fetchAIResponse, regenerateLastAIResponse,
} = useApi();
const [typingUsers, setTypingUsers] = useState<ChatTypes.User[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const processAIRequest = useCallback(async (message: ChatTypes.Message): Promise<void> => {
setIsProcessing(true);
setTypingUsers([assistant]);
await fetchAIResponse(message);
setTypingUsers([]);
setIsProcessing(false);
}, [fetchAIResponse]);
const onMessageEntered = useCallback(async ({ message, event }: MessageEnteredEvent): Promise<void> => {
insertMessage({ id: Date.now(), message });
if (!alerts.length) {
(event.target as HTMLElement).blur();
await processAIRequest(message);
(event.target as HTMLElement).focus();
}
}, [insertMessage, alerts.length, processAIRequest]);
const onRegenerateButtonClick = useCallback(async (): Promise<void> => {
setIsProcessing(true);
await regenerateLastAIResponse();
setIsProcessing(false);
}, [regenerateLastAIResponse]);
const messageRender = useCallback(({ message }: { message: ChatTypes.Message }) => <Message text={message.text} onRegenerateButtonClick={onRegenerateButtonClick} />, [onRegenerateButtonClick]);
return (
<Chat
className={isProcessing ? CHAT_DISABLED_CLASS : ''}
dataSource={dataSource}
reloadOnChange={false}
showAvatar={false}
showDayHeaders={false}
user={user}
height={710}
onMessageEntered={onMessageEntered}
alerts={alerts}
typingUsers={typingUsers}
messageRender={messageRender}
/>
);
}
xxxxxxxxxx
import React, { useCallback, useState } from 'react';
import Chat from 'devextreme-react/chat';
import { loadMessages } from 'devextreme/localization';
import { user, assistant, CHAT_DISABLED_CLASS } from './data.js';
import Message from './Message.js';
import { dataSource, useApi } from './useApi.js';
loadMessages({
en: {
'dxChat-emptyListMessage': 'Chat is Empty',
'dxChat-emptyListPrompt': 'AI Assistant is ready to answer your questions.',
'dxChat-textareaPlaceholder': 'Ask AI Assistant...',
},
});
export default function App() {
const {
alerts, insertMessage, fetchAIResponse, regenerateLastAIResponse,
} = useApi();
const [typingUsers, setTypingUsers] = useState([]);
const [isProcessing, setIsProcessing] = useState(false);
const processAIRequest = useCallback(
async(message) => {
setIsProcessing(true);
setTypingUsers([assistant]);
await fetchAIResponse(message);
setTypingUsers([]);
setIsProcessing(false);
},
[fetchAIResponse],
);
const onMessageEntered = useCallback(
async({ message, event }) => {
insertMessage({ id: Date.now(), message });
if (!alerts.length) {
event.target.blur();
await processAIRequest(message);
event.target.focus();
}
},
[insertMessage, alerts.length, processAIRequest],
);
const onRegenerateButtonClick = useCallback(async() => {
setIsProcessing(true);
await regenerateLastAIResponse();
setIsProcessing(false);
}, [regenerateLastAIResponse]);
const messageRender = useCallback(
({ message }) => (
<Message
text={message.text}
onRegenerateButtonClick={onRegenerateButtonClick}
/>
),
[onRegenerateButtonClick],
);
return (
<Chat
className={isProcessing ? CHAT_DISABLED_CLASS : ''}
dataSource={dataSource}
reloadOnChange={false}
showAvatar={false}
showDayHeaders={false}
user={user}
height={710}
onMessageEntered={onMessageEntered}
alerts={alerts}
typingUsers={typingUsers}
messageRender={messageRender}
/>
);
}
xxxxxxxxxx
import React, { useCallback, useState, FC } 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 { Properties as dxButtonProperties } from 'devextreme/ui/button';
import { REGENERATION_TEXT } from './data.ts';
function convertToHtml(value: string): string {
const result = unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeStringify)
.processSync(value)
.toString();
return result;
}
interface MessageProps {
text: string;
onRegenerateButtonClick: dxButtonProperties['onClick'];
}
const Message: FC<MessageProps> = ({ text, onRegenerateButtonClick }) => {
const [icon, setIcon] = useState('copy');
const onCopyButtonClick = useCallback(() => {
navigator.clipboard?.writeText(text);
setIcon('check');
setTimeout(() => {
setIcon('copy');
}, 2500);
}, [text]);
if (text === REGENERATION_TEXT) {
return <span>{REGENERATION_TEXT}</span>;
}
return (
<React.Fragment>
<div className='dx-chat-messagebubble-text'>
{HTMLReactParser(convertToHtml(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;
xxxxxxxxxx
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.tsx';
ReactDOM.render(
<App />,
document.getElementById('app'),
);
xxxxxxxxxx
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',
};
xxxxxxxxxx
import { useCallback, useState } from 'react';
import { AzureOpenAI, OpenAI } from 'openai';
import { ChatTypes } from 'devextreme-react/chat';
import CustomStore from 'devextreme/data/custom_store';
import DataSource from 'devextreme/data/data_source';
import {
ALERT_TIMEOUT, assistant,
AzureOpenAIConfig, REGENERATION_TEXT,
} from './data.ts';
type Message = (OpenAI.ChatCompletionUserMessageParam | OpenAI.ChatCompletionAssistantMessageParam) & {
content: string;
};
const chatService = new AzureOpenAI(AzureOpenAIConfig);
const wait = (delay: number): Promise<void> =>
new Promise((resolve) => {
setTimeout(resolve, delay);
});
export async function getAIResponse(messages: Message[], delay?: number): Promise<string> {
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 };
if (delay) {
await wait(delay);
}
return data.choices[0].message?.content;
}
const store: ChatTypes.Message[] = [];
const customStore = new CustomStore({
key: 'id',
load: (): Promise<ChatTypes.Message[]> => new Promise((resolve) => {
setTimeout(() => {
resolve([store]);
}, 0);
}),
insert: (message: ChatTypes.Message): Promise<ChatTypes.Message> => new Promise((resolve) => {
setTimeout(() => {
store.push(message);
resolve(message);
});
}),
});
export const dataSource = new DataSource({
store: customStore,
paginate: false,
});
const dataItemToMessage = (item: ChatTypes.Message): Message => ({
role: item.author.id as Message['role'],
content: item.text,
});
const getMessageHistory = (): Message[] => [dataSource.items()].map(dataItemToMessage);
export const useApi = () => {
const [alerts, setAlerts] = useState<ChatTypes.Alert[]>([]);
const insertMessage = useCallback((data: ChatTypes.Message): void => {
dataSource.store().push([{ type: 'insert', data }]);
}, []);
const updateLastMessageContent = useCallback((text: string): void => {
const lastMessage = dataSource.items().at(-1);
dataSource.store().push([{
type: 'update',
key: lastMessage.id,
data: { text },
}]);
}, []);
const alertLimitReached = useCallback((): void => {
setAlerts([{
message: 'Request limit reached, try again in a minute.',
}]);
setTimeout(() => {
setAlerts([]);
}, ALERT_TIMEOUT);
}, []);
const fetchAIResponse = useCallback(async (message: ChatTypes.Message): Promise<void> => {
const messages = [getMessageHistory(), dataItemToMessage(message)];
try {
const aiResponse = await getAIResponse(messages, 200);
insertMessage({
id: Date.now(),
timestamp: new Date(),
author: assistant,
text: aiResponse,
});
} catch {
alertLimitReached();
}
}, [alertLimitReached, insertMessage]);
const regenerateLastAIResponse = useCallback(async (): Promise<void> => {
const messageHistory = getMessageHistory();
updateLastMessageContent(REGENERATION_TEXT);
try {
const aiResponse = await getAIResponse(messageHistory.slice(0, -1));
updateLastMessageContent(aiResponse);
} catch {
updateLastMessageContent(messageHistory.at(-1).content);
alertLimitReached();
}
}, [alertLimitReached, updateLastMessageContent]);
return {
alerts,
insertMessage,
fetchAIResponse,
regenerateLastAIResponse,
};
};
xxxxxxxxxx
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@24.2.5/cjs',
'devextreme-react': 'npm:devextreme-react@24.2.5/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);
xxxxxxxxxx
import React, { useCallback, 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;
}
const Message = ({ text, onRegenerateButtonClick }) => {
const [icon, setIcon] = useState('copy');
const onCopyButtonClick = useCallback(() => {
navigator.clipboard?.writeText(text);
setIcon('check');
setTimeout(() => {
setIcon('copy');
}, 2500);
}, [text]);
if (text === REGENERATION_TEXT) {
return <span>{REGENERATION_TEXT}</span>;
}
return (
<React.Fragment>
<div className="dx-chat-messagebubble-text">{HTMLReactParser(convertToHtml(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;
xxxxxxxxxx
import { useCallback, useState } from 'react';
import { AzureOpenAI } from 'openai';
import CustomStore from 'devextreme/data/custom_store';
import DataSource from 'devextreme/data/data_source';
import {
ALERT_TIMEOUT, assistant, AzureOpenAIConfig, REGENERATION_TEXT,
} from './data.js';
const chatService = new AzureOpenAI(AzureOpenAIConfig);
const wait = (delay) =>
new Promise((resolve) => {
setTimeout(resolve, delay);
});
export async function getAIResponse(messages, delay) {
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 };
if (delay) {
await wait(delay);
}
return data.choices[0].message?.content;
}
const store = [];
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);
});
}),
});
export const dataSource = new DataSource({
store: customStore,
paginate: false,
});
const dataItemToMessage = (item) => ({
role: item.author.id,
content: item.text,
});
const getMessageHistory = () => [dataSource.items()].map(dataItemToMessage);
export const useApi = () => {
const [alerts, setAlerts] = useState([]);
const insertMessage = useCallback((data) => {
dataSource.store().push([{ type: 'insert', data }]);
}, []);
const updateLastMessageContent = useCallback((text) => {
const lastMessage = dataSource.items().at(-1);
dataSource.store().push([
{
type: 'update',
key: lastMessage.id,
data: { text },
},
]);
}, []);
const alertLimitReached = useCallback(() => {
setAlerts([
{
message: 'Request limit reached, try again in a minute.',
},
]);
setTimeout(() => {
setAlerts([]);
}, ALERT_TIMEOUT);
}, []);
const fetchAIResponse = useCallback(
async(message) => {
const messages = [getMessageHistory(), dataItemToMessage(message)];
try {
const aiResponse = await getAIResponse(messages, 200);
insertMessage({
id: Date.now(),
timestamp: new Date(),
author: assistant,
text: aiResponse,
});
} catch {
alertLimitReached();
}
},
[alertLimitReached, insertMessage],
);
const regenerateLastAIResponse = useCallback(async() => {
const messageHistory = getMessageHistory();
updateLastMessageContent(REGENERATION_TEXT);
try {
const aiResponse = await getAIResponse(messageHistory.slice(0, -1));
updateLastMessageContent(aiResponse);
} catch {
updateLastMessageContent(messageHistory.at(-1).content);
alertLimitReached();
}
}, [alertLimitReached, updateLastMessageContent]);
return {
alerts,
insertMessage,
fetchAIResponse,
regenerateLastAIResponse,
};
};
xxxxxxxxxx
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.js';
ReactDOM.render(<App />, document.getElementById('app'));
xxxxxxxxxx
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',
};
xxxxxxxxxx
<html lang="en">
<head></head>
<body class="dx-viewport">
<div class="demo-container">
<div id="app"></div>
</div>
</body>
</html>
xxxxxxxxxx
#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.