diff --git a/apps/demos/Demos/Chat/MessageStreaming/Angular/app/ai/ai.service.ts b/apps/demos/Demos/Chat/MessageStreaming/Angular/app/ai/ai.service.ts new file mode 100644 index 000000000000..fa3d9e89415e --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/Angular/app/ai/ai.service.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@angular/core'; +import { AzureOpenAI, OpenAI } from 'openai'; + +export type AIMessage = ( + OpenAI.ChatCompletionUserMessageParam + | OpenAI.ChatCompletionSystemMessageParam + | OpenAI.ChatCompletionAssistantMessageParam) & { + content: string; + }; + +export interface GetAIResponseStreamOptions { + onAborted: () => void; + onDelta: (delta: string) => void; + onError?: (error: unknown) => void; + signal: AbortSignal; +} + +const AzureOpenAIConfig = { + dangerouslyAllowBrowser: true, + deployment: 'gpt-4o-mini', + apiVersion: '2024-02-01', + endpoint: 'https://public-api.devexpress.com/demo-openai', + apiKey: 'DEMO', +}; + +@Injectable() +export class AiService { + chatService: AzureOpenAI; + + constructor() { + this.chatService = new AzureOpenAI(AzureOpenAIConfig); + } + + async getAIResponseStream( + messages: AIMessage[], + { onAborted, onDelta, onError, signal }: GetAIResponseStreamOptions, + ): Promise { + const params = { + messages, + model: AzureOpenAIConfig.deployment, + max_tokens: 1000, + temperature: 0.7, + stream: true as const, + }; + + try { + const stream = await this.chatService.chat.completions.create(params, { signal }); + + for await (const event of stream) { + const delta = event.choices?.[0]?.delta?.content; + if (delta) { + onDelta(delta); + } + } + + if (signal.aborted) { + onAborted(); + } + } catch (e) { + if ((e as Error)?.name === 'AbortError') { + onAborted(); + } + onError?.(e); + throw e; + } + } +} diff --git a/apps/demos/Demos/Chat/MessageStreaming/Angular/app/app.component.css b/apps/demos/Demos/Chat/MessageStreaming/Angular/app/app.component.css new file mode 100644 index 000000000000..03447764c24a --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/Angular/app/app.component.css @@ -0,0 +1,90 @@ +.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 .chat-messagebubble-text { + display: flex; + flex-direction: column; +} + +::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 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 .chat-suggestion-cards { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 16px; + margin-top: 32px; + width: 100%; +} + +::ng-deep .chat-suggestion-card { + border-radius: 12px; + padding: 16px; + border: 1px solid #EBEBEB; + background: #FAFAFA; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + flex: 0 1 230px; + max-width: 230px; + text-align: left; + cursor: pointer; + transition: 0.2s ease; + width: 230px; +} + +::ng-deep .chat-suggestion-card:hover { + border: 1px solid #E0E0E0; + background: #F5F5F5; + box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.04), 0 4px 24px 0 rgba(0, 0, 0, 0.02); +} + +::ng-deep .chat-suggestion-card-title { + color: #242424; + font-size: 12px; + font-weight: 600; + line-height: 16px; +} + +::ng-deep .chat-suggestion-card-prompt { + color: #616161; + font-size: 12px; + font-weight: 400; + line-height: 16px; +} + +::ng-deep .dx-chat-messagelist-empty-prompt { + margin-top: 4px; +} diff --git a/apps/demos/Demos/Chat/MessageStreaming/Angular/app/app.component.html b/apps/demos/Demos/Chat/MessageStreaming/Angular/app/app.component.html new file mode 100644 index 000000000000..c98c18e487f2 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/Angular/app/app.component.html @@ -0,0 +1,45 @@ +
+ +
+
+
+ +
+
{{ + data.texts.message + }}
+
{{ + data.texts.prompt + }}
+
+ @for (card of suggestionCards; track card.title) { + + } +
+
+
+
diff --git a/apps/demos/Demos/Chat/MessageStreaming/Angular/app/app.component.ts b/apps/demos/Demos/Chat/MessageStreaming/Angular/app/app.component.ts new file mode 100644 index 000000000000..fec17c9febf7 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/Angular/app/app.component.ts @@ -0,0 +1,84 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { Component, enableProdMode, provideZoneChangeDetection } from '@angular/core'; +import { AsyncPipe } from '@angular/common'; +import { DxChatModule } from 'devextreme-angular'; +import type { DxChatTypes } from 'devextreme-angular/ui/chat'; +import { Observable, map } from 'rxjs'; +import { loadMessages } from 'devextreme-angular/common/core/localization'; +import { DataSource } from 'devextreme-angular/common/data'; +import { AppService, suggestionCards } from './app.service'; +import { AiService } from './ai/ai.service'; + +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`], + imports: [ + DxChatModule, + AsyncPipe, + ], +}) +export class AppComponent { + dataSource: DataSource; + + user: DxChatTypes.User; + + typingUsers$: Observable; + + alerts$: Observable; + + sendButtonOptions$: Observable; + + readonly suggestionCards = suggestionCards; + + 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.sendButtonOptions$ = this.appService.isStreaming$.pipe( + map((isStreaming) => (isStreaming ? { + action: 'custom' as const, + icon: 'stopfilled', + onClick: () => this.appService.stopStreaming(), + } : { + action: 'send' as const, + icon: 'arrowright', + onClick: () => {}, + })), + ); + } + + convertToHtml(message: DxChatTypes.Message): string { + return this.appService.convertToHtml(message.text); + } + + onMessageEntered(e: DxChatTypes.MessageEnteredEvent): void { + this.appService.onMessageEntered(e); + } + + onSuggestionClick(prompt: string): void { + this.appService.sendSuggestion(prompt); + } +} + +bootstrapApplication(AppComponent, { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true, runCoalescing: true }), + AppService, + AiService, + ], +}); diff --git a/apps/demos/Demos/Chat/MessageStreaming/Angular/app/app.service.ts b/apps/demos/Demos/Chat/MessageStreaming/Angular/app/app.service.ts new file mode 100644 index 000000000000..07b186a910a2 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/Angular/app/app.service.ts @@ -0,0 +1,269 @@ +import { Injectable } from '@angular/core'; +import { Observable, BehaviorSubject } from 'rxjs'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkRehype from 'remark-rehype'; +import rehypeStringify from 'rehype-stringify'; +import rehypeMinifyWhitespace from 'rehype-minify-whitespace'; +import { type DxChatTypes } from 'devextreme-angular/ui/chat'; +import { DataSource, CustomStore } from 'devextreme-angular/common/data'; +import { AiService, type AIMessage } from './ai/ai.service'; + +export const suggestionCards = [ + { + title: '💡 What is DevExtreme?', + description: 'What is DevExtreme and how can it help me build modern web apps?', + prompt: 'What is DevExtreme, and which components and frameworks does it support?', + }, + { + title: '🚀 Get Started with DevExtreme', + description: 'How do I get started with DevExtreme in my project?', + prompt: 'How can I get started with DevExtreme? Include instructions for library installation, linking required CSS/assets, applying an application theme, and coding a simple working app.', + }, + { + title: '📄 DevExtreme Licensing', + description: 'What are the licensing options for DevExtreme?', + prompt: 'Which DevExtreme license do I need for a commercial project? What licensing options are available?', + }, +]; + +interface DelayedRendererOptions { + delay?: number; + onRender: (chunk: string) => void; +} + +function createDelayedRenderer({ delay = 20, onRender }: DelayedRendererOptions) { + let queue: string[] = []; + let rendering = false; + + function processQueue() { + if (!queue.length) { + rendering = false; + return; + } + + rendering = true; + const chunk = queue.shift(); + if (chunk !== undefined) { + onRender(chunk); + } + + setTimeout(processQueue, delay); + } + + function pushChunk(chunk: string) { + queue.push(chunk); + + if (!rendering) { + processQueue(); + } + } + + function stop() { + queue = []; + rendering = false; + } + + return { pushChunk, stop }; +} + +@Injectable() +export class AppService { + readonly ALERT_TIMEOUT = 1000 * 60; + + readonly user: DxChatTypes.User = { id: 'user' }; + + readonly assistant: DxChatTypes.User = { id: 'assistant', name: 'AI Assistant' }; + + private store: DxChatTypes.Message[] = []; + + private messages: AIMessage[] = []; + + private abortController: AbortController | null = null; + + private typingUsersSubject = new BehaviorSubject([]); + + private alertsSubject = new BehaviorSubject([]); + + private isStreamingSubject = new BehaviorSubject(false); + + readonly dataSource: DataSource; + + get alerts(): DxChatTypes.Alert[] { + return this.alertsSubject.getValue(); + } + + get typingUsers$(): Observable { + return this.typingUsersSubject.asObservable(); + } + + get alerts$(): Observable { + return this.alertsSubject.asObservable(); + } + + get isStreaming$(): Observable { + return this.isStreamingSubject.asObservable(); + } + + constructor(private readonly aiService: AiService) { + const customStore = new CustomStore({ + key: 'id', + load: () => new Promise((resolve) => { + setTimeout(() => { + resolve([...this.store]); + }, 0); + }), + insert: (message) => new Promise((resolve) => { + setTimeout(() => { + this.store.push(message); + resolve(message); + }); + }), + }); + + this.dataSource = new DataSource({ + store: customStore, + paginate: false, + }); + } + + getDictionary() { + return { + en: { + 'dxChat-emptyListMessage': 'Chat is Empty', + 'dxChat-emptyListPrompt': 'AI Assistant is ready to answer your questions.', + 'dxChat-textareaPlaceholder': 'Ask AI Assistant...', + }, + }; + } + + private insertMessage(data: DxChatTypes.Message): void { + this.dataSource.store().push([{ type: 'insert', data }]); + } + + private updateMessageText(id: number, text: string): void { + this.dataSource.store().push([{ + type: 'update', + key: id, + data: { text }, + }]); + } + + private insertAssistantPlaceholder(): number { + const id = Date.now(); + this.dataSource.store().push([{ + type: 'insert', + data: { + id, + timestamp: new Date(), + author: this.assistant, + text: '', + }, + }]); + return id; + } + + private alertLimitReached(): void { + this.alertsSubject.next([{ message: 'Request limit reached, try again in a minute.' }]); + + setTimeout(() => { + this.alertsSubject.next([]); + }, this.ALERT_TIMEOUT); + } + + stopStreaming(): void { + if (this.abortController) { + this.abortController.abort(); + } + } + + async fetchAIResponse(message: DxChatTypes.Message): Promise { + const dataItemToMessage = (item: DxChatTypes.Message): AIMessage => ({ + role: item.author?.id as AIMessage['role'], + content: item.text, + }); + + this.messages = [...this.dataSource.items().map(dataItemToMessage), dataItemToMessage(message)]; + this.abortController = new AbortController(); + + setTimeout(() => this.isStreamingSubject.next(true), 0); + this.typingUsersSubject.next([this.assistant]); + + let assistantId: number | undefined; + let buffer = ''; + let typingCleared = false; + + const delayedRenderer = createDelayedRenderer({ + onRender: (chunk: string) => { + if (!typingCleared) { + this.typingUsersSubject.next([]); + typingCleared = true; + } + + if (assistantId === undefined) { + assistantId = this.insertAssistantPlaceholder(); + } + + buffer += chunk; + this.updateMessageText(assistantId, buffer); + }, + }); + + const onAborted = () => { + delayedRenderer.stop(); + }; + + try { + await this.aiService.getAIResponseStream(this.messages, { + onAborted, + onDelta: delayedRenderer.pushChunk, + signal: this.abortController.signal, + }); + + this.typingUsersSubject.next([]); + } catch (e: unknown) { + this.typingUsersSubject.next([]); + + if ((e as Error)?.name !== 'AbortError' && assistantId !== undefined) { + this.updateMessageText(assistantId, ''); + this.alertLimitReached(); + } + } finally { + this.abortController = null; + this.isStreamingSubject.next(false); + } + } + + onMessageEntered({ message }: DxChatTypes.MessageEnteredEvent): void { + this.insertMessage({ id: Date.now(), ...message }); + + if (!this.alerts.length) { + this.fetchAIResponse(message); + } + } + + sendSuggestion(prompt: string): void { + const message: DxChatTypes.Message = { + id: Date.now(), + timestamp: new Date(), + author: this.user, + text: prompt, + }; + + this.insertMessage(message); + + if (!this.alerts.length) { + this.fetchAIResponse(message); + } + } + + convertToHtml(value: string): string { + return unified() + .use(remarkParse) + .use(remarkRehype) + .use(rehypeMinifyWhitespace) + .use(rehypeStringify) + .processSync(value) + .toString(); + } +} diff --git a/apps/demos/Demos/Chat/MessageStreaming/Angular/index.html b/apps/demos/Demos/Chat/MessageStreaming/Angular/index.html new file mode 100644 index 000000000000..1ab1fb54a1df --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/Angular/index.html @@ -0,0 +1,26 @@ + + + + DevExtreme Demo + + + + + + + + + + + + + + + +
+ Loading... +
+ + diff --git a/apps/demos/Demos/Chat/MessageStreaming/React/App.tsx b/apps/demos/Demos/Chat/MessageStreaming/React/App.tsx new file mode 100644 index 000000000000..d2c39b9f55e1 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/React/App.tsx @@ -0,0 +1,86 @@ +import React, { useCallback, useMemo } from 'react'; +import Chat from 'devextreme-react/chat'; +import type { ChatTypes } from 'devextreme-react/chat'; +import { loadMessages } from 'devextreme-react/common/core/localization'; +import { + user, +} from './data.ts'; +import Message from './Message.tsx'; +import EmptyView from './EmptyView.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, + typingUsers, + isStreaming, + insertMessage, + fetchAIResponse, + stopStreaming, + } = useApi(); + + const onMessageEntered = useCallback(async ({ message }: ChatTypes.MessageEnteredEvent): Promise => { + insertMessage({ id: Date.now(), ...message }); + + if (!alerts.length) { + await fetchAIResponse(message); + } + }, [insertMessage, alerts.length, fetchAIResponse]); + + const sendSuggestion = useCallback((prompt: string): void => { + const message: ChatTypes.Message = { + id: Date.now(), + timestamp: new Date(), + author: user, + text: prompt, + }; + + insertMessage(message); + + if (!alerts.length) { + fetchAIResponse(message); + } + }, [insertMessage, alerts.length, fetchAIResponse]); + + const messageRender = useCallback(({ message }: { message: ChatTypes.Message }) => , []); + + const emptyViewRender = useCallback(({ texts }: ChatTypes.EmptyViewTemplateData) => ( + + ), [sendSuggestion]); + + const sendButtonOptions = useMemo(() => (isStreaming ? { + action: 'custom', + icon: 'stopfilled', + onClick: stopStreaming, + } : { + action: 'send', + icon: 'arrowright', + onClick: () => {}, + }), [isStreaming, stopStreaming]); + + return ( + + ); +} diff --git a/apps/demos/Demos/Chat/MessageStreaming/React/EmptyView.tsx b/apps/demos/Demos/Chat/MessageStreaming/React/EmptyView.tsx new file mode 100644 index 000000000000..852d833e9da1 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/React/EmptyView.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import type { FC } from 'react'; +import type { ChatTypes } from 'devextreme-react/chat'; +import { suggestionCards } from './data.ts'; + +interface EmptyViewProps { + texts: ChatTypes.EmptyViewTemplateData['texts']; + onSuggestionClick: (prompt: string) => void; +} + +const EmptyView: FC = ({ texts, onSuggestionClick }: EmptyViewProps) => ( +
+
{texts.message}
+
{texts.prompt}
+
+ {suggestionCards.map((card) => ( + + ))} +
+
+); + +export default EmptyView; diff --git a/apps/demos/Demos/Chat/MessageStreaming/React/Message.tsx b/apps/demos/Demos/Chat/MessageStreaming/React/Message.tsx new file mode 100644 index 000000000000..f4fb48ef524c --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/React/Message.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import type { FC } from 'react'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkRehype from 'remark-rehype'; +import rehypeMinifyWhitespace from 'rehype-minify-whitespace'; +import rehypeStringify from 'rehype-stringify'; +import HTMLReactParser from 'html-react-parser'; + +function convertToHtml(value: string): string { + return unified() + .use(remarkParse) + .use(remarkRehype) + .use(rehypeMinifyWhitespace) + .use(rehypeStringify) + .processSync(value) + .toString(); +} + +interface MessageProps { + text: string; +} + +const Message: FC = ({ text }: MessageProps) => ( +
+ {HTMLReactParser(convertToHtml(text))} +
+); + +export default Message; diff --git a/apps/demos/Demos/Chat/MessageStreaming/React/data.ts b/apps/demos/Demos/Chat/MessageStreaming/React/data.ts new file mode 100644 index 000000000000..980ebab8945b --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/React/data.ts @@ -0,0 +1,30 @@ +import type { ChatTypes } from 'devextreme-react/chat'; + +export const ALERT_TIMEOUT = 1000 * 60; + +export const user: ChatTypes.User = { + id: 'user', +}; + +export const assistant: ChatTypes.User = { + id: 'assistant', + name: 'AI Assistant', +}; + +export const suggestionCards = [ + { + title: '💡 What is DevExtreme?', + description: 'What is DevExtreme and how can it help me build modern web apps?', + prompt: 'What is DevExtreme, and which components and frameworks does it support?', + }, + { + title: '🚀 Get Started with DevExtreme', + description: 'How do I get started with DevExtreme in my project?', + prompt: 'How can I get started with DevExtreme? Include instructions for library installation, linking required CSS/assets, applying an application theme, and coding a simple working app.', + }, + { + title: '📄 DevExtreme Licensing', + description: 'What are the licensing options for DevExtreme?', + prompt: 'Which DevExtreme license do I need for a commercial project? What licensing options are available?', + }, +]; diff --git a/apps/demos/Demos/Chat/MessageStreaming/React/index.html b/apps/demos/Demos/Chat/MessageStreaming/React/index.html new file mode 100644 index 000000000000..ee451f8288ff --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/React/index.html @@ -0,0 +1,24 @@ + + + + DevExtreme Demo + + + + + + + + + + + + + +
+
+
+ + diff --git a/apps/demos/Demos/Chat/MessageStreaming/React/index.tsx b/apps/demos/Demos/Chat/MessageStreaming/React/index.tsx new file mode 100644 index 000000000000..8acbec4b6179 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/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/MessageStreaming/React/service.ts b/apps/demos/Demos/Chat/MessageStreaming/React/service.ts new file mode 100644 index 000000000000..403227279c35 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/React/service.ts @@ -0,0 +1,59 @@ +import { AzureOpenAI, OpenAI } from 'openai'; + +export type AIMessage = ( + OpenAI.ChatCompletionUserMessageParam + | OpenAI.ChatCompletionSystemMessageParam + | OpenAI.ChatCompletionAssistantMessageParam) & { + content: string; + }; + +interface GetAIResponseStreamOptions { + onAborted: () => void; + onDelta: (delta: string) => void; + onError?: (error: unknown) => void; + signal: AbortSignal; +} + +const AzureOpenAIConfig = { + dangerouslyAllowBrowser: true, + deployment: 'gpt-4o-mini', + apiVersion: '2024-02-01', + endpoint: 'https://public-api.devexpress.com/demo-openai', + apiKey: 'DEMO', +}; + +const chatService = new AzureOpenAI(AzureOpenAIConfig); + +export async function getAIResponseStream( + messages: AIMessage[], + { onAborted, onDelta, onError, signal }: GetAIResponseStreamOptions, +): Promise { + const params = { + messages, + model: AzureOpenAIConfig.deployment, + max_tokens: 1000, + temperature: 0.7, + stream: true as const, + }; + + try { + const stream = await chatService.chat.completions.create(params, { signal }); + // eslint-disable-next-line no-restricted-syntax + for await (const event of stream) { + const delta = event.choices?.[0]?.delta?.content; + if (delta) { + onDelta(delta); + } + } + + if (signal.aborted) { + onAborted(); + } + } catch (e) { + if ((e as Error)?.name === 'AbortError') { + onAborted(); + } + onError?.(e); + throw e; + } +} diff --git a/apps/demos/Demos/Chat/MessageStreaming/React/styles.css b/apps/demos/Demos/Chat/MessageStreaming/React/styles.css new file mode 100644 index 000000000000..3b9fdf5c8c25 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/React/styles.css @@ -0,0 +1,95 @@ +#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, +.chat-messagebubble-text { + display: flex; + flex-direction: column; +} + +.dx-button { + display: inline-block; + color: var(--dx-color-icon); +} + +.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 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; +} + +.chat-suggestion-cards { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 16px; + margin-top: 32px; + width: 100%; +} + +.chat-suggestion-card { + border-radius: 12px; + padding: 16px; + border: 1px solid #EBEBEB; + background: #FAFAFA; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + flex: 0 1 230px; + max-width: 230px; + text-align: left; + cursor: pointer; + transition: 0.2s ease; + width: 230px; +} + +.chat-suggestion-card:hover { + border: 1px solid #E0E0E0; + background: #F5F5F5; + box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.04), 0 4px 24px 0 rgba(0, 0, 0, 0.02); +} + +.chat-suggestion-card-title { + color: #242424; + font-size: 12px; + font-weight: 600; + line-height: 16px; +} + +.chat-suggestion-card-prompt { + color: #616161; + font-size: 12px; + font-weight: 400; + line-height: 16px; +} + +.dx-chat-messagelist-empty-prompt { + margin-top: 4px; +} diff --git a/apps/demos/Demos/Chat/MessageStreaming/React/useApi.ts b/apps/demos/Demos/Chat/MessageStreaming/React/useApi.ts new file mode 100644 index 000000000000..fd5d459b3553 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/React/useApi.ts @@ -0,0 +1,188 @@ +import { useCallback, useRef, useState } from 'react'; +import type { ChatTypes } from 'devextreme-react/chat'; +import { CustomStore, DataSource } from 'devextreme-react/common/data'; +import { + ALERT_TIMEOUT, + assistant, +} from './data.ts'; +import { getAIResponseStream } from './service.ts'; +import type { AIMessage } from './service.ts'; + +const store: ChatTypes.Message[] = []; + +const customStore = new CustomStore({ + key: 'id', + load: (): Promise => new Promise((resolve) => { + setTimeout(() => { + resolve([...store]); + }, 0); + }), + insert: (message: ChatTypes.Message): Promise => new Promise((resolve) => { + setTimeout(() => { + store.push(message); + resolve(message); + }); + }), +}); + +export const dataSource = new DataSource({ + store: customStore, + paginate: false, +}); + +const dataItemToMessage = (item: ChatTypes.Message): AIMessage => ({ + role: item.author?.id as AIMessage['role'], + content: item.text, +}); + +const getMessageHistory = (): AIMessage[] => [...dataSource.items()].map(dataItemToMessage); + +interface DelayedRendererOptions { + delay?: number; + onRender: (chunk: string) => void; +} + +function createDelayedRenderer({ delay = 20, onRender }: DelayedRendererOptions) { + let queue: string[] = []; + let rendering = false; + + function processQueue() { + if (!queue.length) { + rendering = false; + return; + } + + rendering = true; + const chunk = queue.shift(); + if (chunk !== undefined) { + onRender(chunk); + } + + setTimeout(processQueue, delay); + } + + function pushChunk(chunk: string) { + queue.push(chunk); + + if (!rendering) { + processQueue(); + } + } + + function stop() { + queue = []; + rendering = false; + } + + return { pushChunk, stop }; +} + +export const useApi = () => { + const [alerts, setAlerts] = useState([]); + const [typingUsers, setTypingUsers] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const abortControllerRef = useRef(null); + + const insertMessage = useCallback((data: ChatTypes.Message): void => { + dataSource.store().push([{ type: 'insert', data }]); + }, []); + + const updateMessageText = useCallback((id: number, text: string): void => { + dataSource.store().push([{ + type: 'update', + key: id, + data: { text }, + }]); + }, []); + + const insertAssistantPlaceholder = useCallback((): number => { + const id = Date.now(); + dataSource.store().push([{ + type: 'insert', + data: { + id, + timestamp: new Date(), + author: assistant, + text: '', + }, + }]); + return id; + }, []); + + const alertLimitReached = useCallback((): void => { + setAlerts([{ + message: 'Request limit reached, try again in a minute.', + }]); + + setTimeout(() => { + setAlerts([]); + }, ALERT_TIMEOUT); + }, []); + + const stopStreaming = useCallback((): void => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }, []); + + const fetchAIResponse = useCallback(async (message: ChatTypes.Message): Promise => { + const messages = [...getMessageHistory(), dataItemToMessage(message)]; + abortControllerRef.current = new AbortController(); + + setTimeout(() => setIsStreaming(true), 0); + setTypingUsers([assistant]); + + let assistantId: number | undefined; + let buffer = ''; + let typingCleared = false; + + const delayedRenderer = createDelayedRenderer({ + onRender: (chunk: string) => { + if (!typingCleared) { + setTypingUsers([]); + typingCleared = true; + } + + if (assistantId === undefined) { + assistantId = insertAssistantPlaceholder(); + } + + buffer += chunk; + updateMessageText(assistantId, buffer); + }, + }); + + const onAborted = () => { + delayedRenderer.stop(); + }; + + try { + await getAIResponseStream(messages, { + onAborted, + onDelta: delayedRenderer.pushChunk, + signal: abortControllerRef.current.signal, + }); + + setTypingUsers([]); + } catch (e: unknown) { + setTypingUsers([]); + + if ((e as Error)?.name !== 'AbortError' && assistantId !== undefined) { + updateMessageText(assistantId, ''); + alertLimitReached(); + } + } finally { + abortControllerRef.current = null; + setIsStreaming(false); + } + }, [alertLimitReached, insertAssistantPlaceholder, updateMessageText]); + + return { + alerts, + typingUsers, + isStreaming, + insertMessage, + fetchAIResponse, + stopStreaming, + }; +}; diff --git a/apps/demos/Demos/Chat/MessageStreaming/ReactJs/App.js b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/App.js new file mode 100644 index 000000000000..677e16e02f95 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/App.js @@ -0,0 +1,87 @@ +import React, { useCallback, useMemo } from 'react'; +import Chat from 'devextreme-react/chat'; +import { loadMessages } from 'devextreme-react/common/core/localization'; +import { user } from './data.js'; +import Message from './Message.js'; +import EmptyView from './EmptyView.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, typingUsers, isStreaming, insertMessage, fetchAIResponse, stopStreaming, + } = + useApi(); + const onMessageEntered = useCallback( + async ({ message }) => { + insertMessage({ id: Date.now(), ...message }); + if (!alerts.length) { + await fetchAIResponse(message); + } + }, + [insertMessage, alerts.length, fetchAIResponse], + ); + const sendSuggestion = useCallback( + (prompt) => { + const message = { + id: Date.now(), + timestamp: new Date(), + author: user, + text: prompt, + }; + insertMessage(message); + if (!alerts.length) { + fetchAIResponse(message); + } + }, + [insertMessage, alerts.length, fetchAIResponse], + ); + const messageRender = useCallback(({ message }) => , []); + const emptyViewRender = useCallback( + ({ texts }) => ( + + ), + [sendSuggestion], + ); + const sendButtonOptions = useMemo( + () => + isStreaming + ? { + action: 'custom', + icon: 'stopfilled', + onClick: stopStreaming, + } + : { + action: 'send', + icon: 'arrowright', + onClick: () => {}, + }, + [isStreaming, stopStreaming], + ); + return ( + + ); +} diff --git a/apps/demos/Demos/Chat/MessageStreaming/ReactJs/EmptyView.js b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/EmptyView.js new file mode 100644 index 000000000000..b2ab7e83f70e --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/EmptyView.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { suggestionCards } from './data.js'; + +const EmptyView = ({ texts, onSuggestionClick }) => ( +
+
{texts.message}
+
{texts.prompt}
+
+ {suggestionCards.map((card) => ( + + ))} +
+
+); +export default EmptyView; diff --git a/apps/demos/Demos/Chat/MessageStreaming/ReactJs/Message.js b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/Message.js new file mode 100644 index 000000000000..bea9a2bff9cf --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/Message.js @@ -0,0 +1,21 @@ +import React from 'react'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkRehype from 'remark-rehype'; +import rehypeMinifyWhitespace from 'rehype-minify-whitespace'; +import rehypeStringify from 'rehype-stringify'; +import HTMLReactParser from 'html-react-parser'; + +function convertToHtml(value) { + return unified() + .use(remarkParse) + .use(remarkRehype) + .use(rehypeMinifyWhitespace) + .use(rehypeStringify) + .processSync(value) + .toString(); +} +const Message = ({ text }) => ( +
{HTMLReactParser(convertToHtml(text))}
+); +export default Message; diff --git a/apps/demos/Demos/Chat/MessageStreaming/ReactJs/data.js b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/data.js new file mode 100644 index 000000000000..9573773eae88 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/data.js @@ -0,0 +1,27 @@ +export const ALERT_TIMEOUT = 1000 * 60; +export const user = { + id: 'user', +}; +export const assistant = { + id: 'assistant', + name: 'AI Assistant', +}; +export const suggestionCards = [ + { + title: '💡 What is DevExtreme?', + description: 'What is DevExtreme and how can it help me build modern web apps?', + prompt: 'What is DevExtreme, and which components and frameworks does it support?', + }, + { + title: '🚀 Get Started with DevExtreme', + description: 'How do I get started with DevExtreme in my project?', + prompt: + 'How can I get started with DevExtreme? Include instructions for library installation, linking required CSS/assets, applying an application theme, and coding a simple working app.', + }, + { + title: '📄 DevExtreme Licensing', + description: 'What are the licensing options for DevExtreme?', + prompt: + 'Which DevExtreme license do I need for a commercial project? What licensing options are available?', + }, +]; diff --git a/apps/demos/Demos/Chat/MessageStreaming/ReactJs/index.html b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/index.html new file mode 100644 index 000000000000..db31b0fd60c6 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/index.html @@ -0,0 +1,44 @@ + + + + DevExtreme Demo + + + + + + + + + + + + + +
+
+
+ + diff --git a/apps/demos/Demos/Chat/MessageStreaming/ReactJs/index.js b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/index.js new file mode 100644 index 000000000000..b853e0be8242 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/index.js @@ -0,0 +1,5 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App.js'; + +ReactDOM.render(, document.getElementById('app')); diff --git a/apps/demos/Demos/Chat/MessageStreaming/ReactJs/service.js b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/service.js new file mode 100644 index 000000000000..67afd8c0a280 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/service.js @@ -0,0 +1,40 @@ +import { AzureOpenAI } from 'openai'; + +const AzureOpenAIConfig = { + dangerouslyAllowBrowser: true, + deployment: 'gpt-4o-mini', + apiVersion: '2024-02-01', + endpoint: 'https://public-api.devexpress.com/demo-openai', + apiKey: 'DEMO', +}; +const chatService = new AzureOpenAI(AzureOpenAIConfig); +export async function getAIResponseStream(messages, { + onAborted, onDelta, onError, signal, +}) { + const params = { + messages, + model: AzureOpenAIConfig.deployment, + max_tokens: 1000, + temperature: 0.7, + stream: true, + }; + try { + const stream = await chatService.chat.completions.create(params, { signal }); + // eslint-disable-next-line no-restricted-syntax + for await (const event of stream) { + const delta = event.choices?.[0]?.delta?.content; + if (delta) { + onDelta(delta); + } + } + if (signal.aborted) { + onAborted(); + } + } catch (e) { + if (e?.name === 'AbortError') { + onAborted(); + } + onError?.(e); + throw e; + } +} diff --git a/apps/demos/Demos/Chat/MessageStreaming/ReactJs/styles.css b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/styles.css new file mode 100644 index 000000000000..3b9fdf5c8c25 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/styles.css @@ -0,0 +1,95 @@ +#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, +.chat-messagebubble-text { + display: flex; + flex-direction: column; +} + +.dx-button { + display: inline-block; + color: var(--dx-color-icon); +} + +.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 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; +} + +.chat-suggestion-cards { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 16px; + margin-top: 32px; + width: 100%; +} + +.chat-suggestion-card { + border-radius: 12px; + padding: 16px; + border: 1px solid #EBEBEB; + background: #FAFAFA; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + flex: 0 1 230px; + max-width: 230px; + text-align: left; + cursor: pointer; + transition: 0.2s ease; + width: 230px; +} + +.chat-suggestion-card:hover { + border: 1px solid #E0E0E0; + background: #F5F5F5; + box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.04), 0 4px 24px 0 rgba(0, 0, 0, 0.02); +} + +.chat-suggestion-card-title { + color: #242424; + font-size: 12px; + font-weight: 600; + line-height: 16px; +} + +.chat-suggestion-card-prompt { + color: #616161; + font-size: 12px; + font-weight: 400; + line-height: 16px; +} + +.dx-chat-messagelist-empty-prompt { + margin-top: 4px; +} diff --git a/apps/demos/Demos/Chat/MessageStreaming/ReactJs/useApi.js b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/useApi.js new file mode 100644 index 000000000000..8ad6561ef71e --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/ReactJs/useApi.js @@ -0,0 +1,159 @@ +import { useCallback, useRef, useState } from 'react'; +import { CustomStore, DataSource } from 'devextreme-react/common/data'; +import { ALERT_TIMEOUT, assistant } from './data.js'; +import { getAIResponseStream } from './service.js'; + +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); +function createDelayedRenderer({ delay = 20, onRender }) { + let queue = []; + let rendering = false; + function processQueue() { + if (!queue.length) { + rendering = false; + return; + } + rendering = true; + const chunk = queue.shift(); + if (chunk !== undefined) { + onRender(chunk); + } + setTimeout(processQueue, delay); + } + function pushChunk(chunk) { + queue.push(chunk); + if (!rendering) { + processQueue(); + } + } + function stop() { + queue = []; + rendering = false; + } + return { pushChunk, stop }; +} +export const useApi = () => { + const [alerts, setAlerts] = useState([]); + const [typingUsers, setTypingUsers] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const abortControllerRef = useRef(null); + const insertMessage = useCallback((data) => { + dataSource.store().push([{ type: 'insert', data }]); + }, []); + const updateMessageText = useCallback((id, text) => { + dataSource.store().push([ + { + type: 'update', + key: id, + data: { text }, + }, + ]); + }, []); + const insertAssistantPlaceholder = useCallback(() => { + const id = Date.now(); + dataSource.store().push([ + { + type: 'insert', + data: { + id, + timestamp: new Date(), + author: assistant, + text: '', + }, + }, + ]); + return id; + }, []); + const alertLimitReached = useCallback(() => { + setAlerts([ + { + message: 'Request limit reached, try again in a minute.', + }, + ]); + setTimeout(() => { + setAlerts([]); + }, ALERT_TIMEOUT); + }, []); + const stopStreaming = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }, []); + const fetchAIResponse = useCallback( + async (message) => { + const messages = [...getMessageHistory(), dataItemToMessage(message)]; + abortControllerRef.current = new AbortController(); + setTimeout(() => setIsStreaming(true), 0); + setTypingUsers([assistant]); + let assistantId; + let buffer = ''; + let typingCleared = false; + const delayedRenderer = createDelayedRenderer({ + onRender: (chunk) => { + if (!typingCleared) { + setTypingUsers([]); + typingCleared = true; + } + if (assistantId === undefined) { + assistantId = insertAssistantPlaceholder(); + } + buffer += chunk; + updateMessageText(assistantId, buffer); + }, + }); + const onAborted = () => { + delayedRenderer.stop(); + }; + try { + await getAIResponseStream(messages, { + onAborted, + onDelta: delayedRenderer.pushChunk, + signal: abortControllerRef.current.signal, + }); + setTypingUsers([]); + } catch (e) { + setTypingUsers([]); + if (e?.name !== 'AbortError' && assistantId !== undefined) { + updateMessageText(assistantId, ''); + alertLimitReached(); + } + } finally { + abortControllerRef.current = null; + setIsStreaming(false); + } + }, + [alertLimitReached, insertAssistantPlaceholder, updateMessageText], + ); + return { + alerts, + typingUsers, + isStreaming, + insertMessage, + fetchAIResponse, + stopStreaming, + }; +}; diff --git a/apps/demos/Demos/Chat/MessageStreaming/Vue/App.vue b/apps/demos/Demos/Chat/MessageStreaming/Vue/App.vue new file mode 100644 index 000000000000..5ca4e76a612e --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/Vue/App.vue @@ -0,0 +1,289 @@ + + + + + diff --git a/apps/demos/Demos/Chat/MessageStreaming/Vue/data.ts b/apps/demos/Demos/Chat/MessageStreaming/Vue/data.ts new file mode 100644 index 000000000000..436e3c8f6174 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/Vue/data.ts @@ -0,0 +1,76 @@ +import { CustomStore, DataSource } from 'devextreme-vue/common/data'; +import { type DxChatTypes } from 'devextreme-vue/chat'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkRehype from 'remark-rehype'; +import rehypeStringify from 'rehype-stringify'; +import rehypeMinifyWhitespace from 'rehype-minify-whitespace'; + +export const dictionary = { + en: { + 'dxChat-emptyListMessage': 'Chat is Empty', + 'dxChat-emptyListPrompt': 'AI Assistant is ready to answer your questions.', + 'dxChat-textareaPlaceholder': 'Ask AI Assistant...', + }, +}; + +export const ALERT_TIMEOUT = 1000 * 60; + +export const user: DxChatTypes.User = { + id: 'user', +}; + +export const assistant: DxChatTypes.User = { + id: 'assistant', + name: 'AI Assistant', +}; + +export const suggestionCards = [ + { + title: '💡 What is DevExtreme?', + description: 'What is DevExtreme and how can it help me build modern web apps?', + prompt: 'What is DevExtreme, and which components and frameworks does it support?', + }, + { + title: '🚀 Get Started with DevExtreme', + description: 'How do I get started with DevExtreme in my project?', + prompt: 'How can I get started with DevExtreme? Include instructions for library installation, linking required CSS/assets, applying an application theme, and coding a simple working app.', + }, + { + title: '📄 DevExtreme Licensing', + description: 'What are the licensing options for DevExtreme?', + prompt: 'Which DevExtreme license do I need for a commercial project? What licensing options are available?', + }, +]; + +const store: DxChatTypes.Message[] = []; + +const customStore = new CustomStore({ + key: 'id', + load: () => new Promise((resolve) => { + setTimeout(() => { + resolve([...store]); + }, 0); + }), + insert: (message: DxChatTypes.Message) => new Promise((resolve) => { + setTimeout(() => { + store.push(message); + resolve(message); + }); + }), +}); + +export const dataSource = new DataSource({ + store: customStore, + paginate: false, +}); + +export function convertToHtml(value: string): string { + return unified() + .use(remarkParse) + .use(remarkRehype) + .use(rehypeMinifyWhitespace) + .use(rehypeStringify) + .processSync(value) + .toString(); +} diff --git a/apps/demos/Demos/Chat/MessageStreaming/Vue/index.html b/apps/demos/Demos/Chat/MessageStreaming/Vue/index.html new file mode 100644 index 000000000000..2413f2254bf1 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/Vue/index.html @@ -0,0 +1,29 @@ + + + + DevExtreme Demo + + + + + + + + + + + + + + +
+
+
+ + diff --git a/apps/demos/Demos/Chat/MessageStreaming/Vue/index.ts b/apps/demos/Demos/Chat/MessageStreaming/Vue/index.ts new file mode 100644 index 000000000000..684d04215d72 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/Vue/index.ts @@ -0,0 +1,4 @@ +import { createApp } from 'vue'; +import App from './App.vue'; + +createApp(App).mount('#app'); diff --git a/apps/demos/Demos/Chat/MessageStreaming/Vue/service.ts b/apps/demos/Demos/Chat/MessageStreaming/Vue/service.ts new file mode 100644 index 000000000000..cd195ada0646 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/Vue/service.ts @@ -0,0 +1,59 @@ +import { AzureOpenAI, OpenAI } from 'openai'; + +export type AIMessage = ( + OpenAI.ChatCompletionUserMessageParam + | OpenAI.ChatCompletionSystemMessageParam + | OpenAI.ChatCompletionAssistantMessageParam) & { + content: string; + }; + +export interface GetAIResponseStreamOptions { + onAborted: () => void; + onDelta: (delta: string) => void; + onError?: (error: unknown) => void; + signal: AbortSignal; +} + +const AzureOpenAIConfig = { + dangerouslyAllowBrowser: true, + deployment: 'gpt-4o-mini', + apiVersion: '2024-02-01', + endpoint: 'https://public-api.devexpress.com/demo-openai', + apiKey: 'DEMO', +}; + +const chatService = new AzureOpenAI(AzureOpenAIConfig); + +export async function getAIResponseStream( + messages: AIMessage[], + { onAborted, onDelta, onError, signal }: GetAIResponseStreamOptions, +): Promise { + const params = { + messages, + model: AzureOpenAIConfig.deployment, + max_tokens: 1000, + temperature: 0.7, + stream: true as const, + }; + + try { + const stream = await chatService.chat.completions.create(params, { signal }); + + for await (const event of stream) { + const delta = event.choices?.[0]?.delta?.content; + if (delta) { + onDelta(delta); + } + } + + if (signal.aborted) { + onAborted(); + } + } catch (e) { + if ((e as Error)?.name === 'AbortError') { + onAborted(); + } + onError?.(e); + throw e; + } +} diff --git a/apps/demos/Demos/Chat/MessageStreaming/jQuery/data.js b/apps/demos/Demos/Chat/MessageStreaming/jQuery/data.js new file mode 100644 index 000000000000..95fd53e4b841 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/jQuery/data.js @@ -0,0 +1,29 @@ +const deployment = 'gpt-4o-mini'; +const apiVersion = '2024-02-01'; +const endpoint = 'https://public-api.devexpress.com/demo-openai'; +const apiKey = 'DEMO'; +const ALERT_TIMEOUT = 1000 * 60; +const user = { + id: 'user', +}; +const assistant = { + id: 'assistant', + name: 'AI Assistant', +}; +const suggestionCards = [ + { + title: '💡 What is DevExtreme?', + description: 'What is DevExtreme and how can it help me build modern web apps?', + prompt: 'What is DevExtreme, and which components and frameworks does it support?', + }, + { + title: '🚀 Get Started with DevExtreme', + description: 'How do I get started with DevExtreme in my project?', + prompt: 'How can I get started with DevExtreme? Include instructions for library installation, linking required CSS/assets, applying an application theme, and coding a simple working app.', + }, + { + title: '📄 DevExtreme Licensing', + description: 'What are the licensing options for DevExtreme?', + prompt: 'Which DevExtreme license do I need for a commercial project? What licensing options are available?', + }, +]; diff --git a/apps/demos/Demos/Chat/MessageStreaming/jQuery/index.html b/apps/demos/Demos/Chat/MessageStreaming/jQuery/index.html new file mode 100644 index 000000000000..28c36ea0c0a9 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/jQuery/index.html @@ -0,0 +1,37 @@ + + + + DevExtreme Demo + + + + + + + + + + + + + + +
+
+
+ + diff --git a/apps/demos/Demos/Chat/MessageStreaming/jQuery/index.js b/apps/demos/Demos/Chat/MessageStreaming/jQuery/index.js new file mode 100644 index 000000000000..e4f8257bffb1 --- /dev/null +++ b/apps/demos/Demos/Chat/MessageStreaming/jQuery/index.js @@ -0,0 +1,333 @@ +$(() => { + const store = []; + const messages = []; + let abortController = null; + + DevExpress.localization.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({ + dangerouslyAllowBrowser: true, + deployment, + endpoint, + apiVersion, + apiKey, + }); + + function createDelayedRenderer({ delay = 20, onRender }) { + let queue = []; + let rendering = false; + + function processQueue() { + if (!queue.length) { + rendering = false; + + return; + } + + rendering = true; + const chunk = queue.shift(); + onRender(chunk); + + setTimeout(processQueue, delay); + } + + function pushChunk(chunk) { + queue.push(chunk); + + if (!rendering) { + processQueue(); + } + } + + function stop() { + queue = []; + rendering = false; + } + + return { pushChunk, stop }; + } + + async function getAIResponseStream(messages, { onAborted, onDelta, onError, signal }) { + const params = { + messages, + model: deployment, + max_tokens: 1000, + temperature: 0.7, + stream: true, + }; + + try { + const stream = await chatService.chat.completions.create(params, { + signal, + }); + + // eslint-disable-next-line no-restricted-syntax + for await (const event of stream) { + const delta = event.choices?.[0]?.delta?.content; + if (delta) { + onDelta(delta); + } + } + + if (signal.aborted) { + onAborted(); + } + } catch (e) { + onError?.(e); + throw e; + } + } + + function alertLimitReached() { + instance.option({ + alerts: [ + { + message: 'Request limit reached, try again in a minute.', + }, + ], + }); + + setTimeout(() => { + instance.option({ alerts: [] }); + }, ALERT_TIMEOUT); + } + + function setMainButtonToDefault() { + instance.option({ + sendButtonOptions: { + action: 'send', + icon: 'arrowright', + onClick: null, + }, + }); + } + + function setMainButtonToStop() { + instance.option({ + sendButtonOptions: { + action: 'custom', + icon: 'stopfilled', + onClick: stopStreaming, + }, + }); + } + + function stopStreaming() { + if (abortController) { + abortController.abort(); + setMainButtonToDefault(); + } + } + + async function processMessageSending(message) { + abortController = new AbortController(); + setTimeout(setMainButtonToStop, 0); + + messages.push({ role: 'user', content: message.text }); + instance.option({ typingUsers: [assistant] }); + + let assistantId; + let buffer = ''; + let typingCleared = false; + + const delayedRenderer = createDelayedRenderer({ onRender: (chunk) => { + if (!typingCleared) { + instance.option({ typingUsers: [] }); + typingCleared = true; + } + + if (!assistantId) { + assistantId = insertAssistantPlaceholder(); + } + + buffer += chunk; + + updateMessageText(assistantId, buffer); + } }); + + const onAborted = () => { + delayedRenderer.stop(); + }; + + try { + await getAIResponseStream(messages, { + onAborted, + onDelta: delayedRenderer.pushChunk, + signal: abortController.signal, + }); + + instance.option({ typingUsers: [] }); + messages.push({ role: 'assistant', content: buffer }); + } catch (e) { + instance.option({ typingUsers: [] }); + + messages.pop(); + + if (e?.name !== 'AbortError' && assistantId) { + updateMessageText(assistantId, ''); + alertLimitReached(); + } + } finally { + abortController = null; + setMainButtonToDefault(); + } + } + + function insertAssistantPlaceholder() { + const id = Date.now(); + + dataSource.store().push([ + { + type: 'insert', + data: { + id, + timestamp: new Date(), + author: assistant, + text: '', + }, + }, + ]); + + return id; + } + + function updateMessageText(id, text) { + dataSource.store().push([ + { + type: 'update', + key: id, + data: { text }, + }, + ]); + } + + function convertToHtml(value) { + const result = unified() + .use(remarkParse) + .use(remarkRehype) + .use(rehypeMinifyWhitespace) + .use(rehypeStringify) + .processSync(value) + .toString(); + + return result; + } + + function renderMessageContent(message, element) { + $('
') + .addClass('chat-messagebubble-text') + .html(convertToHtml(message.text)) + .appendTo(element); + } + + const customStore = new DevExpress.data.CustomStore({ + key: 'id', + load: () => { + const d = $.Deferred(); + + setTimeout(() => { + d.resolve([...store]); + }); + + return d.promise(); + }, + insert: (message) => { + const d = $.Deferred(); + + setTimeout(() => { + store.push(message); + d.resolve(); + }); + + return d.promise(); + }, + }); + + const dataSource = new DevExpress.data.DataSource({ + store: customStore, + paginate: false, + }); + + function sendSuggestion(prompt) { + const message = { + id: Date.now(), + timestamp: new Date(), + author: user, + text: prompt, + }; + + dataSource.store().push([{ type: 'insert', data: message }]); + + if (!instance.option('alerts').length) { + processMessageSending(message); + } + } + + function createSuggestionCard(card) { + return $('