@model DevExtreme.NETCore.Demos.ViewModels.ChatViewModel
@section ExternalDependencies {
<script type="module">
import { AzureOpenAI } from "https://esm.sh/openai@4.73.1";
import { unified } from "https://esm.sh/unified@11?bundle";
import remarkParse from "https://esm.sh/remark-parse@11?bundle";
import remarkRehype from "https://esm.sh/remark-rehype@11?bundle";
import rehypeStringify from "https://esm.sh/rehype-stringify@10?bundle";
import rehypeMinifyWhitespace from "https://esm.sh/rehype-minify-whitespace@6?bundle";
window.AzureOpenAI = AzureOpenAI;
window.unified = unified;
window.remarkParse = remarkParse;
window.remarkRehype = remarkRehype;
window.rehypeMinifyWhitespace = rehypeMinifyWhitespace;
window.rehypeStringify = rehypeStringify;
</script>
}
@(Html.DevExtreme().Chat()
.ID("dx-ai-chat")
.Height(710)
.User(user => user.Id(Model.CurrentUser.Id))
.DataSource(new JS("dataSource"))
.ReloadOnChange(false)
.ShowAvatar(false)
.ShowDayHeaders(false)
.SpeechToTextEnabled(true)
.SendButtonOptions(opts => opts.Action(SendButtonAction.Send))
.OnMessageEntered("onMessageEntered")
.OnInitialized("chatInitialized")
.MessageTemplate(@<text>
<div class="chat-messagebubble-text">
<%= convertToHtml(message.text) %>
</div>
</text>)
.EmptyViewTemplate(new JS("emptyViewTemplate"))
)
<script>
var instance;
var store = [];
var messages = [];
var 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...",
},
});
var user = { id: "@Model.CurrentUser.Id" };
var assistant = { id: "assistant", name: "AI Assistant" };
var deployment = "demo-mini";
var apiVersion = "2024-02-01";
var endpoint = "https://public-api.devexpress.com/demo-openai";
var apiKey = "DEMO";
var ALERT_TIMEOUT = 1000 * 60;
var 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?",
},
];
var chatService;
function chatInitialized({ component }) {
instance = component;
}
$(() => {
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_completion_tokens: 1000,
temperature: 0.7,
stream: true,
};
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) {
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;
}
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 $("<button>")
.attr({ type: "button", tabindex: 0 })
.addClass("chat-suggestion-card")
.append(
$("<div>").addClass("chat-suggestion-card-title").text(card.title),
$("<div>").addClass("chat-suggestion-card-prompt").text(card.description),
)
.on("click", () => {
sendSuggestion(card.prompt);
});
}
function emptyViewTemplate(data, element) {
const $suggestionCards = $("<div>").addClass("chat-suggestion-cards");
suggestionCards.forEach((card) => {
$suggestionCards.append(createSuggestionCard(card));
});
$("<div>")
.append(
$("<div>").addClass("dx-chat-messagelist-empty-message").text(data.texts.message),
$("<div>").addClass("dx-chat-messagelist-empty-prompt").text(data.texts.prompt),
$suggestionCards,
)
.appendTo(element);
}
function onMessageEntered({ message }) {
dataSource.store().push([{ type: "insert", data: { id: Date.now(), ...message } }]);
if (!instance.option("alerts").length) {
processMessageSending(message);
}
}
</script>
using System;
using System.Linq;
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using DevExtreme.NETCore.Demos.Models.Chat;
using DevExtreme.NETCore.Demos.Models.SampleData;
using DevExtreme.NETCore.Demos.ViewModels;
namespace DevExtreme.NETCore.Demos.Controllers {
public class ChatController : Controller {
public ActionResult MessageStreaming() {
return View(new ChatViewModel {
CurrentUser = SampleData.CurrentUser,
});
}
}
}
.demo-container {
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;
}
.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;
}