dotnet / dotnet/aspnetcore

[Blazor] Library for handling component UI in AI applications

Open
#66,178 1 comment 6 reactions 0 assignees View on GitHub
api-proposal area-blazor
Dominant language
C#
Stars
38.4k
Forks
10.9k
Avg merge
2d 6h
Merged PRs (30d)
290

Description

## Summary

`Microsoft.AspNetCore.Components.AI` is a Blazor component library that transforms `IChatClient` LLM streams into interactive chat UIs. It solves the gap between the raw `Microsoft.Extensions.AI` abstractions (flat `ChatResponseUpdate` streams) and what applications actually need: structured, renderable, interactive content blocks displayed inside ready-to-use chat layouts.

The library is organized into three layers — engine, core components, and shells — each independently usable. A pipeline-based content transformation system routes heterogeneous LLM output (text, tool calls, reasoning, media) into typed `ContentBlock` objects that Blazor components render with streaming updates.

## Motivation

Building a chat UI over an LLM today requires solving several problems simultaneously:

- **Streaming transformation**: LLM responses arrive as a flat stream of `ChatResponseUpdate` objects containing heterogeneous `AIContent` items (text, tool calls, reasoning, media). To render incrementally, you need to classify, accumulate, and structure them into blocks the UI understands.
- **Interactive tool workflows**: Some tool calls require user approval or produce client-side effects. The conversation must pause, collect input, and resume — potentially across multiple round-trips.
- **State extraction**: Applications need to derive structured state from LLM responses (e.g., a shopping cart, a form draft) and react when it changes.
- **Render mode compatibility**: Blazor apps run in Static SSR, Interactive Server, WebAssembly, and Auto modes. A chat UI must work across all of them — including form-based input in SSR.
- **Customization**: Every application renders tool calls, messages, and layouts differently. The library must be extensible at every level without forking.

No existing Blazor library provides all of these together. Developers end up writing ad-hoc streaming parsers, hand-rolled tool call loops, and copy-pasted chat layouts for every project.

## Goals

- Provide a pipeline-based content transformation system that converts `IChatClient` streams into typed, observable `ContentBlock` objects.
- Provide composable Blazor components (`AgentBoundary`, `MessageList`, `MessageInput`, `BlockRenderer`) for building custom chat UIs.
- Provide ready-to-use shell layouts (`ChatPage`, `ChatBubble`, `ChatDrawer`) for the most common chat scenarios.
- Support interactive tool workflows: backend tool invocation, user approval gates, and client-side UI actions.
- Support Static SSR, Interactive Server, WebAssembly, and Auto render modes.
- Provide a CSS custom property-based theming system.
- Enable compile-time source generation of typed tool block handlers.
- Provide an extensible conversation persistence interface (`IConversationThread`).
- Support multimodal content: text, images, audio, video, and file attachments.
- Enable structured state extraction from LLM responses via `UIAgent`.

## Non-goals

- **Not a chat client**: The library uses `IChatClient` from `Microsoft.Extensions.AI`. Any provider (OpenAI, Azure, Ollama, etc.) works. The library does not implement LLM communication.
- **Not a markdown renderer**: The library parses text into a `RichTextNode` AST but does not ship HTML markdown rendering. Applications provide custom renderers for the AST nodes.
- **Not an agent framework**: The library provides the UI layer. It does not define agent architectures, planning, memory, or multi-agent orchestration.
- **Not a persistence layer**: `IConversationThread` defines the contract for conversation history. Storage implementations (Redis, Cosmos DB, etc.) are the application's responsibility.
- **Automatic SSR circuit hibernation**: The library does not automatically save/restore interactive sessions across circuit disconnections.

## Detailed design

### Layered architecture

The library follows a strict three-layer architecture where each layer depends only on the one below it:

```mermaid
graph TB
subgraph "Shell Layer"
CP[ChatPage]
CB[ChatBubble]
CD[ChatDrawer]
end
subgraph "Core Component Layer"
AB[AgentBoundary]
ML[MessageList]
MI[MessageInput]
BR["BlockRenderer<T>"]
SL[SuggestionList]
end
subgraph "Engine Layer"
UA[UIAgent / UIAgent<TState>]
AC[AgentContext]
BMP[BlockMappingPipeline]
CBL[ContentBlock hierarchy]
TH[Thread / IConversationThread]
end

CP --> AB
CP --> ML
CP --> MI
CP --> SL
CB --> AB
CB --> ML
CB --> MI
CD --> AB
CD --> ML
CD --> MI

AB --> AC
ML --> AC
MI --> AC

AC --> UA
UA --> BMP
UA --> TH
BMP --> CBL
```

**Engine layer** — No Blazor dependency. Manages conversation state, streams LLM responses, transforms content through the pipeline, and tracks turns. Can be used standalone for testing or non-UI scenarios.

**Core component layer** — Blazor components that wire the engine to the render tree. `AgentBoundary` creates and cascades `AgentContext`; `MessageList` subscribes to turns; `MessageInput` collects user input; `BlockRenderer` enables custom rendering.

**Shell layer** — Full-page, floating bubble, and side drawer layouts that compose core components into turnkey chat UIs.

### Pipeline-based content transformation

The central abstraction is the `BlockMappingPipeline` — a two-phase content routing system that transforms `ChatResponseUpdate` streams into `ContentBlock` objects.

```mermaid
flowchart LR
subgraph "IChatClient"
S["GetStreamingResponseAsync()"]
end
subgraph "BlockMappingPipeline"
direction TB
P1["Phase 1: Active stack
(newest-first)"]
P2["Phase 2: Inactive handlers
(registration order)"]
P1 -->|unhandled| P2
end
subgraph "Output"
CB1["ContentBlock (emit)"]
CB2["ContentBlock (update)"]
CB3["ContentBlock (complete)"]
end

S -->|"ChatResponseUpdate"| P1
P1 -->|"Update/Complete"| CB2
P1 -->|"Complete"| CB3
P2 -->|"Emit"| CB1
```

**Phase 1 — Active blocks**: Handlers that have already emitted a block get first chance to update or complete it. This ensures streaming continuity (e.g., text accumulates into the same `RichContentBlock` across multiple updates).

**Phase 2 — Inactive handlers**: If Phase 1 doesn't consume all content items, inactive handlers try to claim them. The first handler that returns `Emit` enters the active stack with its new block.

Handlers are stateful — each maintains a typed state object (`TState`) across the lifetime of its block. This makes streaming accumulation natural (append text, pair function call with result) without external state tracking.

### Block model

The `ContentBlock` hierarchy defines the UI contract between the engine and components:

```mermaid
classDiagram
class ContentBlock {
+string Id
+LifecycleState LifecycleState
+ChatRole Role
+string AuthorName
+OnChanged(Action) : IDisposable
}
class RichContentBlock {
+string RawText
+RichTextNode Content
+AppendText(string)
}
class FunctionInvocationContentBlock {
+FunctionCallContent Call
+FunctionResultContent Result
+string ToolName
}
class FunctionApprovalBlock {
+ApprovalStatus Status
+Approve()
+Reject(string)
}
class UIActionBlock {
+InvokeAsync()
}
class ReasoningContentBlock {
+string Text
+bool IsEncrypted
}
class MediaContentBlock {
+IReadOnlyList~DataContent~ Items
}
class ActivityContentBlock {
+string ActivityType
+JsonElement Content
}

ContentBlock <|-- RichContentBlock
ContentBlock <|-- FunctionInvocationContentBlock
ContentBlock <|-- ReasoningContentBlock
ContentBlock <|-- MediaContentBlock
ContentBlock <|-- ActivityContentBlock
FunctionInvocationContentBlock <|-- InteractiveFunctionBlock
InteractiveFunctionBlock <|-- FunctionApprovalBlock
InteractiveFunctionBlock <|-- UIActionBlock
```

Each block has an `Id`, lifecycle state (`Pending → Active → Inactive`), `Role` (user/assistant), and an observable change notification (`OnChanged`) that Blazor components subscribe to for incremental re-rendering.

### Conversation lifecycle

The `AgentContext` manages the conversation as a state machine:

```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Streaming : SendMessageAsync()
Streaming --> AwaitingInput : interactive blocks found
Streaming --> Idle : stream complete
Streaming --> Error : exception
AwaitingInput --> Streaming : user acts
(approve/reject/invoke)
Error --> Streaming : RetryAsync()
Error --> Idle : CancelAsync()
```

The interactive block loop handles multi-turn tool workflows automatically:

```mermaid
sequenceDiagram
participant User
participant AgentContext
participant UIAgent
participant IChatClient
participant Pipeline

User->>AgentContext: SendMessageAsync("What's the weather?")
AgentContext->>UIAgent: SendMessageAsync()
UIAgent->>IChatClient: GetStreamingResponseAsync()
IChatClient-->>Pipeline: ChatResponseUpdate (tool call)
Pipeline-->>AgentContext: FunctionApprovalBlock emitted
Note over AgentContext: Status = AwaitingInput
User->>AgentContext: block.Approve()
Note over AgentContext: Status = Streaming
AgentContext->>UIAgent: SendMessageAsync(continuation)
UIAgent->>IChatClient: GetStreamingResponseAsync()
IChatClient-->>Pipeline: ChatResponseUpdate (text)
Pipeline-->>AgentContext: RichContentBlock emitted
Note over AgentContext: Status = Idle
```

### Cascading value architecture

The component tree uses cascading values to share state without prop-drilling:

```mermaid
graph TD
AB["AgentBoundary"]
CV1["CascadingValue<AgentContext>
(IsFixed=true)"]
CV2["CascadingValue<AgentState<T>>
(IsFixed=true, optional)"]
ML["MessageList"]
CV3["CascadingValue<MessageListContext>"]
BR["BlockRenderer<T>"]
CTR["ConversationTurnRenderer"]
BC["BlockContainer"]

AB --> CV1
CV1 --> CV2
CV2 --> ML
ML --> CV3
CV3 --> BR
CV3 --> CTR
CTR --> BC
```

All cascading values are `IsFixed=true` — they don't change after creation. When the `Agent` parameter changes, `AgentBoundary` uses region keying (`OpenRegion`) to tear down and rebuild the entire subtree.

### Extension points

| Extension Point | Mechanism | What It Does |
|---|---|---|
| Custom content handlers | `UIAgentOptions.AddBlockHandler()` | Transform LLM content into custom block types |
| Custom block renderers | `>` child content | Override rendering for any block type |
| State extraction | `UIAgentOptions.StateMapper` | Extract typed state from LLM responses |
| UI actions | `UIAgentOptions.RegisterUIAction()` | Register client-side tool implementations |
| Conversation persistence | `IConversationThread` | Plug in storage for conversation history |
| Source-generated handlers | `[ToolBlock]` attribute | Generate typed handlers at compile time |
| CSS theming | Override `--sc-ai-*` CSS custom properties | Customize appearance without C# changes |

### SSR and interactive dual-mode support

The library supports both Static SSR and interactive render modes for the same UI:

```mermaid
flowchart TD
subgraph "Interactive Mode"
MI["MessageInput (onclick)"]
AB1["AgentBoundary"]
MI -->|"AgentContext.SendMessageAsync()"| AB1
end
subgraph "Static SSR Mode"
FMI["FormMessageInput (form POST)"]
AFB["AgentFormBoundary"]
FMI -->|"<form method=post>"| AFB
AFB -->|"SupplyParameterFromForm"| AFB
AFB -->|"AgentContext.SendMessageAsync()"| AFB
end
```

Approval buttons render both `` (SSR) and `onclick` handlers (interactive). Whichever fires first wins.

### Data flow — end to end

```mermaid
flowchart TD
A["User types message"] --> B["MessageInput builds ChatMessage
(text + optional file attachments)"]
B --> C["AgentContext.SendMessageAsync()"]
C --> D["UIAgent.SendMessageAsync()"]
D --> E["User message → pipeline → user ContentBlocks"]
D --> F["IChatClient.GetStreamingResponseAsync()"]
F --> G["Each ChatResponseUpdate"]
G --> H{"StateMapper
configured?"}
H -->|yes| I["Extract typed state"]
H -->|no| J["Route through BlockMappingPipeline"]
I --> J
J --> K["Emit / Update / Complete ContentBlocks"]
K --> L{"Interactive blocks
pending?"}
L -->|yes| M["Status = AwaitingInput
Await user action"]
M --> N["Build continuation message
Loop back to LLM"]
N --> F
L -->|no| O["Status = Idle"]
O --> P["MessageList re-renders with new turn"]
```

## Risks

- **Performance with long conversations**: Each turn adds `ConversationTurnRenderer` and `BlockContainer` instances to the render tree. Very long conversations (hundreds of turns) may cause rendering overhead. Mitigated by using non-component render objects for turns and blocks, and by adding `Virtualize` support to `MessageList` so only visible turns are rendered.
- **Pipeline handler ordering conflicts**: Custom handlers run before built-in ones. A poorly written custom handler that claims too much content can starve built-in handlers. Mitigated by documenting the handler pipeline order and providing `Pass` as the default result.
- **SSR form state limitations**: In Static SSR, conversation history lives only in the thread. If no `IConversationThread` is configured, SSR conversations can't maintain history across form posts. SSR support is an opt-in enhancement for applications that need it — interactive render modes are the primary target and work without any thread configuration.

## Drawbacks

- CSS class names are hardcoded with the `sc-ai-` prefix. Applications cannot change the prefix without overriding the CSS.
- The source generator only supports `FunctionInvocationContentBlock` as the base class. We could generalize the `[ToolBlock]` attribute to support other `ContentBlock` subtypes in the future.

## Considered alternatives

### Direct component rendering without a pipeline

A simpler approach would be to have components inspect `ChatResponseUpdate` directly and render content inline. This was rejected because:
- Streaming accumulation (text building over multiple updates) would need to be handled by each component independently.
- Tool call pairing (matching `FunctionCallContent` with `FunctionResultContent` by `CallId`) would be duplicated.
- Adding new content types would require modifying rendering components instead of adding a handler.

### Blazor component parameters for styling instead of CSS custom properties

Using `[Parameter]` properties for colors, fonts, and spacing was considered. Rejected because:
- CSS custom properties cascade naturally to child elements.
- Media queries (dark mode, responsive) work with CSS custom properties but not with C# parameters.
- No C# recompilation needed for style changes.

### Single `ChatComponent` instead of three-layer architecture

A monolithic component that handles everything was considered. Rejected because:
- Applications need different compositions (some want input at the top, custom layouts, drawer vs. page).
- Testing a monolith requires the full stack; separated layers can be tested independently.
- The engine layer can be used without Blazor for unit testing and non-UI scenarios.

## Potential APIs and usage scenarios

### Minimal interactive chat page

```razor
@page "/chat"
@using Microsoft.AspNetCore.Components.AI

@code {
private UIAgent _agent = default!;

[Inject] public IChatClient ChatClient { get; set; } = default!;

protected override void OnInitialized()
{
_agent = new UIAgent(ChatClient);
}
}
```

### Custom tool block rendering

```razor

Context="block"
When="b => b.ToolName == \"getWeather\"">


Weather for @block.Arguments["city"]


@block.Result?.Result



```

### Typed state extraction

```csharp
var agent = new UIAgent(chatClient, options =>
{
options.StateMapper = (context) =>
{
// Inspect the LLM response for structured state
foreach (var content in context.UnhandledContents)
{
if (content is FunctionCallContent fc && fc.Name == "updateCart")
{
var cart = JsonSerializer.Deserialize(fc.Arguments["cart"]);
context.SetState(cart);
context.MarkHandled(content);
}
}
};
});
```

### SSR-compatible form-based chat

```razor
@page "/chat-ssr"


```

### Chat with conversation persistence

```csharp
var thread = new MyRedisConversationThread(threadId);
var agent = new UIAgent(chatClient, options =>
{
options.Thread = thread;
});
// On page load, AgentBoundary calls RestoreAsync() automatically
```

### Source-generated tool block

```csharp
[ToolBlock("getWeather")]
partial class WeatherBlock : FunctionInvocationContentBlock
{
[ToolParameter] public string City { get; set; }
[ToolResult] public WeatherData Forecast { get; set; }
}

// Registration:
var agent = new UIAgent(chatClient, options =>
{
options.AddGeneratedToolBlocks(); // registers all [ToolBlock] handlers
});
```

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.