You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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<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
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:
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<AgentContext><br>(IsFixed=true)"]
CV2["CascadingValue<AgentState<T>><br>(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
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 -->|"<form method=post>"| 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.
varagent=newUIAgent<ShoppingCart>(chatClient, options =>{options.StateMapper=(context)=>{// Inspect the LLM response for structured stateforeach(varcontentincontext.UnhandledContents){if(contentisFunctionCallContentfc&&fc.Name=="updateCart"){varcart=JsonSerializer.Deserialize<ShoppingCart>(fc.Arguments["cart"]);context.SetState(cart);context.MarkHandled(content);}}};});
SSR-compatible form-based chat
@page "/chat-ssr"
<AgentFormBoundaryAgent="@_agent">
<MessageList />
<FormMessageInputPlaceholder="Ask a question..." />
</AgentFormBoundary>
Chat with conversation persistence
varthread=newMyRedisConversationThread(threadId);varagent=newUIAgent(chatClient, options =>{options.Thread=thread;});// On page load, AgentBoundary calls RestoreAsync() automatically
Source-generated tool block
[ToolBlock("getWeather")]partialclassWeatherBlock:FunctionInvocationContentBlock{[ToolParameter]publicstringCity{get;set;}[ToolResult]publicWeatherDataForecast{get;set;}}// Registration:varagent=newUIAgent(chatClient, options =>{options.AddGeneratedToolBlocks();// registers all [ToolBlock] handlers});
Summary
Microsoft.AspNetCore.Components.AIis a Blazor component library that transformsIChatClientLLM streams into interactive chat UIs. It solves the gap between the rawMicrosoft.Extensions.AIabstractions (flatChatResponseUpdatestreams) 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
ContentBlockobjects that Blazor components render with streaming updates.Motivation
Building a chat UI over an LLM today requires solving several problems simultaneously:
ChatResponseUpdateobjects containing heterogeneousAIContentitems (text, tool calls, reasoning, media). To render incrementally, you need to classify, accumulate, and structure them into blocks the UI understands.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
IChatClientstreams into typed, observableContentBlockobjects.AgentBoundary,MessageList,MessageInput,BlockRenderer) for building custom chat UIs.ChatPage,ChatBubble,ChatDrawer) for the most common chat scenarios.IConversationThread).UIAgent<TState>.Non-goals
IChatClientfromMicrosoft.Extensions.AI. Any provider (OpenAI, Azure, Ollama, etc.) works. The library does not implement LLM communication.RichTextNodeAST but does not ship HTML markdown rendering. Applications provide custom renderers for the AST nodes.IConversationThreaddefines the contract for conversation history. Storage implementations (Redis, Cosmos DB, etc.) are the application's responsibility.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<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 --> CBLEngine 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.
AgentBoundarycreates and cascadesAgentContext;MessageListsubscribes to turns;MessageInputcollects 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 transformsChatResponseUpdatestreams intoContentBlockobjects.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"| CB1Phase 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
RichContentBlockacross 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
Emitenters 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
ContentBlockhierarchy 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 <|-- UIActionBlockEach 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
AgentContextmanages 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()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 = IdleCascading value architecture
The component tree uses cascading values to share state without prop-drilling:
graph TD AB["AgentBoundary"] CV1["CascadingValue<AgentContext><br>(IsFixed=true)"] CV2["CascadingValue<AgentState<T>><br>(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 --> BCAll cascading values are
IsFixed=true— they don't change after creation. When theAgentparameter changes,AgentBoundaryuses region keying (OpenRegion) to tear down and rebuild the entire subtree.Extension points
UIAgentOptions.AddBlockHandler<TState>()<BlockRenderer<TBlock>>child contentUIAgentOptions.StateMapperUIAgentOptions.RegisterUIAction()IConversationThread[ToolBlock]attribute--sc-ai-*CSS custom propertiesSSR 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 -->|"<form method=post>"| AFB AFB -->|"SupplyParameterFromForm"| AFB AFB -->|"AgentContext.SendMessageAsync()"| AFB endApproval buttons render both
<button type="submit">(SSR) andonclickhandlers (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"]Risks
ConversationTurnRendererandBlockContainerinstances 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 addingVirtualizesupport toMessageListso only visible turns are rendered.Passas the default result.IConversationThreadis 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
sc-ai-prefix. Applications cannot change the prefix without overriding the CSS.FunctionInvocationContentBlockas the base class. We could generalize the[ToolBlock]attribute to support otherContentBlocksubtypes in the future.Considered alternatives
Direct component rendering without a pipeline
A simpler approach would be to have components inspect
ChatResponseUpdatedirectly and render content inline. This was rejected because:FunctionCallContentwithFunctionResultContentbyCallId) would be duplicated.Blazor component parameters for styling instead of CSS custom properties
Using
[Parameter]properties for colors, fonts, and spacing was considered. Rejected because:Single
ChatComponentinstead of three-layer architectureA monolithic component that handles everything was considered. Rejected because:
Potential APIs and usage scenarios
Minimal interactive chat page
Custom tool block rendering
Typed state extraction
SSR-compatible form-based chat
Chat with conversation persistence
Source-generated tool block