Skip to content

[Blazor] Library for handling component UI in AI applications #66178

@javiercn

Description

@javiercn

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<TState>.

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:

graph TB
    subgraph "Shell Layer"
        CP[ChatPage]
        CB[ChatBubble]
        CD[ChatDrawer]
    end
    subgraph "Core Component Layer"
        AB[AgentBoundary]
        ML[MessageList]
        MI[MessageInput]
        BR["BlockRenderer&lt;T&gt;"]
        SL[SuggestionList]
    end
    subgraph "Engine Layer"
        UA[UIAgent / UIAgent&lt;TState&gt;]
        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
Loading

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<T> 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.

flowchart LR
    subgraph "IChatClient"
        S["GetStreamingResponseAsync()"]
    end
    subgraph "BlockMappingPipeline"
        direction TB
        P1["Phase 1: Active stack<br>(newest-first)"]
        P2["Phase 2: Inactive handlers<br>(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
Loading

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:

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
Loading

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:

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

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

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
Loading

Cascading value architecture

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

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

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

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<TState>() Transform LLM content into custom block types
Custom block renderers <BlockRenderer<TBlock>> 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:

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 -->|"&lt;form method=post&gt;"| AFB
        AFB -->|"SupplyParameterFromForm"| AFB
        AFB -->|"AgentContext.SendMessageAsync()"| AFB
    end
Loading

Approval buttons render both <button type="submit"> (SSR) and onclick handlers (interactive). Whichever fires first wins.

Data flow — end to end

flowchart TD
    A["User types message"] --> B["MessageInput builds ChatMessage<br>(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<br>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<br>pending?"}
    L -->|yes| M["Status = AwaitingInput<br>Await user action"]
    M --> N["Build continuation message<br>Loop back to LLM"]
    N --> F
    L -->|no| O["Status = Idle"]
    O --> P["MessageList re-renders with new turn"]
Loading

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

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

<ChatPage Agent="@_agent" />

@code {
    private UIAgent _agent = default!;

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

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

Custom tool block rendering

<ChatPage Agent="@_agent">
    <BlockRenderer<FunctionInvocationContentBlock> Context="block"
        When="b => b.ToolName == \"getWeather\"">
        <div class="weather-card">
            <h3>Weather for @block.Arguments["city"]</h3>
            <p>@block.Result?.Result</p>
        </div>
    </BlockRenderer>
</ChatPage>

Typed state extraction

var agent = new UIAgent<ShoppingCart>(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<ShoppingCart>(fc.Arguments["cart"]);
                context.SetState(cart);
                context.MarkHandled(content);
            }
        }
    };
});

SSR-compatible form-based chat

@page "/chat-ssr"

<AgentFormBoundary Agent="@_agent">
    <MessageList />
    <FormMessageInput Placeholder="Ask a question..." />
</AgentFormBoundary>

Chat with conversation persistence

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

[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
});

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions