DevExtreme v26.1 is now available.

Explore our newest features/capabilities and share your thoughts with us.

Your search did not match any results.

Vue Chat - Message Streaming

This sample uses the DevExtreme Chat component alongside Azure OpenAI to stream AI-generated responses in real time. Since the AI model generates output token by token, our Chat component renders the response incrementally inside a message bubble. A "typing" indicator appears while the response is being streamed, and the send button transforms into a stop button so that users can cancel an in-progress stream at any time.

The empty Chat displays custom suggestion cards. Clicking a card sends the corresponding prompt directly to the AI service without extra user input.

Backend API
<template> <div class="chat-container"> <DxChat :height="710" :data-source="dataSource" :reload-on-change="false" :show-avatar="false" :show-day-headers="false" :user="user" :speech-to-text-enabled="true" :typing-users="typingUsers" :alerts="alerts" :send-button-options="sendButtonOptions" message-template="message" empty-view-template="emptyView" @message-entered="onMessageEntered($event)" > <template #message="{ data }"> <div v-html="convertToHtml(data.message.text)" class="chat-messagebubble-text" /> </template> <template #emptyView="{ data }"> <div> <div class="dx-chat-messagelist-empty-message">{{ data.texts.message }}</div> <div class="dx-chat-messagelist-empty-prompt">{{ data.texts.prompt }}</div> <div class="chat-suggestion-cards"> <button v-for="card in suggestionCards" :key="card.title" type="button" class="chat-suggestion-card" @click="sendSuggestion(card.prompt)" > <div class="chat-suggestion-card-title">{{ card.title }}</div> <div class="chat-suggestion-card-prompt">{{ card.description }}</div> </button> </div> </div> </template> </DxChat> </div> </template> <script setup lang="ts"> import { ref, computed, onBeforeMount } from 'vue'; import DxChat, { type DxChatTypes } from 'devextreme-vue/chat'; import { loadMessages } from 'devextreme-vue/common/core/localization'; import { dictionary, user, assistant, dataSource, convertToHtml, ALERT_TIMEOUT, suggestionCards, } from './data.ts'; import { getAIResponseStream } from './service.ts'; import type { AIMessage } from './service.ts'; const typingUsers = ref<DxChatTypes.User[]>([]); const alerts = ref<DxChatTypes.Alert[]>([]); const isStreaming = ref(false); let abortController: AbortController | null = null; onBeforeMount(() => { loadMessages(dictionary); }); const sendButtonOptions = computed<DxChatTypes.SendButtonProperties>(() => ( isStreaming.value ? { action: 'custom', icon: 'stopfilled', onClick: stopStreaming } : { action: 'send', icon: 'arrowright', onClick: () => {} } )); function insertMessage(data: DxChatTypes.Message): void { dataSource.store().push([{ type: 'insert', data }]); } function updateMessageText(id: number, text: string): void { dataSource.store().push([{ type: 'update', key: id, data: { text } }]); } function insertAssistantPlaceholder(): number { const id = Date.now(); dataSource.store().push([{ type: 'insert', data: { id, timestamp: new Date(), author: assistant, text: '' }, }]); return id; } function alertLimitReached(): void { alerts.value = [{ message: 'Request limit reached, try again in a minute.' }]; setTimeout(() => { alerts.value = []; }, ALERT_TIMEOUT); } function createDelayedRenderer(onRender: (chunk: string) => void, delay = 20) { 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); } return { pushChunk(chunk: string) { queue.push(chunk); if (!rendering) { processQueue(); } }, stop() { queue = []; rendering = false; }, }; } function stopStreaming(): void { abortController?.abort(); } async function fetchAIResponse(message: DxChatTypes.Message): Promise<void> { const dataItemToAIMessage = (item: DxChatTypes.Message): AIMessage => ({ role: item.author?.id as AIMessage['role'], content: item.text, }); const messages: AIMessage[] = [ ...dataSource.items().map(dataItemToAIMessage), dataItemToAIMessage(message), ]; abortController = new AbortController(); setTimeout(() => { isStreaming.value = true; }, 0); typingUsers.value = [assistant]; let assistantId: number | undefined; let buffer = ''; let typingCleared = false; const renderer = createDelayedRenderer((chunk) => { if (!typingCleared) { typingUsers.value = []; typingCleared = true; } if (assistantId === undefined) { assistantId = insertAssistantPlaceholder(); } buffer += chunk; updateMessageText(assistantId, buffer); }); try { await getAIResponseStream(messages, { onAborted: () => renderer.stop(), onDelta: renderer.pushChunk, signal: abortController.signal, }); typingUsers.value = []; } catch (e: unknown) { typingUsers.value = []; if ((e as Error)?.name !== 'AbortError' && assistantId !== undefined) { updateMessageText(assistantId, ''); alertLimitReached(); } } finally { abortController = null; isStreaming.value = false; } } function onMessageEntered({ message }: DxChatTypes.MessageEnteredEvent): void { insertMessage({ id: Date.now(), ...message }); if (!alerts.value.length) { fetchAIResponse(message); } } function sendSuggestion(prompt: string): void { const message: DxChatTypes.Message = { id: Date.now(), timestamp: new Date(), author: user, text: prompt, }; insertMessage(message); if (!alerts.value.length) { fetchAIResponse(message); } } </script> <style scoped> .chat-container { width: 100%; display: flex; align-items: center; 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-chat-messagebubble-content > div > p:first-child { margin-top: 0; } .dx-chat-messagebubble-content > div > p:last-child { margin-bottom: 0; } .chat-messagebubble-text pre { white-space: pre-wrap; overflow-wrap: break-word; } .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; } </style>
window.exports = window.exports || {}; window.config = { transpiler: 'plugin-babel', meta: { '*.vue': { loader: 'vue-loader', }, '*.ts': { loader: 'demo-ts-loader', }, '*.svg': { loader: 'svg-loader', }, 'devextreme/time_zone_utils.js': { 'esModule': true, }, 'devextreme/localization.js': { 'esModule': true, }, 'devextreme/viz/palette.js': { 'esModule': true, }, 'openai': { 'esModule': true, }, 'zod': { 'esModule': true, }, 'zod-to-json-schema': { 'esModule': true, }, }, paths: { 'project:': '../../../../', 'npm:': 'https://cdn.jsdelivr.net/npm/', 'bundles:': '../../../../bundles/', 'externals:': '../../../../bundles/externals/', 'anti-forgery:': '../../../../shared/anti-forgery/', }, map: { 'anti-forgery': 'anti-forgery:fetch-override.js', 'vue': 'npm:vue@3.5.32/dist/vue.esm-browser.js', '@vue/shared': 'npm:@vue/shared@3.4.27/dist/shared.cjs.prod.js', 'vue-loader': 'npm:dx-systemjs-vue-browser@1.1.2/index.js', 'demo-ts-loader': 'project:utils/demo-ts-loader.js', 'jszip': 'npm:jszip@3.10.1/dist/jszip.min.js', 'svg-loader': 'project:utils/svg-loader.js', 'mitt': 'npm:mitt/dist/mitt.umd.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-vue': 'npm:devextreme-vue@link:../../packages/devextreme-vue/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', 'rehype-minify-whitespace': 'externals:unified/rehype-minify-whitespace.bundle.js', 'openai': 'externals:openai.bundle.js', 'zod': 'externals:zod.bundle.js', 'zod-to-json-schema': 'externals:zod-to-json-schema.bundle.js', 'devextreme-quill': 'npm:devextreme-quill@1.7.9/dist/dx-quill.min.js', 'devexpress-diagram': 'npm:devexpress-diagram@2.2.29/dist/dx-diagram.js', 'devexpress-gantt': 'npm:devexpress-gantt@4.1.69/dist/dx-gantt.js', 'inferno': 'npm:inferno@8.2.3/dist/inferno.min.js', 'inferno-compat': 'npm:inferno-compat/dist/inferno-compat.min.js', 'inferno-create-element': 'npm:inferno-create-element@8.2.3/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', '@preact/signals-core': 'npm:@preact/signals-core@1.14.1/dist/signals-core.min.js', '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-vue': { main: 'index.js', }, 'devextreme-vue/common': { main: 'index.js', }, 'devextreme': { defaultExtension: 'js', }, 'devextreme/events/utils': { main: 'index', }, 'devextreme/common/core/events/utils': { main: 'index', }, 'devextreme/events': { main: 'index', }, 'es6-object-assign': { main: './index.js', defaultExtension: 'js', }, }, packageConfigPaths: [ 'npm:@devextreme/*/package.json', ], babelOptions: { sourceMaps: false, stage0: true, }, }; window.process = { env: { NODE_ENV: 'production', }, }; System.config(window.config); // eslint-disable-next-line const useTgzInCSB = ['openai']; let packagesInfo = { "openai": { "version": "4.73.1" }, "rehype-minify-whitespace": { "version": "5.0.1" }, "rehype-parse": { "version": "8.0.5" }, "rehype-remark": { "version": "9.0.0" }, "rehype-stringify": { "version": "9.0.4" }, "remark-parse": { "version": "10.0.2" }, "remark-rehype": { "version": "10.1.0" }, "remark-stringify": { "version": "10.0.3" }, "unified": { "version": "10.1.2" }, "zod": { "version": "3.24.4" }, "zod-to-json-schema": { "version": "3.24.6" } };
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<DxChatTypes.Message>({ 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(); }
import { createApp } from 'vue'; import App from './App.vue'; createApp(App).mount('#app');
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: 'demo-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<void> { const params = { messages, model: AzureOpenAIConfig.deployment, max_completion_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; } }
<!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/26.1.3/css/dx.light.css" /> <script type="module"> import * as vueCompilerSFC from "https://cdn.jsdelivr.net/npm/@vue/compiler-sfc@3.4.27/dist/compiler-sfc.esm-browser.js"; window.vueCompilerSFC = vueCompilerSFC; </script> <script src="https://cdn.jsdelivr.net/npm/typescript@5.9.3/lib/typescript.js"></script> <script src="https://cdn.jsdelivr.net/npm/core-js@2.6.12/client/shim.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/systemjs@0.21.3/dist/system.js"></script> <script type="text/javascript" src="config.js"></script> <script type="text/javascript"> System.import("./index.ts"); </script> </head> <body class="dx-viewport"> <div class="demo-container"> <div id="app"></div> </div> </body> </html>

Streaming AI Responses

The demo calls the Azure OpenAI Chat Completions API with stream: true. Incoming delta chunks are passed through a createDelayedRenderer queue with a short display delay between chunks to produce a smooth typing effect. The demo appends each chunk to a growing buffer and updates the assistant message in the data store with every render cycle via a dataSource.store().push(...) call.

Stopping a Stream

The demo creates an AbortController before each request and passes its signal (AbortSignal) to the Azure OpenAI SDK in the request options. When the user clicks the stop button, the demo calls abortController.abort() to cancel the in-progress HTTP request.

The sendButtonOptions property switches the button's action property to 'custom' and the icon to 'stopfilled' while streaming is active, then reverts to the default (send) configuration once streaming ends.

Custom Empty View

The Chat component specifies an emptyViewTemplate that replaces the default empty state with custom suggestion cards. Clicking a card creates a message and triggers demo message send operations directly, bypassing text input.