DevExtreme v26.1 is now available.

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

Your search did not match any results.

Angular 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
<div class="demo-container"> <dx-chat [dataSource]="dataSource" [reloadOnChange]="false" [showAvatar]="false" [showDayHeaders]="false" [user]="user" height="710" [typingUsers]="typingUsers$ | async" [alerts]="alerts$ | async" [sendButtonOptions]="sendButtonOptions$ | async" [speechToTextEnabled]="true" (onMessageEntered)="onMessageEntered($event)" messageTemplate="messageTemplate" emptyViewTemplate="emptyViewTemplate" > <div *dxTemplate="let data of 'messageTemplate'"> <div class="chat-messagebubble-text" [innerHTML]="convertToHtml(data.message)" ></div> </div> <div *dxTemplate="let data of 'emptyViewTemplate'"> <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"> @for (card of suggestionCards; track card.title) { <button type="button" class="chat-suggestion-card" (click)="onSuggestionClick(card.prompt)" > <div class="chat-suggestion-card-title">{{ card.title }}</div> <div class="chat-suggestion-card-prompt">{{ card.description }}</div> </button> } </div> </div> </dx-chat> </div>
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: `app/app.component.html`, styleUrls: [`app/app.component.css`], imports: [ DxChatModule, AsyncPipe, ], }) export class AppComponent { dataSource: DataSource; user: DxChatTypes.User; typingUsers$: Observable<DxChatTypes.User[]>; alerts$: Observable<DxChatTypes.Alert[]>; sendButtonOptions$: Observable<DxChatTypes.SendButtonProperties>; 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, ], });
.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 .chat-messagebubble-text pre { white-space: pre-wrap; overflow-wrap: break-word; } ::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; }
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<DxChatTypes.User[]>([]); private alertsSubject = new BehaviorSubject<DxChatTypes.Alert[]>([]); private isStreamingSubject = new BehaviorSubject<boolean>(false); readonly dataSource: DataSource; get alerts(): DxChatTypes.Alert[] { return this.alertsSubject.getValue(); } get typingUsers$(): Observable<DxChatTypes.User[]> { return this.typingUsersSubject.asObservable(); } get alerts$(): Observable<DxChatTypes.Alert[]> { return this.alertsSubject.asObservable(); } get isStreaming$(): Observable<boolean> { 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<void> { 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(); } }
// In real applications, you should not transpile code in the browser. // You can see how to create your own application with Angular and DevExtreme here: // https://js.devexpress.com/Documentation/Guide/Angular_Components/Getting_Started/Create_a_DevExtreme_Application/ const componentNames = [ 'accordion', 'action-sheet', 'autocomplete', 'bar-gauge', 'box', 'bullet', 'button-group', 'button', 'calendar', 'card-view', 'chart', 'chat', 'check-box', 'circular-gauge', 'color-box', 'context-menu', 'data-grid', 'date-box', 'date-range-box', 'defer-rendering', 'diagram', 'draggable', 'drawer', 'drop-down-box', 'drop-down-button', 'file-manager', 'file-uploader', 'filter-builder', 'form', 'funnel', 'gallery', 'gantt', 'html-editor', 'linear-gauge', 'list', 'load-indicator', 'load-panel', 'lookup', 'map', 'menu', 'multi-view', 'nested', 'number-box', 'pagination', 'pie-chart', 'pivot-grid-field-chooser', 'pivot-grid', 'polar-chart', 'popover', 'popup', 'progress-bar', 'radio-group', 'range-selector', 'range-slider', 'recurrence-editor', 'resizable', 'responsive-box', 'sankey', 'scheduler', 'scroll-view', 'select-box', 'slider', 'sortable', 'sparkline', 'speech-to-text', 'speed-dial-action', 'splitter', 'stepper', 'switch', 'tab-panel', 'tabs', 'tag-box', 'text-area', 'text-box', 'tile-view', 'toast', 'toolbar', 'tooltip', 'tree-list', 'tree-map', 'tree-view', 'validation-group', 'validation-summary', 'validator', 'vector-map', ]; window.exports = window.exports || {}; window.config = { transpiler: 'ts', typescriptOptions: { module: 'system', emitDecoratorMetadata: true, experimentalDecorators: true, }, meta: { 'typescript': { 'exports': 'ts', }, 'devextreme/time_zone_utils.js': { 'esModule': true, }, 'devextreme/localization.js': { 'esModule': true, }, 'devextreme/viz/palette.js': { 'esModule': true, }, '@angular/platform-browser-dynamic': { 'esModule': true, }, '@angular/platform-browser': { 'esModule': true, }, '@angular/core': { 'esModule': true, }, '@angular/common': { 'esModule': true, }, '@angular/common/http': { 'esModule': true, }, '@angular/animations': { 'esModule': true, }, '@angular/forms': { 'esModule': true, }, 'openai': { 'esModule': true, }, 'zod': { 'esModule': true, }, 'zod-to-json-schema': { 'esModule': true, }, }, paths: { 'npm:': 'https://cdn.jsdelivr.net/npm/', 'bundles:': '../../../../bundles/', 'externals:': '../../../../bundles/externals/', 'anti-forgery:': '../../../../shared/anti-forgery/', }, map: { 'anti-forgery': 'anti-forgery:fetch-override.js', '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', /* @angular */ '@angular/compiler': 'bundles:@angular/compiler.umd.js', '@angular/platform-browser-dynamic': 'bundles:@angular/platform-browser-dynamic.umd.js', '@angular/core': 'bundles:@angular/core.umd.js', '@angular/core/primitives/signals': 'bundles:@angular/core.primitives.signals.umd.js', '@angular/core/primitives/di': 'bundles:@angular/core.primitives.di.umd.js', '@angular/common': 'bundles:@angular/common.umd.js', '@angular/common/http': 'bundles:@angular/common-http.umd.js', '@angular/platform-browser': 'bundles:@angular/platform-browser.umd.js', '@angular/platform-browser/animations': 'bundles:@angular/platform-browser.umd.js', '@angular/forms': 'bundles:@angular/forms.umd.js', /* devextreme */ 'devextreme': 'npm:devextreme@link:../../packages/devextreme/artifacts/npm/devextreme/cjs', 'devextreme-quill': 'npm:devextreme-quill@1.7.9/dist/dx-quill.min.js', 'devexpress-diagram': 'npm:devexpress-diagram@2.2.29', 'devexpress-gantt': 'npm:devexpress-gantt@4.1.69', /* devextreme-angular umd maps */ 'devextreme-angular': 'bundles:devextreme-angular/devextreme-angular.umd.js', 'devextreme-angular/common/ai-integration': 'bundles:devextreme-angular/devextreme-angular-common-ai-integration.umd.js', 'devextreme-angular/core': 'bundles:devextreme-angular/devextreme-angular-core.umd.js', 'devextreme-angular/common/charts': 'bundles:devextreme-angular/devextreme-angular-common-charts.umd.js', 'devextreme-angular/common/core/animation': 'bundles:devextreme-angular/devextreme-angular-common-core-animation.umd.js', 'devextreme-angular/common/core/environment': 'bundles:devextreme-angular/devextreme-angular-common-core-environment.umd.js', 'devextreme-angular/common/core/events': 'bundles:devextreme-angular/devextreme-angular-common-core-events.umd.js', 'devextreme-angular/common/core/localization': 'bundles:devextreme-angular/devextreme-angular-common-core-localization.umd.js', 'devextreme-angular/common/core': 'bundles:devextreme-angular/devextreme-angular-common-core.umd.js', 'devextreme-angular/common/data/custom-store': 'bundles:devextreme-angular/devextreme-angular-common-data-custom-store.umd.js', 'devextreme-angular/common/data': 'bundles:devextreme-angular/devextreme-angular-common-data.umd.js', 'devextreme-angular/common/export/excel': 'bundles:devextreme-angular/devextreme-angular-common-export-excel.umd.js', 'devextreme-angular/common/export/pdf': 'bundles:devextreme-angular/devextreme-angular-common-export-pdf.umd.js', 'devextreme-angular/common/export': 'bundles:devextreme-angular/devextreme-angular-common-export.umd.js', 'devextreme-angular/common/grids': 'bundles:devextreme-angular/devextreme-angular-common-grids.umd.js', 'devextreme-angular/common': 'bundles:devextreme-angular/devextreme-angular-common.umd.js', 'devextreme-angular/http': 'bundles:devextreme-angular/devextreme-angular-http.umd.js', 'devextreme-angular/core/tokens': 'bundles:devextreme-angular/devextreme-angular-core-tokens.umd.js', ...componentNames.reduce((acc, name) => { acc[`devextreme-angular/ui/${name}`] = `bundles:devextreme-angular/devextreme-angular-ui-${name}.umd.js`; acc[`devextreme-angular/ui/${name}/nested`] = `bundles:devextreme-angular/devextreme-angular-ui-${name}-nested.umd.js`; return acc; }, {}), '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', 'tslib': 'npm:tslib/tslib.js', 'rxjs': 'npm:rxjs@7.5.3/dist/bundles/rxjs.umd.js', 'rxjs/operators': 'npm:rxjs@7.5.3/dist/cjs/operators/index.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', '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', // Prettier 'prettier/standalone': 'npm:prettier@2.8.8/standalone.js', 'prettier/parser-html': 'npm:prettier@2.8.8/parser-html.js', 'zone.js': 'npm:zone.js@0.15.1/bundles/zone.umd.js', }, packages: { 'app': { main: './app.component.ts', defaultExtension: 'ts', }, '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', }, 'rxjs': { defaultExtension: 'js', }, 'rxjs/operators': { defaultExtension: 'js', }, }, packageConfigPaths: [ 'npm:@devextreme/*/package.json', 'npm:rxjs@7.5.3/package.json', 'npm:rxjs@7.5.3/operators/package.json', 'npm:devexpress-diagram@2.2.29/package.json', 'npm:devexpress-gantt@4.1.69/package.json', ], }; window.process = { env: { NODE_ENV: 'production', }, }; System.config(window.config); // eslint-disable-next-line no-console // System.import('@angular/compiler').catch(console.error.bind(console)); // 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" }, "@angular/core": { "version": "21.2.17" }, "core-js": { "version": "2.6.12" }, "typescript": { "version": "5.9.3" }, "zone.js": { "version": "0.15.1" } };
<!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" 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 src="https://cdn.jsdelivr.net/npm/core-js@2.6.12/client/shim.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/zone.js@0.15.1/bundles/zone.umd.js"></script> <script src="https://cdn.jsdelivr.net/npm/reflect-metadata@0.1.13/Reflect.js"></script> <script src="https://cdn.jsdelivr.net/npm/systemjs@0.21.3/dist/system.js"></script> <script src="config.js"></script> <script> System.import("app").catch(console.error.bind(console)); </script> </head> <body class="dx-viewport"> <div class="demo-container"> <demo-app>Loading...</demo-app> </div> </body> </html>
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: 'demo-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<void> { const params = { messages, model: AzureOpenAIConfig.deployment, max_completion_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; } } }

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.