From 87a1f1575ebcf9ee0a1636e8b457251c7283cc77 Mon Sep 17 00:00:00 2001 From: Alexander Kozlovskiy Date: Fri, 6 Dec 2024 22:11:37 +0400 Subject: [PATCH] Chat - add AIAndChatbotIntegration React demo (#28491) --- .../AIAndChatbotIntegration/React/App.tsx | 182 ++++++++++++++++++ .../AIAndChatbotIntegration/React/Message.tsx | 63 ++++++ .../AIAndChatbotIntegration/React/data.ts | 25 +++ .../AIAndChatbotIntegration/React/index.html | 24 +++ .../AIAndChatbotIntegration/React/index.tsx | 9 + .../AIAndChatbotIntegration/React/styles.css | 65 +++++++ apps/demos/menuMeta.json | 1 + 7 files changed, 369 insertions(+) create mode 100644 apps/demos/Demos/Chat/AIAndChatbotIntegration/React/App.tsx create mode 100644 apps/demos/Demos/Chat/AIAndChatbotIntegration/React/Message.tsx create mode 100644 apps/demos/Demos/Chat/AIAndChatbotIntegration/React/data.ts create mode 100644 apps/demos/Demos/Chat/AIAndChatbotIntegration/React/index.html create mode 100644 apps/demos/Demos/Chat/AIAndChatbotIntegration/React/index.tsx create mode 100644 apps/demos/Demos/Chat/AIAndChatbotIntegration/React/styles.css diff --git a/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/App.tsx b/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/App.tsx new file mode 100644 index 000000000000..4cba5ee4f435 --- /dev/null +++ b/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/App.tsx @@ -0,0 +1,182 @@ +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, + 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([]); + 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: 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 ( + Message(data, onRegenerateButtonClick)} + /> + ); +} diff --git a/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/Message.tsx b/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/Message.tsx new file mode 100644 index 000000000000..39807803d2fd --- /dev/null +++ b/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/Message.tsx @@ -0,0 +1,63 @@ +import React, { useState, useRef } 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 {REGENERATION_TEXT}; + } + + function onCopyButtonClick() { + navigator.clipboard?.writeText(message.text); + setIcon('check'); + + setTimeout(() => { + setIcon('copy'); + }, 2500); + } + + return ( + +
+ {HTMLReactParser(convertToHtml(message.text))} +
+
+
+
+ ) +} + +export default Message; diff --git a/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/data.ts b/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/data.ts new file mode 100644 index 000000000000..63fdf20d2a96 --- /dev/null +++ b/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/data.ts @@ -0,0 +1,25 @@ +import { ChatTypes } from 'devextreme-react/chat'; + +const date = new Date(); +date.setHours(0, 0, 0, 0); + +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', +}; diff --git a/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/index.html b/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/index.html new file mode 100644 index 000000000000..ee451f8288ff --- /dev/null +++ b/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/index.html @@ -0,0 +1,24 @@ + + + + DevExtreme Demo + + + + + + + + + + + + + +
+
+
+ + diff --git a/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/index.tsx b/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/index.tsx new file mode 100644 index 000000000000..8acbec4b6179 --- /dev/null +++ b/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import App from './App.tsx'; + +ReactDOM.render( + , + document.getElementById('app'), +); diff --git a/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/styles.css b/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/styles.css new file mode 100644 index 000000000000..320a5354463e --- /dev/null +++ b/apps/demos/Demos/Chat/AIAndChatbotIntegration/React/styles.css @@ -0,0 +1,65 @@ +#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; +} diff --git a/apps/demos/menuMeta.json b/apps/demos/menuMeta.json index 656d9ffd7ad0..87e40d5793b6 100644 --- a/apps/demos/menuMeta.json +++ b/apps/demos/menuMeta.json @@ -2098,6 +2098,7 @@ "Title": "AI and Chatbot Integration", "Name": "AIAndChatbotIntegration", "Widget": "Chat", + "Modules": "html-react-parser", "DemoType": "Web" } ]