AI Chat
An interactive chat interface demonstrating AI-style message streaming and structured responses.
Features
- Streaming message display with word-by-word animation
- Structured response parsing (thinking, search, text blocks)
- ComposableTextArea for the input with header/footer slots
- Card, Button, Badge, Accordion, and other ShadcnBlazor primitives
Preview
This sample showcases a chat UI built with ShadcnBlazor components. It simulates streaming AI responses with support for structured content blocks (thinking, search, text). Type a message and press send to try it.
Components
The sample is built from several reusable pieces. Understanding each helps when adapting it to your own chat or AI integration.
ChatInput
The input bar at the bottom. It wraps ComposableTextArea with Header and Footer slots. The header holds an "Add Context" button; the footer has toggles (extended thinking, auto, sources) and a send button. It exposes SubmitPrompt as an EventCallback<string> so the parent can handle submission.
UserChatMessageView
Renders user messages in a right-aligned bubble with primary styling. It receives a UserChatMessage and displays the raw text via GetRawContent().
AiChatMessageView
Renders AI responses as a sequence of MessageComponent views. It switches on component type: ThinkingMessageComponent (collapsible accordion with markdown), WebSearchMessageComponent (expandable list of URLs), and TextMessageComponent (markdown-rendered text). Each view uses Markdig for markdown and optional syntax highlighting.
Blazor is an excellent choice for .NET developers. It offers server-side and WebAssembly hosting models.
Considering ecosystem maturity, performance, and developer experience...
Message component views
ThinkingMessageComponentView, TextMessageComponentView, and WebSearchMessageComponentView each render one block type. Thinking and WebSearch use Accordion for expand/collapse. Text is plain markdown. All support streaming updates as content arrives.
The user is asking about Blazor. I should consider ecosystem maturity, performance, and developer experience.
Blazor is a great choice. It offers:
- Server-side rendering
- WebAssembly for client-side
- Full .NET ecosystem
Internals
If you want to adapt this for a real API or different streaming format, here's how the pieces fit together.
Models
ChatMessage is the base; UserChatMessage and AiChatMessage extend it. AiChatMessage has a Components list of ThinkingMessageComponent, WebSearchMessageComponent, or TextMessageComponent. Add new component types and corresponding views if your API returns additional block types.
Expected stream format
IChatService yields a stream of string chunks (tokens). The parser expects XML-like tags to structure the response: <thinking> for internal reasoning, <search> for URLs (one per line), and <text> for user-facing markdown. Tags can appear in any order. Escape literal < or > with a backslash.
<thinking>
Considering the user's question about Blazor...
</thinking>
<search>
https://learn.microsoft.com/en-us/aspnet/core/blazor
https://docs.fluentui-blazor.net
</search>
<text>
**Blazor** is a .NET framework for building interactive web UIs. It supports both Server and WebAssembly hosting.
</text>ChatOrchestrator
The central coordinator. It injects IChatService and IStreamResponseParserService. SendPromptAsync adds a UserChatMessage, creates an empty AiChatMessage, resolves the parser via streamParserService.CreateStream(responseMsg.Components), then streams chunks from IChatService. Each chunk is appended to the AI message and fed to the parser; after each chunk it raises OnStateChange so the UI re-renders.
IChatService
The contract for streaming AI responses. Implement this to plug in your own API (OpenAI, Claude, custom backend).
public interface IChatService
{
IAsyncEnumerable<string> RunStreamingAsync(
string prompt,
CancellationToken cancellationToken = default);
}ChatService is the concrete implementation: a stub that returns hardcoded streaming text with simulated delays. Replace it with a real implementation that calls your API and yields string chunks.
IStreamResponseParser
The contract for parsing streaming chunks into structured MessageComponent instances. The orchestrator resolves IStreamResponseParserService and passes the message's components into CreateStream to get a parser bound to that message.
public interface IStreamResponseParser
{
void AppendChunk(string chunk);
}
public interface IStreamResponseParserService
{
IStreamResponseParser CreateStream(IList<MessageComponent> components);
}StreamResponseParser parses XML-like tags (<thinking>, <search>, <text>) from the stream, routes content into the corresponding component types, and handles buffering and escaping. IStreamResponseParserService exposes CreateStream(components) so the orchestrator can obtain a parser per AI response.
Extending
The sample is designed to be adapted. Below are two common scenarios: connecting to a real streaming API, and handling non-streamed JSON responses.
Real streaming API
Implement IChatService to call your API. For streaming endpoints (e.g. OpenAI, Claude), use HttpClient with HttpCompletionOption.ResponseHeadersRead and read the response stream. Yield chunks as they arrive. The existing parser and UI handle the rest.
using System.Runtime.CompilerServices;
public class OpenAiChatService : IChatService
{
private readonly HttpClient _http;
public OpenAiChatService(HttpClient http) => _http = http;
public async IAsyncEnumerable<string> RunStreamingAsync(
string prompt,
[EnumeratorCancellation] CancellationToken ct = default)
{
var request = new { model = "gpt-4", messages = new[] { new { role = "user", content = prompt } } };
using var res = await _http.PostAsJsonAsync("https://api.openai.com/v1/chat/completions", request, ct);
res.EnsureSuccessStatusCode();
await using var stream = await res.Content.ReadAsStreamAsync(ct);
await foreach (var chunk in ReadStreamAsChunks(stream, ct))
yield return chunk;
}
private static async IAsyncEnumerable<string> ReadStreamAsChunks(Stream stream, [EnumeratorCancellation] CancellationToken ct)
{
using var reader = new StreamReader(stream);
var buffer = new char[256];
int read;
while ((read = await reader.ReadAsync(buffer, ct)) > 0)
yield return new string(buffer, 0, read);
}
}Non-streamed JSON
If your API returns a complete JSON response instead of a stream, you have two options. Option A: Fetch the JSON, convert it to the expected tag format, and yield it as a single chunk—the existing parser will populate the components. Option B: Implement a custom IStreamResponseParser that parses JSON and populates components directly, then yield the raw JSON as one chunk.
using System.Text;
using System.Text.Json;
// Option A: Convert JSON to tag format, yield as one chunk
public class JsonToTagChatService : IChatService
{
private readonly HttpClient _http;
public async IAsyncEnumerable<string> RunStreamingAsync(string prompt, CancellationToken ct = default)
{
var json = await _http.GetStringAsync($"/api/chat?q={Uri.EscapeDataString(prompt)}", ct);
var doc = JsonSerializer.Deserialize<ApiResponse>(json)!;
var sb = new StringBuilder();
if (!string.IsNullOrEmpty(doc.Thinking)) sb.Append($"<thinking>{doc.Thinking}</thinking>");
if (doc.Sources?.Count > 0) sb.Append("<search>\n").AppendJoin('\n', doc.Sources).Append("\n</search>");
if (!string.IsNullOrEmpty(doc.Text)) sb.Append($"<text>{doc.Text}</text>");
yield return sb.ToString();
}
private record ApiResponse(string? Thinking, List<string>? Sources, string? Text);
}
// Option B: Custom parser for JSON (register via IStreamResponseParserService)
// Use when the API returns the full JSON in one chunk.
public class JsonStreamResponseParser : IStreamResponseParser
{
private readonly IList<MessageComponent> _components;
private string _buffer = string.Empty;
private bool _parsed;
public JsonStreamResponseParser(IList<MessageComponent> components) => _components = components;
public void AppendChunk(string chunk)
{
if (_parsed) return;
_buffer += chunk;
var doc = JsonSerializer.Deserialize<ApiResponse>(_buffer);
if (doc == null) return;
_parsed = true;
if (!string.IsNullOrEmpty(doc.Thinking)) _components.Add(new ThinkingMessageComponent { Content = doc.Thinking });
if (doc.Sources?.Count > 0) _components.Add(new WebSearchMessageComponent { Content = string.Join("\n", doc.Sources) });
if (!string.IsNullOrEmpty(doc.Text)) _components.Add(new TextMessageComponent { Content = doc.Text });
}
private record ApiResponse(string? Thinking, List<string>? Sources, string? Text);
}