Compare commits

...
Author SHA1 Message Date
Javier Calvarro Nelson f9c60c29ec Improve shared state 2025-12-11 18:48:54 +01:00
Javier Calvarro Nelson fcb6ec9fd3 More UI cleanups 2025-12-11 16:10:59 +01:00
Javier Calvarro Nelson c0ce673354 More UI cleanups 2025-12-11 13:58:44 +01:00
Javier Calvarro Nelson 0c93762cb6 UI cleanups 2025-12-11 13:13:53 +01:00
Javier Calvarro Nelson c34a72c3bf Add README.md 2025-12-10 20:17:27 +01:00
Javier Calvarro Nelson 64790c4c34 Cleanup settings 2025-12-10 19:23:51 +01:00
Javier Calvarro Nelson 38f3e6a5ec Cleanup logging 2025-12-10 19:00:30 +01:00
Javier Calvarro Nelson 495c2e9a92 Cleanup 2025-12-10 18:11:50 +01:00
Javier Calvarro Nelson 99ac6fdaa5 Fix build 2025-12-10 18:09:01 +01:00
Javier Calvarro Nelson 0eef9949bf Cleanup 2025-12-10 17:08:40 +01:00
Javier Calvarro Nelson 0340531f3a Add AG-UI Blazor sample 2025-12-10 16:46:50 +01:00
151 changed files with 10021 additions and 4 deletions
+6 -1
View File
@@ -20,9 +20,14 @@
</Folder>
<Folder Name="/Samples/AGUIClientServer/">
<Project Path="samples/AGUIClientServer/AGUIClient/AGUIClient.csproj" />
<Project Path="samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj" />
<Project Path="samples/AGUIClientServer/AGUIServer/AGUIServer.csproj" />
</Folder>
<Folder Name="/Samples/AGUIDojo/">
<File Path="samples/AGUIDojo/Design.md" />
<File Path="samples/AGUIDojo/Plan.md" />
<Project Path="samples/AGUIDojo/AGUIDojoClient/AGUIDojoClient.csproj" />
<Project Path="samples/AGUIDojo/AGUIDojoServer/AGUIDojoServer.csproj" />
</Folder>
<Folder Name="/Samples/AzureFunctions/">
<File Path="samples/AzureFunctions/.editorconfig" />
<File Path="samples/AzureFunctions/README.md" />
+1
View File
@@ -0,0 +1 @@
.playwright-mcp/
+11
View File
@@ -0,0 +1,11 @@
<Solution>
<Project Path="../../src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj" />
<Project Path="../../src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj" />
<Project Path="../../src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<Project Path="../../src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
<Project Path="../../src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
<Project Path="../../src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
<Project Path="../../src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj" />
<Project Path="AGUIDojoClient/AGUIDojoClient.csproj" />
<Project Path="AGUIDojoServer/AGUIDojoServer.csproj" />
</Solution>
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks></TargetFrameworks>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,141 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.AspNetCore.Components.Rendering;
namespace Microsoft.AspNetCore.Components.AI;
[CascadingTypeParameter(nameof(TState))]
public partial class AgentStateBoundary<TState> : IComponent, IDisposable
{
private RenderHandle _renderHandle;
private RenderFragment? _renderWithState;
private protected AgentBoundaryContext<TState>? _context;
private AgentThread? _currentThread;
private AIAgent? _currentAgent;
private bool _disposed;
[EditorRequired][Parameter] public AIAgent? Agent { get; set; }
[Parameter] public AgentThread? Thread { get; set; }
[Parameter] public TState? State { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>
/// Callback invoked when the boundary context is created or recreated.
/// Use this to register tools or perform other initialization.
/// </summary>
[Parameter] public EventCallback<IAgentBoundaryContext> OnContextCreated { get; set; }
public void Attach(RenderHandle renderHandle)
{
this._renderHandle = renderHandle;
this._renderWithState = this.RenderWithState;
}
// Agent boundary renders once when it receives the initial set of parameters
// and only ever renders again if the Agent, Thread, or State changes.
// Re-rendering the agent boundary will dispose the existing context and create
// a new one.
public Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
if (this.Agent == null)
{
throw new InvalidOperationException("AgentBoundary requires an Agent parameter.");
}
// Use Thread parameter if provided, otherwise keep existing thread or create new one
var thread = this.Thread ?? this._currentThread ?? this.Agent.GetNewThread();
TState? currentState = this._context != null ? this._context.CurrentState : default;
bool agentChanged = this.Agent != this._currentAgent;
bool threadChanged = thread != this._currentThread;
bool stateChanged = !EqualityComparer<TState?>.Default.Equals(this.State, currentState);
bool refresh = agentChanged || threadChanged || stateChanged;
if (refresh)
{
this._context?.Dispose();
this._context = null;
this._currentThread = null;
}
this._currentAgent = this.Agent;
this._currentThread = thread;
// Agent is validated non-null above, thread is either from Thread parameter or GetNewThread()
bool isNewContext = this._context == null;
this._context ??= new AgentBoundaryContext<TState>(this.Agent!, thread!);
this._context.CurrentState = this.State;
// Invoke the context created callback if we created a new context
if (isNewContext && this.OnContextCreated.HasDelegate)
{
_ = this.OnContextCreated.InvokeAsync(this._context);
}
if (refresh)
{
this.Render();
}
return Task.CompletedTask;
}
private void Render()
{
this._renderHandle.Render(builder =>
{
builder.OpenComponent<CascadingValue<AgentBoundaryContext<TState>>>(0);
builder.AddComponentParameter(1, "Value", this._context);
builder.AddComponentParameter(2, "IsFixed", true);
builder.AddComponentParameter(3, "ChildContent", this._renderWithState);
builder.CloseComponent();
});
}
protected virtual void RenderWithState(RenderTreeBuilder builder)
{
builder.OpenComponent<CascadingValue<TState?>>(0);
builder.AddComponentParameter(1, "Value", this.State);
builder.AddComponentParameter(2, "IsFixed", false);
builder.AddComponentParameter(3, "ChildContent", this.ChildContent);
builder.CloseComponent();
}
public void Dispose()
{
this.Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!this._disposed)
{
if (disposing)
{
this._context?.Dispose();
}
this._disposed = true;
}
}
}
public class AgentBoundary : AgentStateBoundary<object?>
{
protected override void RenderWithState(RenderTreeBuilder builder)
{
builder.OpenComponent<CascadingValue<IAgentBoundaryContext>>(0);
builder.AddComponentParameter(1, "Value", this._context);
builder.AddComponentParameter(2, "IsFixed", true);
builder.AddComponentParameter(3, "ChildContent", this.ChildContent);
builder.CloseComponent();
}
}
@@ -0,0 +1,411 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
public sealed partial class AgentBoundaryContext<TState> : IAgentBoundaryContext, IDisposable
{
private readonly AIAgent _agent;
private readonly AgentThread _thread;
private readonly CancellationTokenSource _cancellationTokenSource;
private readonly List<ChatMessage> _messages = [];
private readonly List<ChatMessage> _pendingMessages = [];
private readonly List<Action> _messageChangeSubscribers = [];
private readonly List<Action> _responseUpdateSubscribers = [];
private readonly List<Action> _runStatusSubscribers = [];
private readonly List<AITool> _tools = [];
private readonly Dictionary<string, TaskCompletionSource<object>> _pendingResponses = [];
public CancellationToken CancellationToken => this._cancellationTokenSource.Token;
public IReadOnlyList<ChatMessage> CompletedMessages => this._messages.AsReadOnly();
public TState? CurrentState { get; set; }
public IReadOnlyList<ChatMessage> PendingMessages => this._pendingMessages.AsReadOnly();
public ChatMessage? CurrentMessage { get; private set; }
public ChatResponseUpdate? CurrentUpdate { get; private set; }
/// <summary>
/// Gets whether the agent is currently processing a turn.
/// </summary>
public bool IsProcessing { get; private set; }
public AgentBoundaryContext(AIAgent agent, AgentThread thread)
{
this._agent = agent;
this._thread = thread;
this._cancellationTokenSource = new CancellationTokenSource();
}
/// <summary>
/// Registers a client-side tool that will be passed to the agent during invocation.
/// These tools are sent to the server via ChatClientAgentRunOptions.ChatOptions.Tools.
/// </summary>
public void RegisterTool(AITool tool)
{
ArgumentNullException.ThrowIfNull(tool);
this._tools.Add(tool);
}
/// <summary>
/// Registers multiple client-side tools that will be passed to the agent during invocation.
/// </summary>
public void RegisterTools(params AITool[] tools)
{
foreach (var tool in tools)
{
this.RegisterTool(tool);
}
}
/// <summary>
/// Creates a pending response for the given key and returns a task that completes when ProvideResponse is called.
/// Used by frontend tools to wait for user input from UI components.
/// </summary>
/// <param name="key">A unique key to identify this pending response (e.g., function call ID).</param>
/// <returns>A task that completes with the response object when ProvideResponse is called.</returns>
public Task<object> WaitForResponse(string key)
{
var tcs = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
this._pendingResponses[key] = tcs;
return tcs.Task;
}
/// <summary>
/// Provides a response for a pending request, completing the task returned by WaitForResponse.
/// Called by UI components when user interaction is complete.
/// </summary>
/// <param name="key">The key used when calling WaitForResponse.</param>
/// <param name="response">The response object to return.</param>
/// <returns>True if the response was provided successfully, false if no pending request was found.</returns>
public bool ProvideResponse(string key, object response)
{
if (this._pendingResponses.TryGetValue(key, out var tcs))
{
this._pendingResponses.Remove(key);
tcs.TrySetResult(response);
return true;
}
return false;
}
public void Dispose()
{
this._cancellationTokenSource.Cancel();
this._cancellationTokenSource.Dispose();
GC.SuppressFinalize(this);
}
public async Task SendAsync(params ChatMessage[] userMessages)
{
// This starts a new turn. Once completed, we will add the new messages to the conversation.
this._pendingMessages.Clear();
this._pendingMessages.AddRange(userMessages);
// Mark as processing
this.IsProcessing = true;
// Notify subscribers that run status changed (processing started)
this.TriggerRunStatusChanged();
// User messages added, notify subscribers.
this.TriggerMessageChanges();
// Build run options with registered tools if any
AgentRunOptions? options = null;
if (this._tools.Count > 0)
{
options = new ChatClientAgentRunOptions(new ChatOptions
{
Tools = [.. this._tools]
});
}
// Start a turn. Collect all updates as we stream them.
await foreach (var update in this._agent.RunStreamingAsync(
userMessages,
this._thread,
options,
this._cancellationTokenSource.Token))
{
var chatUpdate = update.AsChatResponseUpdate();
this.CurrentUpdate = chatUpdate;
// Notify subscribers of the new update, this always happens before a new message is created/updated.
this.TriggerChatResponseUpdate();
// This creates or adds a new message to the pending messages as needed.
var isNewMessage = MessageHelpers.ProcessUpdate(chatUpdate, this._pendingMessages);
if (isNewMessage)
{
this.CurrentMessage = this._pendingMessages[this._pendingMessages.Count - 1];
// Finalize the previous message's content if we have 2 or more messages now.
if (this._pendingMessages.Count > userMessages.Length + 1)
{
MessageHelpers.CoalesceContent(this._pendingMessages[this._pendingMessages.Count - 2].Contents);
}
// Notify subscribers of new message
this.TriggerMessageChanges();
}
}
// Process any remaining updates to finalize the last message.
if (this._pendingMessages.Count > userMessages.Length)
{
MessageHelpers.CoalesceContent(this._pendingMessages[this._pendingMessages.Count - 1].Contents);
}
// Add the new messages to the conversation
this._messages.AddRange(this._pendingMessages);
// Finish the turn
this._pendingMessages.Clear();
this.CurrentMessage = null;
this.CurrentUpdate = null;
this.IsProcessing = false;
// Notify subscribers that run status changed (processing ended)
this.TriggerRunStatusChanged();
// Notify subscribers
this.TriggerChatResponseUpdate();
this.TriggerMessageChanges();
}
private void TriggerChatResponseUpdate()
{
// Iterate backwards to avoid issues if subscribers are removed during iteration
for (var i = this._responseUpdateSubscribers.Count - 1; i >= 0; i--)
{
this._responseUpdateSubscribers[i]();
}
}
private void TriggerMessageChanges()
{
// Iterate backwards to avoid issues if subscribers are removed during iteration
for (var i = this._messageChangeSubscribers.Count - 1; i >= 0; i--)
{
this._messageChangeSubscribers[i]();
}
}
public MessageSubscription SubscribeToMessageChanges(Action onNewMessage)
{
return new MessageSubscription(this._messageChangeSubscribers, onNewMessage);
}
public ResponseUpdateSubscription SubscribeToResponseUpdates(Action onChatResponse)
{
return new ResponseUpdateSubscription(this._responseUpdateSubscribers, onChatResponse);
}
public RunStatusSubscription SubscribeToRunStatusChanges(Action onRunStatusChanged)
{
return new RunStatusSubscription(this._runStatusSubscribers, onRunStatusChanged);
}
private void TriggerRunStatusChanged()
{
// Iterate backwards to avoid issues if subscribers are removed during iteration
for (var i = this._runStatusSubscribers.Count - 1; i >= 0; i--)
{
this._runStatusSubscribers[i]();
}
}
}
public interface IAgentBoundaryContext
{
// Push new messages to the agent
Task SendAsync(params ChatMessage[] userMessages);
// All message interactions from previous turns
IReadOnlyList<ChatMessage> CompletedMessages { get; }
// All message interactions from the current turn. These represent completed messages only.
IReadOnlyList<ChatMessage> PendingMessages { get; }
// The current message being processed by the agent.
ChatMessage? CurrentMessage { get; }
ChatResponseUpdate? CurrentUpdate { get; }
/// <summary>
/// Gets whether the agent is currently processing a turn.
/// </summary>
bool IsProcessing { get; }
// Triggered any time there is a change on a message.
MessageSubscription SubscribeToMessageChanges(Action onNewMessage);
ResponseUpdateSubscription SubscribeToResponseUpdates(Action onChatResponse);
/// <summary>
/// Subscribes to run status changes (when processing starts or ends).
/// </summary>
/// <param name="onRunStatusChanged">The callback to invoke when run status changes.</param>
/// <returns>A subscription that can be disposed to unsubscribe.</returns>
RunStatusSubscription SubscribeToRunStatusChanges(Action onRunStatusChanged);
CancellationToken CancellationToken { get; }
/// <summary>
/// Registers a client-side tool that will be passed to the agent during invocation.
/// </summary>
void RegisterTool(AITool tool);
/// <summary>
/// Registers multiple client-side tools that will be passed to the agent during invocation.
/// </summary>
void RegisterTools(params AITool[] tools);
/// <summary>
/// Creates a pending response for the given key and returns a task that completes when ProvideResponse is called.
/// Used by frontend tools to wait for user input from UI components.
/// </summary>
/// <param name="key">A unique key to identify this pending response (e.g., function call ID).</param>
/// <returns>A task that completes with the response object when ProvideResponse is called.</returns>
Task<object> WaitForResponse(string key);
/// <summary>
/// Provides a response for a pending request, completing the task returned by WaitForResponse.
/// Called by UI components when user interaction is complete.
/// </summary>
/// <param name="key">The key used when calling WaitForResponse.</param>
/// <param name="response">The response object to return.</param>
/// <returns>True if the response was provided successfully, false if no pending request was found.</returns>
bool ProvideResponse(string key, object response);
}
public readonly struct MessageSubscription : IDisposable, IEquatable<MessageSubscription>
{
internal readonly List<Action> _subscribers;
internal readonly Action _subscription;
internal MessageSubscription(List<Action> subscribers, Action subscription)
{
this._subscribers = subscribers;
this._subscription = subscription;
this._subscribers.Add(this._subscription);
}
public void Dispose() => this._subscribers.Remove(this._subscription);
public override bool Equals(object? obj)
{
return obj is MessageSubscription subscription && this.Equals(subscription);
}
public bool Equals(MessageSubscription other)
{
return EqualityComparer<List<Action>>.Default.Equals(this._subscribers, other._subscribers) &&
EqualityComparer<Action>.Default.Equals(this._subscription, other._subscription);
}
public override int GetHashCode()
{
return HashCode.Combine(this._subscribers, this._subscription);
}
public static bool operator ==(MessageSubscription left, MessageSubscription right)
{
return left.Equals(right);
}
public static bool operator !=(MessageSubscription left, MessageSubscription right)
{
return !(left == right);
}
}
public readonly struct ResponseUpdateSubscription : IDisposable, IEquatable<ResponseUpdateSubscription>
{
private readonly List<Action> _subscribers;
private readonly Action _subscription;
public ResponseUpdateSubscription(List<Action> subscribers, Action subscription)
{
this._subscribers = subscribers;
this._subscription = subscription;
this._subscribers.Add(this._subscription);
}
public void Dispose() => this._subscribers.Remove(this._subscription);
public override bool Equals(object? obj)
{
return obj is ResponseUpdateSubscription subscription && this.Equals(subscription);
}
public bool Equals(ResponseUpdateSubscription other)
{
return EqualityComparer<List<Action>>.Default.Equals(this._subscribers, other._subscribers) &&
EqualityComparer<Action>.Default.Equals(this._subscription, other._subscription);
}
public override int GetHashCode()
{
return HashCode.Combine(this._subscribers, this._subscription);
}
public static bool operator ==(ResponseUpdateSubscription left, ResponseUpdateSubscription right)
{
return left.Equals(right);
}
public static bool operator !=(ResponseUpdateSubscription left, ResponseUpdateSubscription right)
{
return !(left == right);
}
}
public readonly struct RunStatusSubscription : IDisposable, IEquatable<RunStatusSubscription>
{
private readonly List<Action> _subscribers;
private readonly Action _subscription;
public RunStatusSubscription(List<Action> subscribers, Action subscription)
{
this._subscribers = subscribers;
this._subscription = subscription;
this._subscribers.Add(this._subscription);
}
public void Dispose() => this._subscribers.Remove(this._subscription);
public override bool Equals(object? obj)
{
return obj is RunStatusSubscription subscription && this.Equals(subscription);
}
public bool Equals(RunStatusSubscription other)
{
return EqualityComparer<List<Action>>.Default.Equals(this._subscribers, other._subscribers) &&
EqualityComparer<Action>.Default.Equals(this._subscription, other._subscription);
}
public override int GetHashCode()
{
return HashCode.Combine(this._subscribers, this._subscription);
}
public static bool operator ==(RunStatusSubscription left, RunStatusSubscription right)
{
return left.Equals(right);
}
public static bool operator !=(RunStatusSubscription left, RunStatusSubscription right)
{
return !(left == right);
}
}
@@ -0,0 +1,94 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
public sealed partial class AgentInput : IComponent, IDisposable
{
private RenderHandle _renderHandle;
private AgentBoundaryContext<object?>? _context;
private string? _inputText;
private RunStatusSubscription? _subscription;
[CascadingParameter] public AgentBoundaryContext<object?>? AgentContext { get; set; }
[Parameter] public string Placeholder { get; set; } = "Type a message...";
public void Attach(RenderHandle renderHandle)
{
this._renderHandle = renderHandle;
}
public Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
// Unsubscribe from previous context if it changed
if (this._context != this.AgentContext)
{
this._subscription?.Dispose();
this._context = this.AgentContext;
// Subscribe to run status changes
if (this._context != null)
{
this._subscription = this._context.SubscribeToRunStatusChanges(this.OnRunStatusChanged);
}
}
this.Render();
return Task.CompletedTask;
}
private void OnRunStatusChanged()
{
this.Render();
}
private void Render()
{
var isProcessing = this._context?.IsProcessing ?? false;
var isDisabled = string.IsNullOrWhiteSpace(this._inputText) || isProcessing;
this._renderHandle.Render(builder =>
{
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "agent-input");
builder.OpenElement(2, "textarea");
builder.AddAttribute(3, "value", this._inputText);
builder.AddAttribute(4, "oninput", EventCallback.Factory.Create<ChangeEventArgs>(this, e => { this._inputText = e.Value?.ToString(); this.Render(); }));
builder.AddAttribute(5, "placeholder", this.Placeholder);
builder.AddAttribute(6, "rows", "1");
builder.AddAttribute(7, "disabled", isProcessing);
builder.CloseElement();
builder.OpenElement(8, "button");
builder.AddAttribute(9, "class", "send-button");
builder.AddAttribute(10, "onclick", EventCallback.Factory.Create(this, this.SendAsync));
builder.AddAttribute(11, "disabled", isDisabled);
builder.AddMarkupContent(12, """<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>""");
builder.CloseElement(); // close button
builder.CloseElement(); // close div
});
}
private async Task SendAsync()
{
if (!string.IsNullOrWhiteSpace(this._inputText) && this._context is { IsProcessing: false })
{
var text = this._inputText;
this._inputText = ""; // Clear input immediately
this.Render(); // Re-render to clear input
await this._context.SendAsync(new ChatMessage(ChatRole.User, text));
}
}
public void Dispose()
{
this._subscription?.Dispose();
}
}
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.AspNetCore.Components.AI;
/// <summary>
/// A component that displays a loading indicator (three animated dots) when the agent is processing.
/// </summary>
public sealed partial class AgentLoadingIndicator : IComponent, IDisposable
{
private RenderHandle _renderHandle;
private AgentBoundaryContext<object?>? _context;
private RunStatusSubscription? _subscription;
[CascadingParameter] public AgentBoundaryContext<object?>? AgentContext { get; set; }
public void Attach(RenderHandle renderHandle)
{
this._renderHandle = renderHandle;
}
public Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
// Unsubscribe from previous context if it changed
if (this._context != this.AgentContext)
{
this._subscription?.Dispose();
this._context = this.AgentContext;
// Subscribe to run status changes
if (this._context != null)
{
this._subscription = this._context.SubscribeToRunStatusChanges(this.OnRunStatusChanged);
}
}
this.Render();
return Task.CompletedTask;
}
private void OnRunStatusChanged()
{
this.Render();
}
private void Render()
{
var isProcessing = this._context?.IsProcessing ?? false;
this._renderHandle.Render(builder =>
{
if (!isProcessing)
{
return;
}
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "agent-loading-indicator");
builder.OpenElement(2, "div");
builder.AddAttribute(3, "class", "agent-loading-dots");
builder.AddMarkupContent(4, "<span></span><span></span><span></span>");
builder.CloseElement();
builder.CloseElement();
});
}
public void Dispose()
{
this._subscription?.Dispose();
}
}
@@ -0,0 +1,93 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@namespace Microsoft.AspNetCore.Components.AI
@using Microsoft.Extensions.AI
@typeparam TState
@implements IDisposable
<CascadingValue Value="@CurrentState" IsFixed="false">
@ChildContent
</CascadingValue>
@code {
private ResponseUpdateSubscription? _subscription;
/// <summary>
/// The agent boundary context to subscribe to for state updates.
/// </summary>
[CascadingParameter]
public IAgentBoundaryContext? BoundaryContext { get; set; }
/// <summary>
/// The current state value. Can be set initially and will be updated by state events.
/// </summary>
[Parameter]
public TState? CurrentState { get; set; }
/// <summary>
/// Callback to deserialize a STATE_SNAPSHOT (application/json) into TState.
/// </summary>
[Parameter]
public Func<ReadOnlyMemory<byte>, TState?>? OnSnapshot { get; set; }
/// <summary>
/// Callback to apply a STATE_DELTA (application/json-patch+json) to the current state.
/// Returns the updated state.
/// </summary>
[Parameter]
public Func<TState?, ReadOnlyMemory<byte>, TState?>? OnDelta { get; set; }
/// <summary>
/// Optional callback invoked whenever state changes.
/// </summary>
[Parameter]
public EventCallback<TState?> CurrentStateChanged { get; set; }
/// <summary>
/// Child content that will receive the cascaded state.
/// </summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }
protected override void OnInitialized()
{
if (BoundaryContext is not null)
{
_subscription = BoundaryContext.SubscribeToResponseUpdates(OnResponseUpdate);
}
}
private void OnResponseUpdate()
{
var update = BoundaryContext?.CurrentUpdate;
if (update?.Contents is null)
{
return;
}
foreach (var content in update.Contents)
{
if (content is DataContent dataContent)
{
if (string.Equals(dataContent.MediaType, "application/json", StringComparison.OrdinalIgnoreCase) && OnSnapshot is not null)
{
// STATE_SNAPSHOT - let app deserialize
CurrentState = OnSnapshot(dataContent.Data);
_ = CurrentStateChanged.InvokeAsync(CurrentState);
InvokeAsync(StateHasChanged);
}
else if (string.Equals(dataContent.MediaType, "application/json-patch+json", StringComparison.OrdinalIgnoreCase) && OnDelta is not null)
{
// STATE_DELTA - let app apply the patch
CurrentState = OnDelta(CurrentState, dataContent.Data);
_ = CurrentStateChanged.InvokeAsync(CurrentState);
InvokeAsync(StateHasChanged);
}
}
}
}
public void Dispose()
{
_subscription?.Dispose();
}
}
@@ -0,0 +1,148 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
/// <summary>
/// Represents a suggestion that can be displayed and sent to the agent.
/// </summary>
public readonly struct Suggestion : IEquatable<Suggestion>
{
/// <summary>
/// Initializes a new instance of the <see cref="Suggestion"/> struct.
/// </summary>
/// <param name="text">The display text for the suggestion.</param>
/// <param name="message">The message to send when the suggestion is selected.</param>
public Suggestion(string text, ChatMessage message)
{
this.Text = text;
this.Message = message;
}
/// <summary>
/// Initializes a new instance of the <see cref="Suggestion"/> struct with a simple text message.
/// </summary>
/// <param name="text">The display text for the suggestion, also used as the message content.</param>
public Suggestion(string text)
{
this.Text = text;
this.Message = new ChatMessage(ChatRole.User, text);
}
/// <summary>
/// Gets the display text for the suggestion.
/// </summary>
public string Text { get; }
/// <summary>
/// Gets the message to send when the suggestion is selected.
/// </summary>
public ChatMessage Message { get; }
/// <inheritdoc/>
public override bool Equals(object? obj) => obj is Suggestion other && this.Equals(other);
/// <inheritdoc/>
public bool Equals(Suggestion other) => this.Text == other.Text;
/// <inheritdoc/>
public override int GetHashCode() => this.Text?.GetHashCode() ?? 0;
/// <summary>
/// Determines whether two <see cref="Suggestion"/> instances are equal.
/// </summary>
public static bool operator ==(Suggestion left, Suggestion right) => left.Equals(right);
/// <summary>
/// Determines whether two <see cref="Suggestion"/> instances are not equal.
/// </summary>
public static bool operator !=(Suggestion left, Suggestion right) => !left.Equals(right);
}
public sealed partial class AgentSuggestions : IComponent, IDisposable
{
private RenderHandle _renderHandle;
private AgentBoundaryContext<object?>? _context;
private IReadOnlyList<Suggestion>? _suggestions;
private RunStatusSubscription? _subscription;
[CascadingParameter] public AgentBoundaryContext<object?>? AgentContext { get; set; }
[Parameter] public IReadOnlyList<Suggestion>? Suggestions { get; set; }
public void Attach(RenderHandle renderHandle)
{
this._renderHandle = renderHandle;
}
public Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
// Unsubscribe from previous context if it changed
if (this._context != this.AgentContext)
{
this._subscription?.Dispose();
this._context = this.AgentContext;
// Subscribe to run status changes
if (this._context != null)
{
this._subscription = this._context.SubscribeToRunStatusChanges(this.OnRunStatusChanged);
}
}
this._suggestions = this.Suggestions;
this.Render();
return Task.CompletedTask;
}
private void OnRunStatusChanged()
{
this.Render();
}
private void Render()
{
var isProcessing = this._context?.IsProcessing ?? false;
this._renderHandle.Render(builder =>
{
if (this._suggestions is null || this._suggestions.Count == 0)
{
return;
}
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "agent-suggestions");
for (var i = 0; i < this._suggestions.Count; i++)
{
var suggestion = this._suggestions[i];
builder.OpenElement(2, "button");
builder.SetKey(suggestion.Text);
builder.AddAttribute(3, "class", "suggestion-button");
builder.AddAttribute(4, "onclick", EventCallback.Factory.Create(this, () => this.SelectSuggestionAsync(suggestion)));
builder.AddAttribute(5, "disabled", isProcessing);
builder.AddContent(6, suggestion.Text);
builder.CloseElement();
}
builder.CloseElement();
});
}
private async Task SelectSuggestionAsync(Suggestion suggestion)
{
if (this._context is { IsProcessing: false })
{
await this._context.SendAsync(suggestion.Message);
}
}
public void Dispose()
{
this._subscription?.Dispose();
}
}
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.AspNetCore.Components.Rendering;
namespace Microsoft.AspNetCore.Components.AI;
#pragma warning disable CA1812 // Internal class is apparently never instantiated
internal sealed class ContentBlock : IComponent
#pragma warning restore CA1812 // Internal class is apparently never instantiated
{
private RenderHandle _renderHandle;
[Parameter] public RenderFragment ChildContent { get; set; } = default!;
public void Attach(RenderHandle renderHandle)
{
this._renderHandle = renderHandle;
}
public Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
// Always render when parameters are set - the parent component (MessageList)
// only triggers renders when there are actual updates to show.
this._renderHandle.Render(this.Render);
return Task.CompletedTask;
}
private void Render(RenderTreeBuilder builder)
{
builder.AddContent(0, this.ChildContent);
}
}
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
public class ContentContext(AIContent content)
{
public AIContent Content { get; init; } = content;
}
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.AspNetCore.Components.AI;
public abstract class ContentTemplateBase : IComponent
{
public abstract void Attach(RenderHandle renderHandle);
public abstract Task SetParametersAsync(ParameterView parameters);
public virtual bool When(ContentContext context) => true;
[Parameter] public RenderFragment<ContentContext> ChildContent { get; set; } = (content) => builder => { };
}
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
/// <summary>
/// Default template for rendering DataContent. Renders nothing by default
/// as DataContent typically contains binary/JSON data not meant for display.
/// </summary>
public class DataContentTemplate : ContentTemplateBase
{
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
public override void Attach(RenderHandle renderHandle)
{
this.ChildContent = this.RenderData;
}
public override Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
this.Context.RegisterContentTemplate(this);
return Task.CompletedTask;
}
public override bool When(ContentContext context)
{
return context.Content is DataContent;
}
private RenderFragment RenderData(ContentContext content) => builder =>
{
// By default, render nothing.
};
}
@@ -0,0 +1,43 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
/// <summary>
/// Default template for rendering ErrorContent. Renders nothing by default
/// to prevent the error from crashing the UI. More specific templates can
/// override this to display error messages.
/// </summary>
public class ErrorTemplate : ContentTemplateBase
{
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
public override void Attach(RenderHandle renderHandle)
{
this.ChildContent = this.RenderError;
}
public override Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
this.Context.RegisterContentTemplate(this);
return Task.CompletedTask;
}
/// <summary>
/// Determines if this template should handle the given content.
/// Matches ErrorContent.
/// </summary>
public override bool When(ContentContext context)
{
return context.Content is ErrorContent;
}
private RenderFragment RenderError(ContentContext content) => builder =>
{
// By default, render nothing.
// Specific templates can override to display error messages.
};
}
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
/// <summary>
/// Template for rendering function/tool call content in messages.
/// Provides access to both the call and its result (when available) via InvocationContext.
/// </summary>
public class FunctionCallTemplate : ContentTemplateBase
{
/// <summary>
/// Gets or sets the tool name to filter on. If null, matches all function calls.
/// </summary>
[Parameter] public string? ToolName { get; set; }
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
public override void Attach(RenderHandle renderHandle)
{
// This component never renders anything by itself.
this.ChildContent = this.RenderFunctionCall;
}
public override Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
this.Context.RegisterContentTemplate(this);
return Task.CompletedTask;
}
/// <summary>
/// Determines if this template should handle the given content.
/// </summary>
/// <param name="context">The content context.</param>
/// <returns>True if this template should render the content.</returns>
public override bool When(ContentContext context)
{
if (context.Content is not FunctionCallContent call)
{
return false;
}
// Filter by tool name if specified
if (this.ToolName != null && !string.Equals(call.Name, this.ToolName, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return true;
}
private RenderFragment RenderFunctionCall(ContentContext content) => builder =>
{
// By default, function calls are not rendered visually.
// Custom templates (like WeatherCallTemplate) can override this
// behavior for specific functions by registering before this template.
};
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
/// <summary>
/// Default template for FunctionResultContent that renders nothing.
/// Function results are internal tool responses and typically don't need
/// visual representation in the chat UI.
/// </summary>
public class FunctionResultTemplate : ContentTemplateBase
{
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
public override void Attach(RenderHandle renderHandle)
{
// This component never renders anything by itself.
this.ChildContent = this.RenderFunctionResult;
}
public override Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
this.Context.RegisterContentTemplate(this);
return Task.CompletedTask;
}
/// <summary>
/// Only match FunctionResultContent.
/// </summary>
public override bool When(ContentContext context) => context.Content is FunctionResultContent;
private RenderFragment RenderFunctionResult(ContentContext content) => builder =>
{
// By default, function results are not rendered visually.
// The result data is typically processed by the agent to generate text responses.
};
}
@@ -0,0 +1,135 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
/// <summary>
/// Context for a function invocation, tracking the call and its result.
/// </summary>
public class InvocationContext
{
private Action? _resultArrived;
/// <summary>
/// Initializes a new instance of the <see cref="InvocationContext"/> class.
/// </summary>
/// <param name="call">The function call content.</param>
public InvocationContext(FunctionCallContent call)
{
this.Call = call;
}
/// <summary>
/// Gets the function call content.
/// </summary>
public FunctionCallContent Call { get; }
/// <summary>
/// Gets the function result content, if available.
/// </summary>
public FunctionResultContent? ResultContent { get; private set; }
/// <summary>
/// Gets a value indicating whether the result has arrived.
/// </summary>
public bool HasResult => this.ResultContent != null;
/// <summary>
/// Gets the function name from the call.
/// </summary>
public string FunctionName => this.Call.Name;
/// <summary>
/// Gets the call ID.
/// </summary>
public string CallId => this.Call.CallId;
/// <summary>
/// Gets the arguments from the call.
/// </summary>
public IDictionary<string, object?>? Arguments => this.Call.Arguments;
/// <summary>
/// Event raised when the result arrives.
/// </summary>
#pragma warning disable CA1003 // Use generic event handler instances
public event Action? ResultArrived
#pragma warning restore CA1003 // Use generic event handler instances
{
add => this._resultArrived += value;
remove => this._resultArrived -= value;
}
/// <summary>
/// Sets the result and raises the ResultArrived event.
/// </summary>
/// <param name="result">The function result content.</param>
internal void SetResult(FunctionResultContent result)
{
this.ResultContent = result;
this._resultArrived?.Invoke();
this._resultArrived = null; // Clear invocation list after firing
}
/// <summary>
/// Gets an argument value by name, deserializing from JSON if necessary.
/// </summary>
/// <typeparam name="T">The type to deserialize to.</typeparam>
/// <param name="name">The argument name.</param>
/// <returns>The argument value, or default if not found.</returns>
public T? GetArgument<T>(string name)
{
if (this.Arguments is null || !this.Arguments.TryGetValue(name, out var value))
{
return default;
}
if (value is null)
{
return default;
}
if (value is T typed)
{
return typed;
}
if (value is JsonElement jsonElement)
{
return jsonElement.Deserialize<T>();
}
// Try to convert via JSON serialization
var json = JsonSerializer.Serialize(value);
return JsonSerializer.Deserialize<T>(json);
}
/// <summary>
/// Gets the result as a specific type, deserializing from JSON if necessary.
/// </summary>
/// <typeparam name="T">The type to deserialize to.</typeparam>
/// <returns>The result value, or default if not available.</returns>
public T? GetResult<T>()
{
if (this.ResultContent?.Result is null)
{
return default;
}
if (this.ResultContent.Result is T typed)
{
return typed;
}
if (this.ResultContent.Result is JsonElement jsonElement)
{
return jsonElement.Deserialize<T>();
}
// Try to convert via JSON serialization
var json = JsonSerializer.Serialize(this.ResultContent.Result);
return JsonSerializer.Deserialize<T>(json);
}
}
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
public class TextTemplate : ContentTemplateBase
{
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
public override void Attach(RenderHandle renderHandle)
{
// This component never renders anything by itself.
this.ChildContent = this.RenderText;
}
public override Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
this.Context.RegisterContentTemplate(this);
return Task.CompletedTask;
}
/// <summary>
/// Only match TextContent, allowing other content templates to handle
/// FunctionCallContent, FunctionResultContent, etc.
/// </summary>
public override bool When(ContentContext context) => context.Content is TextContent;
private RenderFragment RenderText(ContentContext content) => builder =>
{
if (content.Content is TextContent textContent)
{
builder.AddContent(0, textContent.Text);
}
};
}
@@ -0,0 +1,116 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
#pragma warning disable CA1812 // Internal class is apparently never instantiated
internal sealed class DefaultMessageTemplate : MessageTemplateBase
#pragma warning restore CA1812 // Internal class is apparently never instantiated
{
public override bool When(MessageContext context) => true;
// Buffer to convert response updates to chat messages.
private readonly List<ChatMessage> _buffer = [];
public DefaultMessageTemplate()
{
this.ChildContent = this.SelectTemplate;
}
private RenderFragment SelectTemplate(MessageContext messageContext)
{
if (messageContext.ChatMessage is not null)
{
var getRenderContents = messageContext.RenderContents();
// Return a render fragment that checks visibility at render time
// This is important because message contents may change during streaming
return CreateRenderMessage(
messageContext.ChatMessage,
getRenderContents);
}
else
{
var getRenderContents = messageContext.RenderContents();
var updates = messageContext.ResponseUpdates ?? [];
if (updates.Count == 0)
{
throw new InvalidOperationException("MessageContext must have either a ChatMessage or at least one ResponseUpdate.");
}
this._buffer.Clear();
this._buffer.AddMessages(updates);
if (this._buffer.Count != 1)
{
throw new InvalidOperationException("DefaultMessageTemplate only supports a single ResponseUpdate.");
}
return CreateRenderMessage(
this._buffer[0],
getRenderContents);
}
}
/// <summary>
/// Checks if a message has any visible content that should be displayed.
/// Messages that only contain FunctionResultContent are internal tool messages
/// and should not be rendered as chat bubbles.
/// FunctionCallContent is considered visible because content templates can render them
/// (e.g., WeatherCallTemplate renders weather tool calls as cards).
/// </summary>
private static bool HasVisibleContent(ChatMessage message)
{
if (message.Contents.Count == 0)
{
return false;
}
foreach (var content in message.Contents)
{
// TextContent is visible
if (content is TextContent textContent && !string.IsNullOrWhiteSpace(textContent.Text))
{
return true;
}
// FunctionCallContent is visible - content templates can render them
// (e.g., WeatherCallTemplate renders weather tool calls as cards)
if (content is FunctionCallContent)
{
return true;
}
// Other content types that are not function results are visible
if (content is not FunctionResultContent)
{
return true;
}
}
return false;
}
private static RenderFragment CreateRenderMessage(
ChatMessage message,
RenderFragment getRenderContents)
{
var roleClass = $"{message.Role}-message";
return builder =>
{
// Check visibility at render time, not at template creation time
// This is important because message contents may change during streaming
if (!HasVisibleContent(message))
{
return; // Don't render messages without visible content
}
builder.OpenElement(0, "div");
if (!string.IsNullOrEmpty(message.MessageId))
{
builder.AddAttribute(1, "id", message.MessageId);
}
builder.AddAttribute(2, "class", $"chat-message {roleClass}");
builder.AddContent(3, getRenderContents);
builder.CloseElement();
};
}
}
@@ -0,0 +1,96 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
public class MessageContext
{
private readonly MessageListContext _messageListContext;
private RenderFragment? _contentsRenderer;
private MessageTemplateBase? _template;
private readonly Dictionary<AIContent, RenderFragment> _contentRenderers = [];
internal MessageContext(ChatMessage message, MessageListContext messageListContext)
{
this.ChatMessage = message;
this._messageListContext = messageListContext;
}
public ChatMessage? ChatMessage { get; init; }
public IList<ChatResponseUpdate>? ResponseUpdates { get; init; }
public RenderFragment RenderContents() => this.GetOrCreateContentsRenderer();
internal void SetTemplate(MessageTemplateBase template)
{
this._template = template;
}
private RenderFragment GetOrCreateContentsRenderer()
{
if (this._contentsRenderer != null)
{
return this._contentsRenderer;
}
if (this._template == null)
{
throw new InvalidOperationException("Message template has not been set for this message context.");
}
this._contentsRenderer = builder =>
{
if (this.ChatMessage != null)
{
for (int i = 0; i < this.ChatMessage.Contents.Count; i++)
{
var content = this.ChatMessage.Contents[i];
builder.AddContent(0, this.ResolveContentRenderer(content));
}
}
else if (this.ResponseUpdates != null)
{
for (int i = 0; i < this.ResponseUpdates.Count; i++)
{
var update = this.ResponseUpdates[i];
for (int j = 0; j < update.Contents.Count; j++)
{
var content = update.Contents[j];
if (this._contentRenderers.TryGetValue(content, out var contentRenderer))
{
builder.AddContent(0, contentRenderer);
}
}
}
}
else
{
throw new InvalidOperationException("MessageContext must have either a ChatMessage or ResponseUpdates to render contents.");
}
};
return this._contentsRenderer;
}
private RenderFragment ResolveContentRenderer(AIContent content)
{
if (this._contentRenderers.TryGetValue(content, out var contentRenderer))
{
return contentRenderer;
}
Debug.Assert(this._template != null);
contentRenderer = this._template.GetContentTemplate(content);
if (contentRenderer != null)
{
this._contentRenderers[content] = contentRenderer;
return contentRenderer;
}
contentRenderer = this._messageListContext.GetContentTemplate(content);
this._contentRenderers[content] = contentRenderer;
return contentRenderer;
}
}
@@ -0,0 +1,322 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
// Roughly lifted from src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs
internal static class MessageHelpers
{
internal static void CoalesceContent(IList<AIContent> contents)
{
Coalesce<TextContent>(
contents,
mergeSingle: false,
canMerge: null,
static (contents, start, end) => new(MergeText(contents, start, end)) { AdditionalProperties = contents[start].AdditionalProperties?.Clone() });
Coalesce(
contents,
mergeSingle: false,
canMerge: static (r1, r2) => string.IsNullOrEmpty(r1.ProtectedData), // we allow merging if the first item has no ProtectedData, even if the second does
static (contents, start, end) =>
{
TextReasoningContent content = new(MergeText(contents, start, end))
{
AdditionalProperties = contents[start].AdditionalProperties?.Clone()
};
#if DEBUG
for (int i = start; i < end - 1; i++)
{
Debug.Assert(contents[i] is TextReasoningContent { ProtectedData: null }, "Expected all but the last to have a null ProtectedData");
}
#endif
if (((TextReasoningContent)contents[end - 1]).ProtectedData is { } protectedData)
{
content.ProtectedData = protectedData;
}
return content;
});
Coalesce(
contents,
mergeSingle: false,
canMerge: static (r1, r2) => r1.MediaType == r2.MediaType && r1.HasTopLevelMediaType("text") && r1.Name == r2.Name,
static (contents, start, end) =>
{
Debug.Assert(end - start > 1, "Expected multiple contents to merge");
MemoryStream ms = new();
for (int i = start; i < end; i++)
{
var current = (DataContent)contents[i];
#if NET
ms.Write(current.Data.Span);
#else
if (!MemoryMarshal.TryGetArray(current.Data, out var segment))
{
segment = new(current.Data.ToArray());
}
ms.Write(segment.Array!, segment.Offset, segment.Count);
#endif
}
var first = (DataContent)contents[start];
return new DataContent(new ReadOnlyMemory<byte>(ms.GetBuffer(), 0, (int)ms.Length), first.MediaType) { Name = first.Name };
});
Coalesce(
contents,
mergeSingle: true,
canMerge: static (r1, r2) => r1.CallId == r2.CallId,
static (contents, start, end) =>
{
var firstContent = (CodeInterpreterToolCallContent)contents[start];
if (start == end - 1)
{
if (firstContent.Inputs is not null)
{
CoalesceContent(firstContent.Inputs);
}
return firstContent;
}
List<AIContent>? inputs = null;
for (int i = start; i < end; i++)
{
(inputs ??= []).AddRange(((CodeInterpreterToolCallContent)contents[i]).Inputs ?? []);
}
if (inputs is not null)
{
CoalesceContent(inputs);
}
return new()
{
CallId = firstContent.CallId,
Inputs = inputs,
AdditionalProperties = firstContent.AdditionalProperties?.Clone(),
};
});
Coalesce(
contents,
mergeSingle: true,
canMerge: static (r1, r2) => r1.CallId is not null && r2.CallId is not null && r1.CallId == r2.CallId,
static (contents, start, end) =>
{
var firstContent = (CodeInterpreterToolResultContent)contents[start];
if (start == end - 1)
{
if (firstContent.Outputs is not null)
{
CoalesceContent(firstContent.Outputs);
}
return firstContent;
}
List<AIContent>? output = null;
for (int i = start; i < end; i++)
{
(output ??= []).AddRange(((CodeInterpreterToolResultContent)contents[i]).Outputs ?? []);
}
if (output is not null)
{
CoalesceContent(output);
}
return new()
{
CallId = firstContent.CallId,
Outputs = output,
AdditionalProperties = firstContent.AdditionalProperties?.Clone(),
};
});
static string MergeText(IList<AIContent> contents, int start, int end)
{
Debug.Assert(end - start > 1, "Expected multiple contents to merge");
StringBuilder sb = new();
for (int i = start; i < end; i++)
{
_ = sb.Append(contents[i]);
}
return sb.ToString();
}
static void Coalesce<TContent>(
IList<AIContent> contents,
bool mergeSingle,
Func<TContent, TContent, bool>? canMerge,
Func<IList<AIContent>, int, int, TContent> merge)
where TContent : AIContent
{
// Iterate through all of the items in the list looking for contiguous items that can be coalesced.
int start = 0;
while (start < contents.Count)
{
if (!TryAsCoalescable(contents[start], out var firstContent))
{
start++;
continue;
}
// Iterate until we find a non-coalescable item.
int i = start + 1;
TContent prev = firstContent;
while (i < contents.Count && TryAsCoalescable(contents[i], out TContent? next) && (canMerge is null || canMerge(prev, next)))
{
i++;
prev = next;
}
// If there's only one item in the run, and we don't want to merge single items, skip it.
if (start == i - 1 && !mergeSingle)
{
start++;
continue;
}
// Store the replacement node and null out all of the nodes that we coalesced.
// We can then remove all coalesced nodes in one O(N) operation via RemoveAll.
// Leave start positioned at the start of the next run.
contents[start] = merge(contents, start, i);
start++;
while (start < i)
{
contents[start++] = null!;
}
static bool TryAsCoalescable(AIContent content, [NotNullWhen(true)] out TContent? coalescable)
{
if (content is TContent tmp && tmp.Annotations is not { Count: > 0 })
{
coalescable = tmp;
return true;
}
coalescable = null;
return false;
}
}
// Remove all of the null slots left over from the coalescing process.
RemoveNullContents(contents);
}
}
private static void RemoveNullContents<T>(IList<T> contents)
where T : class
{
if (contents is List<AIContent> contentsList)
{
_ = contentsList.RemoveAll(u => u is null);
}
else
{
int nextSlot = 0;
int contentsCount = contents.Count;
for (int i = 0; i < contentsCount; i++)
{
if (contents[i] is { } content)
{
contents[nextSlot++] = content;
}
}
for (int i = contentsCount - 1; i >= nextSlot; i--)
{
contents.RemoveAt(i);
}
Debug.Assert(nextSlot == contents.Count, "Expected final count to equal list length.");
}
}
internal static bool ProcessUpdate(ChatResponseUpdate update, List<ChatMessage> messages)
{
// If there is no message created yet, or if the last update we saw had a different
// identifying parts, create a new message.
bool isNewMessage = true;
if (messages.Count != 0)
{
var lastMessage = messages[messages.Count - 1];
isNewMessage =
NotEmptyOrEqual(update.AuthorName, lastMessage.AuthorName) ||
NotEmptyOrEqual(update.MessageId, lastMessage.MessageId) ||
NotNullOrEqual(update.Role, lastMessage.Role);
}
// Get the message to target, either a new one or the last ones.
ChatMessage message;
if (isNewMessage)
{
message = new(ChatRole.Assistant, []);
messages.Add(message);
}
else
{
message = messages[messages.Count - 1];
}
// Some members on ChatResponseUpdate map to members of ChatMessage.
// Incorporate those into the latest message; in cases where the message
// stores a single value, prefer the latest update's value over anything
// stored in the message.
if (update.AuthorName is not null)
{
message.AuthorName = update.AuthorName;
}
if (message.CreatedAt is null || (update.CreatedAt is not null && update.CreatedAt > message.CreatedAt))
{
message.CreatedAt = update.CreatedAt;
}
if (update.Role is ChatRole role)
{
message.Role = role;
}
if (update.MessageId is { Length: > 0 })
{
// Note that this must come after the message checks earlier, as they depend
// on this value for change detection.
message.MessageId = update.MessageId;
}
foreach (var content in update.Contents)
{
message.Contents.Add(content);
}
return isNewMessage;
}
/// <summary>Gets whether both strings are not null/empty and not the same as each other.</summary>
private static bool NotEmptyOrEqual(string? s1, string? s2) =>
s1 is { Length: > 0 } str1 && s2 is { Length: > 0 } str2 && str1 != str2;
/// <summary>Gets whether two roles are not null and not the same as each other.</summary>
private static bool NotNullOrEqual(ChatRole? r1, ChatRole? r2) =>
r1.HasValue && r2.HasValue && r1.Value != r2.Value;
}
@@ -0,0 +1,139 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
#pragma warning disable CA1812 // Internal class is apparently never instantiated
internal sealed partial class MessageList : IComponent, IDisposable
#pragma warning restore CA1812 // Internal class is apparently never instantiated
{
private RenderHandle _renderHandle;
private MessageSubscription _messageSubscription;
private ResponseUpdateSubscription _responseSubscription;
[CascadingParameter] public MessageListContext MessageListContext { get; set; } = default!;
public void Attach(RenderHandle renderHandle)
{
this._renderHandle = renderHandle;
}
public Task SetParametersAsync(ParameterView parameters)
{
var previousContext = this.MessageListContext;
parameters.SetParameterProperties(this);
if (previousContext != null && this.MessageListContext != previousContext)
{
throw new InvalidOperationException(
$"{nameof(MessageList)} does not support changing the {nameof(this.MessageListContext)} once it has been set.");
}
// Subscribe to message updates and response updates for streaming
this._messageSubscription = this.MessageListContext.AgentBoundaryContext.SubscribeToMessageChanges(this.ProcessUpdate);
this._responseSubscription = this.MessageListContext.AgentBoundaryContext.SubscribeToResponseUpdates(this.ProcessUpdate);
// Initial render. This component will only render once since the only parameter is a cascading parameter and it's fixed.
this._renderHandle.Render(this.Render);
return Task.CompletedTask;
}
private void ProcessUpdate()
{
// Scan all messages for function calls and results to track invocations.
// This allows us to associate results with their calls when results arrive.
foreach (var message in this.MessageListContext.AgentBoundaryContext.CompletedMessages)
{
this.ProcessMessageContents(message);
}
foreach (var message in this.MessageListContext.AgentBoundaryContext.PendingMessages)
{
this.ProcessMessageContents(message);
}
this._renderHandle.Render(this.Render);
}
private void ProcessMessageContents(ChatMessage message)
{
foreach (var content in message.Contents)
{
if (content is FunctionCallContent call)
{
this.MessageListContext.GetOrCreateInvocation(call);
}
else if (content is FunctionResultContent result)
{
this.MessageListContext.AssociateResult(result);
}
}
}
public void Render(RenderTreeBuilder builder)
{
// Track all render keys to detect duplicates
var allRenderKeys = new HashSet<string?>();
// Open container div for message list with flex layout
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "messages-container");
foreach (var message in this.MessageListContext.AgentBoundaryContext.CompletedMessages)
{
var renderKey = GetUniqueRenderKey(message);
// Calling GetTemplate will stop template collection on the first message if it
// was still ongoing.
builder.OpenComponent<ContentBlock>(2);
builder.SetKey(renderKey);
builder.AddComponentParameter(3, "ChildContent", this.MessageListContext.GetTemplate(message));
builder.CloseComponent();
}
foreach (var message in this.MessageListContext.AgentBoundaryContext.PendingMessages)
{
var renderKey = GetUniqueRenderKey(message);
builder.OpenComponent<ContentBlock>(4);
builder.SetKey(renderKey);
builder.AddComponentParameter(5, "ChildContent", this.MessageListContext.GetTemplate(message));
builder.CloseComponent();
}
// Close container div
builder.CloseElement();
}
/// <summary>
/// Gets a unique render key for a message.
/// For tool result messages (role=tool), we use MessageId + CallId to ensure uniqueness
/// because the AGUI protocol may reuse MessageId for multiple tool results in the same batch.
/// </summary>
private static string? GetUniqueRenderKey(ChatMessage message)
{
// For tool result messages, combine MessageId with CallId to ensure uniqueness
// This works around a bug in the AGUI protocol where multiple tool results
// can share the same MessageId when processed in the same update batch.
if (message.Role == ChatRole.Tool)
{
var resultContent = message.Contents.OfType<FunctionResultContent>().FirstOrDefault();
if (resultContent != null && !string.IsNullOrEmpty(resultContent.CallId))
{
return $"{message.MessageId}_{resultContent.CallId}";
}
}
return message.MessageId;
}
public void Dispose()
{
((IDisposable)this._messageSubscription).Dispose();
((IDisposable)this._responseSubscription).Dispose();
}
}
@@ -0,0 +1,137 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
public sealed partial class MessageListContext
{
private bool _collectingTemplates;
private readonly List<MessageTemplateBase> _templates = [];
private readonly List<ContentTemplateBase> _contentTemplates = [];
// We compute a render fragment to render each message only once and cache it here.
private readonly Dictionary<ChatMessage, RenderFragment> _templateCache = [];
// Track function invocations by CallId to associate calls with results.
private readonly Dictionary<string, InvocationContext> _invocationMap = [];
public MessageListContext(IAgentBoundaryContext context)
{
this.AgentBoundaryContext = context;
}
public IAgentBoundaryContext AgentBoundaryContext { get; }
public void BeginCollectingTemplates()
{
// This is triggered by the Messages component before rendering its children.
// In this situation we are going to render again the MessageTemplates and
// ContentTemplates and since we can't tell if they have changed we have to
// recompute all the templates again.
this._collectingTemplates = true;
this._templates.Clear();
this._contentTemplates.Clear();
this._templateCache.Clear();
}
public void RegisterTemplate(MessageTemplateBase template)
{
if (this._collectingTemplates)
{
this._templates.Add(template);
}
}
public void RegisterContentTemplate(ContentTemplateBase template)
{
if (this._collectingTemplates)
{
this._contentTemplates.Add(template);
}
}
/// <summary>
/// Gets or creates an invocation context for the given function call.
/// </summary>
/// <param name="call">The function call content.</param>
/// <returns>The invocation context for this call.</returns>
public InvocationContext GetOrCreateInvocation(FunctionCallContent call)
{
if (!this._invocationMap.TryGetValue(call.CallId, out var context))
{
context = new InvocationContext(call);
this._invocationMap[call.CallId] = context;
}
return context;
}
/// <summary>
/// Associates a function result with its corresponding call.
/// </summary>
/// <param name="result">The function result content.</param>
public void AssociateResult(FunctionResultContent result)
{
if (this._invocationMap.TryGetValue(result.CallId, out var context))
{
context.SetResult(result);
}
}
/// <summary>
/// Gets an invocation context by call ID.
/// </summary>
/// <param name="callId">The call ID.</param>
/// <returns>The invocation context, or null if not found.</returns>
public InvocationContext? GetInvocation(string callId)
{
return this._invocationMap.TryGetValue(callId, out var context) ? context : null;
}
internal RenderFragment GetTemplate(ChatMessage message)
{
// We are about to render the first message. If we were collecting templates, stop now.
this._collectingTemplates = false;
if (this._templateCache.TryGetValue(message, out var cachedTemplate))
{
return cachedTemplate;
}
var messageContext = new MessageContext(message, this);
foreach (var template in this._templates)
{
if (template.When(messageContext))
{
var chosen = template;
messageContext.SetTemplate(chosen);
// We ask the template to create a RenderFragment for the message.
// The template will render a wrapper and use the messageContext to
// render the contents.
// The template might call back through the messageContext to get renderers for
// contents if the message template doesn't override the full rendering or
// if it doesn't define the rendering for a content type.
var renderer = chosen.ChildContent(messageContext);
this._templateCache[message] = renderer;
return renderer;
}
}
throw new InvalidOperationException($"No message template found for message of type {message.Role}.");
}
internal RenderFragment GetContentTemplate(AIContent content)
{
foreach (var template in this._contentTemplates)
{
var contentContext = new ContentContext(content);
if (template.When(contentContext))
{
return template.ChildContent(contentContext);
}
}
throw new InvalidOperationException($"No content template found for content of type {content.GetType().Name}.");
}
}
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
public abstract class MessageTemplateBase : IComponent
{
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
public abstract bool When(MessageContext context);
[Parameter] public RenderFragment<MessageContext> ChildContent { get; set; } = (message) => builder => { };
public void Attach(RenderHandle renderHandle)
{
}
public Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
this.Context.RegisterTemplate(this);
return Task.CompletedTask;
}
internal RenderFragment? GetContentTemplate(AIContent content)
{
return this.Context.GetContentTemplate(content);
}
}
@@ -0,0 +1,94 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.AspNetCore.Components.Rendering;
namespace Microsoft.AspNetCore.Components.AI;
public partial class Messages : IComponent
{
private RenderHandle _renderHandle;
private RenderFragment? _renderContents;
private IAgentBoundaryContext? _context;
private MessageListContext? _messageListContext;
[CascadingParameter] public IAgentBoundaryContext? AgentContext { get; set; }
[Parameter]
public RenderFragment MessageTemplates { get; set; } = builder =>
{
builder.OpenComponent<DefaultMessageTemplate>(0);
builder.CloseComponent();
};
[Parameter]
public RenderFragment ContentTemplates { get; set; } = builder =>
{
builder.OpenComponent<TextTemplate>(0);
builder.CloseComponent();
builder.OpenComponent<FunctionCallTemplate>(1);
builder.CloseComponent();
builder.OpenComponent<FunctionResultTemplate>(2);
builder.CloseComponent();
builder.OpenComponent<ErrorTemplate>(3);
builder.CloseComponent();
builder.OpenComponent<DataContentTemplate>(4);
builder.CloseComponent();
};
public void Attach(RenderHandle renderHandle)
{
this._renderHandle = renderHandle;
this._renderContents = this.RenderContents;
}
public Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
if (this.AgentContext == null)
{
throw new InvalidOperationException("Messages component must be used within an AgentBoundary.");
}
if (this._context != null && this.AgentContext != this._context)
{
throw new InvalidOperationException("Messages component cannot change AgentBoundaryContext.");
}
this._context = this.AgentContext;
if (this._messageListContext == null)
{
this._messageListContext = new MessageListContext(this.AgentContext);
this.Render();
}
return Task.CompletedTask;
}
private void Render()
{
this._renderHandle.Render(this.RenderCore);
}
private void RenderCore(RenderTreeBuilder builder)
{
builder.OpenComponent<CascadingValue<MessageListContext>>(0);
builder.AddComponentParameter(1, "Value", this._messageListContext);
builder.AddComponentParameter(2, "IsFixed", true);
builder.AddComponentParameter(3, "ChildContent", this._renderContents);
builder.CloseComponent();
}
private void RenderContents(RenderTreeBuilder builder)
{
Debug.Assert(this._messageListContext != null);
this._messageListContext.BeginCollectingTemplates();
builder.AddContent(1, this.MessageTemplates);
builder.AddContent(2, this.ContentTemplates);
builder.OpenComponent<MessageList>(3);
builder.CloseComponent();
}
}
@@ -0,0 +1,309 @@
/* Copyright (c) Microsoft. All rights reserved. */
/* ==========================================================================
AG-UI Components - Shared Styles
These styles provide consistent appearance for AI components across demos.
========================================================================== */
/* ==========================================================================
Agent Input Component
========================================================================== */
.agent-input {
display: flex;
align-items: flex-end;
gap: 12px;
width: 100%;
}
.agent-input textarea {
flex: 1;
padding: 12px 16px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
resize: none;
min-height: 44px;
max-height: 200px;
line-height: 1.5;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.agent-input textarea:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.agent-input textarea::placeholder {
color: #9ca3af;
}
.agent-input textarea:disabled {
background-color: #f9fafb;
color: #9ca3af;
cursor: not-allowed;
}
.agent-input .send-button {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: none;
border-radius: 8px;
background-color: #2563eb;
color: white;
cursor: pointer;
transition: background-color 0.2s, opacity 0.2s;
flex-shrink: 0;
}
.agent-input .send-button:hover:not(:disabled) {
background-color: #1d4ed8;
}
.agent-input .send-button:disabled {
background-color: #d1d5db;
color: #9ca3af;
cursor: not-allowed;
}
.agent-input .send-button svg {
display: block;
}
/* ==========================================================================
Agent Suggestions Component
========================================================================== */
.agent-suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-start;
margin-bottom: 0.75rem;
}
.suggestion-button {
padding: 0.5rem 1rem;
background: white;
border: 1px solid #d1d1d1;
border-radius: 1rem;
cursor: pointer;
font-size: 0.875rem;
color: #424242;
transition: all 0.2s;
}
.suggestion-button:hover:not(:disabled) {
background: #f0f0f0;
border-color: #0078d4;
color: #0078d4;
}
.suggestion-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ==========================================================================
Loading Indicator - Triple Dot Animation
========================================================================== */
.agent-loading-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
color: #6b7280;
font-size: 14px;
}
.agent-loading-dots {
display: flex;
gap: 4px;
align-items: center;
}
.agent-loading-dots span {
width: 6px;
height: 6px;
background-color: #2563eb;
border-radius: 50%;
animation: agent-dot-bounce 1.4s ease-in-out infinite both;
}
.agent-loading-dots span:nth-child(1) {
animation-delay: -0.32s;
}
.agent-loading-dots span:nth-child(2) {
animation-delay: -0.16s;
}
.agent-loading-dots span:nth-child(3) {
animation-delay: 0s;
}
@keyframes agent-dot-bounce {
0%, 80%, 100% {
transform: scale(0.6);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* Inline loading indicator for use within messages */
.agent-loading-inline {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background-color: #f3f4f6;
border-radius: 12px;
}
.agent-loading-inline span {
width: 5px;
height: 5px;
background-color: #6b7280;
border-radius: 50%;
animation: agent-dot-bounce 1.4s ease-in-out infinite both;
}
.agent-loading-inline span:nth-child(1) {
animation-delay: -0.32s;
}
.agent-loading-inline span:nth-child(2) {
animation-delay: -0.16s;
}
.agent-loading-inline span:nth-child(3) {
animation-delay: 0s;
}
/* ==========================================================================
Processing State Overlay
========================================================================== */
.agent-input-container {
position: relative;
}
.agent-input-container.processing .agent-input textarea {
background-color: #f9fafb;
}
/* ==========================================================================
Message Styles
========================================================================== */
.message-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
display: flex;
flex-direction: column;
gap: 4px;
}
.message-user {
align-items: flex-end;
}
.message-assistant {
align-items: flex-start;
}
.message-content {
max-width: 80%;
padding: 12px 16px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
}
.message-user .message-content {
background-color: #2563eb;
color: white;
border-bottom-right-radius: 4px;
}
.message-assistant .message-content {
background-color: #f3f4f6;
color: #1f2937;
border-bottom-left-radius: 4px;
}
/* ==========================================================================
Common Chat Layout Styles
========================================================================== */
.chat-layout {
display: flex;
flex-direction: column;
height: 100%;
max-width: 1200px;
margin: 0 auto;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
border-bottom: 1px solid #e5e5e5;
}
.chat-title {
font-size: 1.5rem;
font-weight: 600;
color: #1a1a1a;
}
.new-chat-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: white;
border: 1px solid #d1d1d1;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
color: #424242;
transition: all 0.2s;
}
.new-chat-button:hover {
background: #f5f5f5;
border-color: #b3b3b3;
}
.button-icon {
font-size: 1.2rem;
line-height: 1;
}
.chat-content {
flex: 1;
overflow-y: auto;
padding: 2rem;
display: flex;
flex-direction: column;
}
.chat-input-container {
padding: 1.5rem 2rem 2rem;
border-top: 1px solid #e5e5e5;
background: white;
}
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="AGUIDojoClient.styles.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="@renderMode" />
</head>
<body>
<Routes @rendermode="@renderMode" />
<script src="_framework/blazor.web.js"></script>
</body>
</html>
@code {
private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false);
}
@@ -0,0 +1,72 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@using Microsoft.AspNetCore.Components.AI
@using Microsoft.Agents.AI
@using Microsoft.Extensions.AI
@using Microsoft.Extensions.DependencyInjection
@using AGUIDojoClient.Services
@inject IServiceProvider ServiceProvider
@inject IBackgroundColorService BackgroundColorService
@implements IDisposable
<PageTitle>Agentic Chat</PageTitle>
<div class="chat-layout" style="@GetBackgroundStyle()">
<div class="chat-header">
<div class="chat-title">AGUI WebChat</div>
<button class="new-chat-button" @onclick="ResetConversationAsync">
<span class="button-icon">+</span> New chat
</button>
</div>
<AgentBoundary Agent="@agent">
<div class="chat-content">
<Messages />
<AgentLoadingIndicator />
</div>
<div class="chat-input-container">
<AgentSuggestions Suggestions="@suggestions" />
<AgentInput Placeholder="Type your message..." />
</div>
</AgentBoundary>
</div>
@code {
private AIAgent? agent;
private string? _backgroundColor;
private Suggestion[] suggestions = [
new Suggestion("Change background", new ChatMessage(ChatRole.User, "Change background to light blue")),
new Suggestion("Generate sonnet")
];
[Parameter]
public string ScenarioId { get; set; } = "agentic_chat";
protected override void OnInitialized()
{
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("agentic-chat");
BackgroundColorService.ColorChanged += OnColorChanged;
}
private string GetBackgroundStyle()
{
return _backgroundColor != null ? $"background-color: {_backgroundColor}" : "";
}
private async void OnColorChanged(object? sender, BackgroundColorChangedEventArgs e)
{
_backgroundColor = e.Color;
await InvokeAsync(StateHasChanged);
}
private void ResetConversationAsync()
{
// Reset would need to be implemented in AgentBoundary
StateHasChanged();
}
public void Dispose()
{
BackgroundColorService.ColorChanged -= OnColorChanged;
}
}
@@ -0,0 +1,74 @@
/* Copyright (c) Microsoft. All rights reserved. */
.chat-layout {
display: flex;
flex-direction: column;
height: 100%;
max-width: 1200px;
margin: 0 auto;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
border-bottom: 1px solid #e5e5e5;
}
.chat-title {
font-size: 1.5rem;
font-weight: 600;
color: #1a1a1a;
}
.new-chat-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: white;
border: 1px solid #d1d1d1;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
color: #424242;
transition: all 0.2s;
}
.new-chat-button:hover {
background: #f5f5f5;
border-color: #b3b3b3;
}
.button-icon {
font-size: 1.2rem;
line-height: 1;
}
.chat-content {
flex: 1;
overflow-y: auto;
padding: 2rem;
display: flex;
flex-direction: column;
}
.chat-input-container {
padding: 1rem 2rem 1.5rem;
border-top: 1px solid #e5e5e5;
background: white;
}
.agentic-chat-demo {
height: 100%;
display: flex;
flex-direction: column;
}
.chat-container {
padding: 0 2rem 1.5rem;
border-top: 1px solid #e0e0e0;
}
@@ -0,0 +1,111 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@using Microsoft.AspNetCore.Components.AI
@using Microsoft.Agents.AI
@using Microsoft.Extensions.AI
@using Microsoft.Extensions.DependencyInjection
@using AGUIDojoClient.Components.Shared
@using System.Text.Json
@inject IServiceProvider ServiceProvider
<PageTitle>Agentic Generative UI</PageTitle>
<div class="chat-layout">
<div class="chat-header">
<div class="chat-title">Agentic Generative UI</div>
<button class="new-chat-button" @onclick="ResetConversation">
<span class="button-icon">+</span> New chat
</button>
</div>
<AgentBoundary Agent="@agent">
<AgentState TState="Plan"
CurrentState="@currentPlan"
OnSnapshot="@DeserializePlan"
OnDelta="@ApplyPlanDelta"
CurrentStateChanged="@OnPlanChanged">
<div class="chat-content">
<Messages>
<ContentTemplates>
<TaskProgressTemplate />
<TextTemplate />
<FunctionCallTemplate />
<FunctionResultTemplate />
<ErrorTemplate />
<DataContentTemplate />
</ContentTemplates>
</Messages>
<AgentLoadingIndicator />
</div>
<div class="chat-input-container">
<AgentSuggestions Suggestions="@suggestions" />
<AgentInput Placeholder="Ask me to plan something..." />
</div>
</AgentState>
</AgentBoundary>
</div>
@code {
private AIAgent? agent;
private Plan? currentPlan;
private Suggestion[] suggestions = [
new Suggestion("Simple plan", new ChatMessage(ChatRole.User, "Please build a plan to go to mars in 5 steps.")),
new Suggestion("Complex plan", new ChatMessage(ChatRole.User, "Please build a plan to make pizza in 10 steps."))
];
[Parameter]
public string ScenarioId { get; set; } = "agentic_generative_ui";
protected override void OnInitialized()
{
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("agentic-generative-ui");
}
private Plan? DeserializePlan(ReadOnlyMemory<byte> data)
{
try
{
return JsonSerializer.Deserialize<Plan>(data.Span);
}
catch
{
return null;
}
}
private Plan? ApplyPlanDelta(Plan? current, ReadOnlyMemory<byte> deltaData)
{
if (current is null)
{
return null;
}
try
{
var operations = JsonSerializer.Deserialize<List<JsonPatchOperation>>(deltaData.Span);
if (operations is not null)
{
PlanPatcher.Apply(current, operations);
}
}
catch
{
// Ignore deserialization errors
}
return current;
}
private void OnPlanChanged(Plan? plan)
{
currentPlan = plan;
StateHasChanged();
}
private void ResetConversation()
{
currentPlan = null;
StateHasChanged();
}
}
@@ -0,0 +1,86 @@
/* Copyright (c) Microsoft. All rights reserved. */
.chat-layout {
display: flex;
flex-direction: column;
height: 100%;
max-width: 1200px;
margin: 0 auto;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
border-bottom: 1px solid #e5e5e5;
}
.chat-title {
font-size: 1.5rem;
font-weight: 600;
color: #1a1a1a;
}
.new-chat-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: white;
border: 1px solid #d1d1d1;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
color: #424242;
transition: all 0.2s;
}
.new-chat-button:hover {
background: #f5f5f5;
border-color: #b3b3b3;
}
.button-icon {
font-size: 1.2rem;
line-height: 1;
}
.chat-content {
flex: 1;
overflow-y: auto;
padding: 2rem;
display: flex;
flex-direction: column;
}
.chat-input-container {
padding: 1.5rem 2rem 2rem;
border-top: 1px solid #e5e5e5;
background: white;
}
::deep .agent-suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-start;
margin-bottom: 0.75rem;
}
::deep .suggestion-button {
padding: 0.5rem 1rem;
background: white;
border: 1px solid #d1d1d1;
border-radius: 1rem;
cursor: pointer;
font-size: 0.875rem;
color: #424242;
transition: all 0.2s;
}
::deep .suggestion-button:hover {
background: #f0f0f0;
border-color: #0078d4;
color: #0078d4;
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoClient.Components.Demos.AgenticGenerativeUI;
/// <summary>
/// Represents a JSON Patch operation (RFC 6902).
/// </summary>
public sealed class JsonPatchOperation
{
/// <summary>
/// The operation to perform (e.g., "replace", "add", "remove").
/// </summary>
[JsonPropertyName("op")]
public string Op { get; set; } = string.Empty;
/// <summary>
/// The JSON Pointer path to the target location.
/// </summary>
[JsonPropertyName("path")]
public string Path { get; set; } = string.Empty;
/// <summary>
/// The value for the operation (used with "replace", "add", "test").
/// </summary>
[JsonPropertyName("value")]
public object? Value { get; set; }
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoClient.Components.Demos.AgenticGenerativeUI;
/// <summary>
/// Represents a plan with multiple steps.
/// </summary>
public sealed class Plan
{
/// <summary>
/// The list of steps in the plan.
/// </summary>
[JsonPropertyName("steps")]
public List<Step> Steps { get; set; } = [];
/// <summary>
/// Gets the count of completed steps.
/// </summary>
[JsonIgnore]
public int CompletedCount => this.Steps.Count(s => s.IsCompleted);
/// <summary>
/// Gets the total number of steps.
/// </summary>
[JsonIgnore]
public int TotalCount => this.Steps.Count;
/// <summary>
/// Gets whether all steps are completed.
/// </summary>
[JsonIgnore]
public bool IsComplete => this.Steps.Count > 0 && this.Steps.All(s => s.IsCompleted);
}
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.RegularExpressions;
namespace AGUIDojoClient.Components.Demos.AgenticGenerativeUI;
/// <summary>
/// Applies JSON Patch operations to a Plan object.
/// </summary>
public static partial class PlanPatcher
{
/// <summary>
/// Applies a list of JSON Patch operations to the given plan.
/// </summary>
/// <param name="plan">The plan to modify.</param>
/// <param name="operations">The patch operations to apply.</param>
public static void Apply(Plan plan, IEnumerable<JsonPatchOperation> operations)
{
foreach (var operation in operations)
{
ApplyOperation(plan, operation);
}
}
private static void ApplyOperation(Plan plan, JsonPatchOperation operation)
{
// Parse paths like "/steps/0/status" or "/steps/0/description"
var match = StepPathRegex().Match(operation.Path);
if (!match.Success)
{
return;
}
if (!int.TryParse(match.Groups["index"].Value, out var index))
{
return;
}
if (index < 0 || index >= plan.Steps.Count)
{
return;
}
var property = match.Groups["property"].Value;
var step = plan.Steps[index];
if (string.Equals(operation.Op, "replace", StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(property, "status", StringComparison.OrdinalIgnoreCase))
{
step.Status = GetStringValue(operation.Value) ?? step.Status;
}
else if (string.Equals(property, "description", StringComparison.OrdinalIgnoreCase))
{
step.Description = GetStringValue(operation.Value) ?? step.Description;
}
}
}
private static string? GetStringValue(object? value)
{
return value switch
{
string s => s,
JsonElement { ValueKind: JsonValueKind.String } je => je.GetString(),
_ => value?.ToString()
};
}
[GeneratedRegex(@"^/steps/(?<index>\d+)/(?<property>\w+)$")]
private static partial Regex StepPathRegex();
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoClient.Components.Demos.AgenticGenerativeUI;
/// <summary>
/// Represents a single step in a plan.
/// </summary>
public sealed class Step
{
/// <summary>
/// The description of the step.
/// </summary>
[JsonPropertyName("description")]
public string Description { get; set; } = string.Empty;
/// <summary>
/// The status of the step (pending or completed).
/// </summary>
[JsonPropertyName("status")]
public string Status { get; set; } = "pending";
/// <summary>
/// Gets whether this step is completed.
/// </summary>
[JsonIgnore]
public bool IsCompleted => string.Equals(this.Status, "completed", StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,42 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@if (Plan is not null && Plan.Steps.Count > 0)
{
<div class="task-progress-card @(Plan.IsComplete ? "completed" : "in-progress")">
<div class="task-header">
<h3>Task Progress</h3>
<span class="task-counter">@Plan.CompletedCount/@Plan.TotalCount Complete</span>
</div>
<div class="task-steps">
@for (int i = 0; i < Plan.Steps.Count; i++)
{
var step = Plan.Steps[i];
var isCurrentStep = !step.IsCompleted &&
(i == 0 || Plan.Steps[i - 1].IsCompleted);
<div class="task-step @(step.IsCompleted ? "step-completed" : isCurrentStep ? "step-current" : "step-pending")">
<span class="step-icon">
@if (step.IsCompleted)
{
<span class="check-icon">✓</span>
}
else if (isCurrentStep)
{
<span class="current-icon">●</span>
}
else
{
<span class="pending-icon">○</span>
}
</span>
<span class="step-description">@step.Description</span>
</div>
}
</div>
</div>
}
@code {
[CascadingParameter]
public Plan? Plan { get; set; }
}
@@ -0,0 +1,139 @@
/* Copyright (c) Microsoft. All rights reserved. */
.task-progress-card {
background: #ffffff;
border: 1px solid #e5e5e5;
border-radius: 12px;
padding: 16px;
margin: 8px 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.task-progress-card.completed {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
border-color: #86efac;
}
.task-progress-card.in-progress {
background: #ffffff;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e5e5e5;
}
.task-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: #1a1a1a;
}
.task-counter {
font-size: 0.875rem;
color: #666666;
background: #f5f5f5;
padding: 4px 10px;
border-radius: 20px;
}
.task-steps {
display: flex;
flex-direction: column;
gap: 8px;
}
.task-step {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
transition: all 0.3s ease;
}
.task-step.step-completed {
background: rgba(34, 197, 94, 0.1);
border-left: 3px solid #22c55e;
}
.task-step.step-current {
background: rgba(0, 120, 212, 0.1);
border-left: 3px solid #0078d4;
animation: pulse-border 2s infinite;
}
.task-step.step-pending {
background: #fafafa;
border-left: 3px solid #d1d1d1;
opacity: 0.7;
}
@keyframes pulse-border {
0%, 100% {
border-left-color: #0078d4;
box-shadow: 0 0 0 0 rgba(0, 120, 212, 0.4);
}
50% {
border-left-color: #50a0e8;
box-shadow: 0 0 8px 0 rgba(0, 120, 212, 0.2);
}
}
.step-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.check-icon {
color: #22c55e;
font-weight: bold;
font-size: 14px;
}
.current-icon {
color: #0078d4;
font-size: 12px;
animation: pulse 1.5s infinite;
}
.pending-icon {
color: #999999;
font-size: 12px;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.2);
}
}
.step-description {
flex: 1;
font-size: 0.9rem;
line-height: 1.5;
color: #424242;
}
.step-completed .step-description {
color: #166534;
}
.step-pending .step-description {
color: #666666;
}
@@ -0,0 +1,62 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@using Microsoft.AspNetCore.Components.AI
@using Microsoft.Agents.AI
@using Microsoft.Extensions.AI
@using Microsoft.Extensions.DependencyInjection
@using AGUIDojoClient.Components.Shared
@inject IServiceProvider ServiceProvider
<PageTitle>Backend Tool Rendering</PageTitle>
<div class="chat-layout">
<div class="chat-header">
<div class="chat-title">Backend Tool Rendering</div>
<button class="new-chat-button" @onclick="ResetConversation">
<span class="button-icon">+</span> New chat
</button>
</div>
<AgentBoundary Agent="@agent">
<div class="chat-content">
<Messages>
<ContentTemplates>
<TextTemplate />
<WeatherCallTemplate />
<FunctionCallTemplate />
<FunctionResultTemplate />
<ErrorTemplate />
<DataContentTemplate />
</ContentTemplates>
</Messages>
<AgentLoadingIndicator />
</div>
<div class="chat-input-container">
<AgentSuggestions Suggestions="@suggestions" />
<AgentInput Placeholder="Ask about the weather..." />
</div>
</AgentBoundary>
</div>
@code {
private AIAgent? agent;
private Suggestion[] suggestions = [
new Suggestion("Weather in San Francisco", new ChatMessage(ChatRole.User, "What's the weather like in San Francisco?")),
new Suggestion("Weather in New York", new ChatMessage(ChatRole.User, "What's the weather like in New York?")),
new Suggestion("Weather in Tokyo", new ChatMessage(ChatRole.User, "What's the weather like in Tokyo?"))
];
[Parameter]
public string ScenarioId { get; set; } = "backend_tool_rendering";
protected override void OnInitialized()
{
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("backend-tool-rendering");
}
private void ResetConversation()
{
// Reset would need to be implemented - for now just trigger re-render
StateHasChanged();
}
}
@@ -0,0 +1,86 @@
/* Copyright (c) Microsoft. All rights reserved. */
.chat-layout {
display: flex;
flex-direction: column;
height: 100%;
max-width: 1200px;
margin: 0 auto;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
border-bottom: 1px solid #e5e5e5;
}
.chat-title {
font-size: 1.5rem;
font-weight: 600;
color: #1a1a1a;
}
.new-chat-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: white;
border: 1px solid #d1d1d1;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
color: #424242;
transition: all 0.2s;
}
.new-chat-button:hover {
background: #f5f5f5;
border-color: #b3b3b3;
}
.button-icon {
font-size: 1.2rem;
line-height: 1;
}
.chat-content {
flex: 1;
overflow-y: auto;
padding: 2rem;
display: flex;
flex-direction: column;
}
.chat-input-container {
padding: 1.5rem 2rem 2rem;
border-top: 1px solid #e5e5e5;
background: white;
}
::deep .agent-suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-start;
margin-bottom: 0.75rem;
}
::deep .suggestion-button {
padding: 0.5rem 1rem;
background: white;
border: 1px solid #d1d1d1;
border-radius: 1rem;
cursor: pointer;
font-size: 0.875rem;
color: #424242;
transition: all 0.2s;
}
::deep .suggestion-button:hover {
background: #f0f0f0;
border-color: #0078d4;
color: #0078d4;
}
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
/// <summary>
/// Template for rendering weather function call content with its result.
/// Uses InvocationContext to access both the call arguments and result.
/// </summary>
public class WeatherCallTemplate : ContentTemplateBase
{
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
public override void Attach(RenderHandle renderHandle)
{
// This component never renders anything by itself.
this.ChildContent = this.RenderWeatherCall;
}
public override Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
this.Context.RegisterContentTemplate(this);
return Task.CompletedTask;
}
/// <summary>
/// Determines if this template should handle the given content.
/// Matches FunctionCallContent for the get_weather function.
/// </summary>
public override bool When(ContentContext context)
{
// Only match FunctionCallContent for the get_weather function
return context.Content is FunctionCallContent call &&
string.Equals(call.Name, "get_weather", StringComparison.OrdinalIgnoreCase);
}
private RenderFragment RenderWeatherCall(ContentContext content) => builder =>
{
if (content.Content is FunctionCallContent call)
{
// Get the invocation context which tracks both call and result
var invocation = this.Context.GetOrCreateInvocation(call);
// Provide the invocation context to child components
builder.OpenComponent<CascadingValue<InvocationContext>>(0);
builder.AddComponentParameter(1, "Value", invocation);
builder.AddComponentParameter(2, "IsFixed", true);
builder.AddComponentParameter(3, "ChildContent", (RenderFragment)(innerBuilder =>
{
// Render the WeatherCard component which uses InvocationContext
innerBuilder.OpenComponent<AGUIDojoClient.Components.Demos.BackendToolRendering.WeatherCard>(0);
innerBuilder.CloseComponent();
}));
builder.CloseComponent();
}
};
}
@@ -0,0 +1,117 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@using AGUIDojoClient.Components.Shared
@using Microsoft.AspNetCore.Components.AI
@if (Weather is null)
{
<div class="weather-card weather-loading">
<div class="weather-header">
<div class="weather-location">
<h3>@Location</h3>
<p>Loading weather...</p>
</div>
<span class="weather-icon skeleton-icon"></span>
</div>
<div class="weather-main">
<div class="temperature">
<span class="temp-value skeleton-text skeleton-temp"></span>
<span class="temp-fahrenheit skeleton-text skeleton-temp-f"></span>
</div>
<div class="conditions skeleton-text skeleton-conditions"></div>
</div>
<div class="weather-details">
<div class="detail-item">
<p class="detail-label">Humidity</p>
<p class="detail-value skeleton-text skeleton-detail"></p>
</div>
<div class="detail-item">
<p class="detail-label">Wind</p>
<p class="detail-value skeleton-text skeleton-detail"></p>
</div>
<div class="detail-item">
<p class="detail-label">Feels Like</p>
<p class="detail-value skeleton-text skeleton-detail"></p>
</div>
</div>
</div>
}
else
{
<div class="weather-card @GetConditionClass()">
<div class="weather-header">
<div class="weather-location">
<h3>@Location</h3>
<p>Current Weather</p>
</div>
<span class="weather-icon">@Weather.ConditionIcon</span>
</div>
<div class="weather-main">
<div class="temperature">
<span class="temp-value">@Weather.Temperature° C</span>
<span class="temp-fahrenheit">/ @Weather.TemperatureFahrenheit.ToString("F1")° F</span>
</div>
<div class="conditions">@Weather.Conditions</div>
</div>
<div class="weather-details">
<div class="detail-item">
<p class="detail-label">Humidity</p>
<p class="detail-value">@(Weather.Humidity)%</p>
</div>
<div class="detail-item">
<p class="detail-label">Wind</p>
<p class="detail-value">@Weather.WindSpeed mph</p>
</div>
<div class="detail-item">
<p class="detail-label">Feels Like</p>
<p class="detail-value">@(Weather.FeelsLike)°</p>
</div>
</div>
</div>
}
@code {
[CascadingParameter]
public InvocationContext Invocation { get; set; } = default!;
private string Location => Invocation?.GetArgument<string>("location") ?? "Unknown Location";
private WeatherInfo? Weather => Invocation?.HasResult == true
? Invocation.GetResult<WeatherInfo>()
: null;
protected override void OnInitialized()
{
if (Invocation is not null && !Invocation.HasResult)
{
Invocation.ResultArrived += OnResultArrived;
}
}
private void OnResultArrived()
{
InvokeAsync(StateHasChanged);
}
private string GetConditionClass()
{
if (Weather is null)
{
return "condition-default";
}
return Weather.Conditions.ToLowerInvariant() switch
{
"sunny" or "clear" => "condition-sunny",
"cloudy" or "overcast" => "condition-cloudy",
"rainy" or "rain" => "condition-rainy",
"stormy" or "thunderstorm" => "condition-stormy",
"snowy" or "snow" => "condition-snowy",
"foggy" or "fog" => "condition-foggy",
_ => "condition-default"
};
}
}
@@ -0,0 +1,162 @@
.weather-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 20px;
color: white;
max-width: 320px;
box-shadow: 0 10px 40px rgba(102, 126, 234, 0.4);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.weather-card.condition-sunny {
background: linear-gradient(135deg, #f6d365 0%, #fda085 100%);
}
.weather-card.condition-cloudy {
background: linear-gradient(135deg, #bdc3c7 0%, #2c3e50 100%);
}
.weather-card.condition-rainy {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.weather-card.condition-stormy {
background: linear-gradient(135deg, #373b44 0%, #4286f4 100%);
}
.weather-card.condition-snowy {
background: linear-gradient(135deg, #e6dada 0%, #274046 100%);
}
.weather-card.condition-foggy {
background: linear-gradient(135deg, #606c88 0%, #3f4c6b 100%);
}
.weather-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.weather-location h3 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.weather-location p {
margin: 4px 0 0 0;
font-size: 0.85rem;
opacity: 0.8;
}
.weather-icon {
font-size: 3rem;
}
.weather-main {
margin-bottom: 20px;
}
.temperature {
display: flex;
align-items: baseline;
gap: 8px;
}
.temp-value {
font-size: 3rem;
font-weight: 300;
line-height: 1;
}
.temp-fahrenheit {
font-size: 1rem;
opacity: 0.7;
}
.conditions {
font-size: 1.1rem;
text-transform: capitalize;
margin-top: 8px;
opacity: 0.9;
}
.weather-details {
display: flex;
justify-content: space-between;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.detail-item {
text-align: center;
}
.detail-label {
margin: 0;
font-size: 0.75rem;
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.detail-value {
margin: 4px 0 0 0;
font-size: 1.1rem;
font-weight: 500;
}
/* Loading/Skeleton state */
.weather-loading {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.skeleton-text {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.2) 25%, rgba(255, 255, 255, 0.4) 50%, rgba(255, 255, 255, 0.2) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
border-radius: 4px;
display: inline-block;
}
.skeleton-icon {
width: 48px;
height: 48px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.2) 25%, rgba(255, 255, 255, 0.4) 50%, rgba(255, 255, 255, 0.2) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
border-radius: 8px;
display: inline-block;
}
.skeleton-temp {
width: 80px;
height: 3rem;
}
.skeleton-temp-f {
width: 60px;
height: 1rem;
}
.skeleton-conditions {
width: 100px;
height: 1.1rem;
margin-top: 8px;
}
.skeleton-detail {
width: 40px;
height: 1.1rem;
}
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.AI;
using Microsoft.Extensions.AI;
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
/// <summary>
/// Template for rendering create_plan function call content.
/// The PlanCard will subscribe to events to track confirm_plan and update_plan_step calls.
/// </summary>
public class CreatePlanCallTemplate : ContentTemplateBase
{
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
public override void Attach(RenderHandle renderHandle)
{
// This component never renders anything by itself.
this.ChildContent = this.RenderCreatePlanCall;
}
public override Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
this.Context.RegisterContentTemplate(this);
return Task.CompletedTask;
}
/// <summary>
/// Determines if this template should handle the given content.
/// Matches FunctionCallContent for the create_plan function.
/// </summary>
public override bool When(ContentContext context)
{
// Only match FunctionCallContent for the create_plan function
return context.Content is FunctionCallContent call &&
string.Equals(call.Name, "create_plan", StringComparison.OrdinalIgnoreCase);
}
private RenderFragment RenderCreatePlanCall(ContentContext content) => builder =>
{
if (content.Content is FunctionCallContent call)
{
// Get the invocation context which tracks both call and result
var invocation = this.Context.GetOrCreateInvocation(call);
// Provide both the invocation context and message list context to PlanCard
builder.OpenComponent<CascadingValue<InvocationContext>>(0);
builder.AddComponentParameter(1, "Value", invocation);
builder.AddComponentParameter(2, "IsFixed", true);
builder.AddComponentParameter(3, "ChildContent", (RenderFragment)(innerBuilder =>
{
// Also cascade the MessageListContext so PlanCard can track other tool calls
innerBuilder.OpenComponent<CascadingValue<MessageListContext>>(0);
innerBuilder.AddComponentParameter(1, "Value", this.Context);
innerBuilder.AddComponentParameter(2, "IsFixed", true);
innerBuilder.AddComponentParameter(3, "ChildContent", (RenderFragment)(cardBuilder =>
{
// Render the PlanCard component
cardBuilder.OpenComponent<PlanCard>(0);
cardBuilder.CloseComponent();
}));
innerBuilder.CloseComponent();
}));
builder.CloseComponent();
}
};
}
@@ -0,0 +1,56 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
/// <summary>
/// A delegating agent that prepends instructions for the human-in-the-loop workflow.
/// </summary>
internal sealed class HumanInTheLoopAgent : DelegatingAIAgent
{
private static readonly ChatMessage InstructionsMessage = new(
ChatRole.System,
"""
You help users create and execute plans. Follow this workflow:
1. When asked to create a plan, use the `create_plan` tool with a list of step descriptions.
2. IMMEDIATELY after creating a plan, call `confirm_plan` with the plan object to ask for user approval.
3. Wait for the user to confirm which steps they want to proceed with.
4. Once confirmed, use `update_plan_step` to mark steps as 'completed' as you execute them.
IMPORTANT:
- Always call `confirm_plan` right after `create_plan` - don't skip this step!
- The plan parameter for `confirm_plan` should be the exact plan object returned from `create_plan`.
- Do NOT start executing steps until the user confirms.
- After receiving confirmation, update each selected step to 'completed' status.
""");
public HumanInTheLoopAgent(AIAgent innerAgent)
: base(innerAgent)
{
}
public override Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
// Prepend instructions message
var messagesWithInstructions = messages.Prepend(InstructionsMessage);
return base.RunAsync(messagesWithInstructions, thread, options, cancellationToken);
}
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
// Prepend instructions message
var messagesWithInstructions = messages.Prepend(InstructionsMessage);
return base.RunStreamingAsync(messagesWithInstructions, thread, options, cancellationToken);
}
}
@@ -0,0 +1,162 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@using Microsoft.AspNetCore.Components.AI
@using Microsoft.Agents.AI
@using Microsoft.Extensions.AI
@using Microsoft.Extensions.DependencyInjection
@using AGUIDojoClient.Components.Shared
@using System.Text.Json
@using System.ComponentModel
@inject IServiceProvider ServiceProvider
<PageTitle>Human in the Loop</PageTitle>
<div class="chat-layout">
<div class="chat-header">
<div class="chat-title">Human in the Loop</div>
<button class="new-chat-button" @onclick="ResetConversation">
<span class="button-icon">+</span> New chat
</button>
</div>
<AgentBoundary Agent="@agent" OnContextCreated="OnContextCreated">
<div class="chat-content">
<Messages>
<ContentTemplates>
<CreatePlanCallTemplate />
<TextTemplate />
<FunctionCallTemplate />
<FunctionResultTemplate />
<ErrorTemplate />
<DataContentTemplate />
</ContentTemplates>
</Messages>
<AgentLoadingIndicator />
</div>
<div class="chat-input-container">
<AgentSuggestions Suggestions="@suggestions" />
<AgentInput Placeholder="Ask for a plan..." />
</div>
</AgentBoundary>
</div>
@code {
private AIAgent? agent;
private IAgentBoundaryContext? boundaryContext;
private Plan? currentPlan;
private Suggestion[] suggestions = [
new Suggestion("Simple plan", new ChatMessage(ChatRole.User, "Create a simple 5-step plan for organizing a birthday party")),
new Suggestion("Complex plan", new ChatMessage(ChatRole.User, "Create a detailed 10-step plan for launching a new product"))
];
[Parameter]
public string ScenarioId { get; set; } = "human_in_the_loop";
protected override void OnInitialized()
{
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("human-in-the-loop");
}
private void OnContextCreated(IAgentBoundaryContext context)
{
boundaryContext = context;
// Register all three frontend tools for the human-in-the-loop scenario
// 1. create_plan - creates a plan and stores it
var createPlanTool = AIFunctionFactory.Create(
(List<string> steps) => CreatePlan(steps),
"create_plan",
"Create a plan with multiple steps. Call this first before confirm_plan.");
// 2. confirm_plan - waits for user confirmation via UI, receives the plan to display
var confirmPlanTool = AIFunctionFactory.Create(
(Plan plan) => ConfirmPlanAsync(context, plan),
"confirm_plan",
"Present the plan to the user for confirmation. The user can select which steps to proceed with. Pass the plan returned from create_plan.");
// 3. update_plan_step - updates a step's status
var updatePlanStepTool = AIFunctionFactory.Create(
(int index, string? description, string? status) => UpdatePlanStep(index, description, status),
"update_plan_step",
"Update a step in the plan with new description or status. Use status 'completed' to mark a step as done.");
context.RegisterTools(createPlanTool, confirmPlanTool, updatePlanStepTool);
}
/// <summary>
/// Frontend tool that creates a plan with the given steps.
/// </summary>
[Description("Create a plan with multiple steps.")]
private Plan CreatePlan([Description("List of step descriptions to create the plan.")] List<string> steps)
{
currentPlan = new Plan
{
Steps = [.. steps.Select(s => new Step { Description = s, Status = "pending" })]
};
return currentPlan;
}
/// <summary>
/// Frontend tool that waits for user confirmation via the UI.
/// The PlanCard component will call ProvideResponse when the user confirms/rejects.
/// </summary>
[Description("Present the plan to the user for confirmation.")]
private static async Task<PlanConfirmationResult> ConfirmPlanAsync(
IAgentBoundaryContext context,
[Description("The plan to present to the user for confirmation.")] Plan plan)
{
// The plan parameter is received and will be accessible via InvocationContext in the PlanCard
// Wait for the PlanCard component to provide the response
var response = await context.WaitForResponse("confirm_plan");
return (PlanConfirmationResult)response;
}
/// <summary>
/// Frontend tool that updates a step in the plan.
/// </summary>
[Description("Update a step in the plan with new description or status.")]
private List<JsonPatchOperation> UpdatePlanStep(
[Description("The index of the step to update.")] int index,
[Description("The new description for the step (optional).")] string? description = null,
[Description("The new status for the step: 'pending' or 'completed'.")] string? status = null)
{
var changes = new List<JsonPatchOperation>();
if (currentPlan is null || index < 0 || index >= currentPlan.Steps.Count)
{
return changes;
}
if (description is not null)
{
currentPlan.Steps[index].Description = description;
changes.Add(new JsonPatchOperation
{
Op = "replace",
Path = $"/steps/{index}/description",
Value = description
});
}
if (status is not null)
{
currentPlan.Steps[index].Status = status.ToLowerInvariant();
changes.Add(new JsonPatchOperation
{
Op = "replace",
Path = $"/steps/{index}/status",
Value = status.ToLowerInvariant()
});
}
return changes;
}
private void ResetConversation()
{
currentPlan = null;
StateHasChanged();
}
}
@@ -0,0 +1,86 @@
/* Copyright (c) Microsoft. All rights reserved. */
.chat-layout {
display: flex;
flex-direction: column;
height: 100%;
max-width: 1200px;
margin: 0 auto;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
border-bottom: 1px solid #e5e5e5;
}
.chat-title {
font-size: 1.5rem;
font-weight: 600;
color: #1a1a1a;
}
.new-chat-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: white;
border: 1px solid #d1d1d1;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
color: #424242;
transition: all 0.2s;
}
.new-chat-button:hover {
background: #f5f5f5;
border-color: #b3b3b3;
}
.button-icon {
font-size: 1.2rem;
line-height: 1;
}
.chat-content {
flex: 1;
overflow-y: auto;
padding: 2rem;
display: flex;
flex-direction: column;
}
.chat-input-container {
padding: 1.5rem 2rem 2rem;
border-top: 1px solid #e5e5e5;
background: white;
}
::deep .agent-suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-start;
margin-bottom: 0.75rem;
}
::deep .suggestion-button {
padding: 0.5rem 1rem;
background: white;
border: 1px solid #d1d1d1;
border-radius: 1rem;
cursor: pointer;
font-size: 0.875rem;
color: #424242;
transition: all 0.2s;
}
::deep .suggestion-button:hover {
background: #f0f0f0;
border-color: #0078d4;
color: #0078d4;
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
/// <summary>
/// Represents a JSON Patch operation.
/// </summary>
public sealed class JsonPatchOperation
{
/// <summary>
/// The operation type (e.g., "replace", "add", "remove").
/// </summary>
[JsonPropertyName("op")]
public string Op { get; set; } = string.Empty;
/// <summary>
/// The JSON Pointer path to the target location.
/// </summary>
[JsonPropertyName("path")]
public string Path { get; set; } = string.Empty;
/// <summary>
/// The value for the operation.
/// </summary>
[JsonPropertyName("value")]
public object? Value { get; set; }
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
/// <summary>
/// Represents a plan with multiple steps.
/// </summary>
public sealed class Plan
{
/// <summary>
/// The list of steps in the plan.
/// </summary>
[JsonPropertyName("steps")]
public List<Step> Steps { get; set; } = [];
/// <summary>
/// Gets the count of completed steps.
/// </summary>
[JsonIgnore]
public int CompletedCount => this.Steps.Count(s => s.IsCompleted);
/// <summary>
/// Gets the total number of steps.
/// </summary>
[JsonIgnore]
public int TotalCount => this.Steps.Count;
/// <summary>
/// Gets whether all steps are completed.
/// </summary>
[JsonIgnore]
public bool IsComplete => this.Steps.Count > 0 && this.Steps.All(s => s.IsCompleted);
}
@@ -0,0 +1,324 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@using Microsoft.AspNetCore.Components.AI
@using Microsoft.Extensions.AI
@using System.Text.Json
@implements IDisposable
@if (IsWaitingForPlan)
{
<div class="plan-card plan-loading">
<div class="plan-header">
<h3>Loading Plan...</h3>
<div class="plan-progress">
<span class="skeleton-text skeleton-progress"></span>
</div>
</div>
<div class="plan-steps">
@for (int i = 0; i < 3; i++)
{
<div class="plan-step skeleton-step">
<span class="step-checkbox skeleton-checkbox"></span>
<span class="step-description skeleton-text skeleton-description"></span>
</div>
}
</div>
<div class="plan-actions">
<button class="plan-button plan-button-skeleton" disabled>Loading...</button>
</div>
</div>
}
else if (CurrentPlan is not null && AwaitingConfirmation)
{
<div class="plan-card plan-active">
<div class="plan-header">
<h3>Plan Confirmation</h3>
<div class="plan-progress">
<span class="progress-count">@CurrentPlan.CompletedCount / @CurrentPlan.TotalCount completed</span>
</div>
</div>
<div class="plan-steps">
@for (int i = 0; i < CurrentPlan.Steps.Count; i++)
{
var step = CurrentPlan.Steps[i];
var index = i;
<div class="plan-step @(step.IsCompleted ? "step-completed" : "step-pending") @(selectedSteps.Contains(index) ? "step-selected" : "")">
<label class="step-checkbox-label">
<input type="checkbox"
class="step-checkbox"
checked="@selectedSteps.Contains(index)"
disabled="@step.IsCompleted"
@onchange="() => ToggleStepSelection(index)" />
<span class="checkmark @(step.IsCompleted || selectedSteps.Contains(index) ? "checkmark-completed" : "")">
@if (step.IsCompleted || selectedSteps.Contains(index))
{
<span class="check-icon">✓</span>
}
</span>
</label>
<span class="step-description @(step.IsCompleted ? "description-completed" : "")">@step.Description</span>
<span class="step-status @(step.IsCompleted ? "status-completed" : "status-pending")">
@(step.IsCompleted ? "Done" : "Pending")
</span>
</div>
}
</div>
<div class="plan-actions">
<button class="plan-button plan-button-confirm" @onclick="ConfirmPlan" disabled="@(selectedSteps.Count == 0)">
Confirm Selected (@selectedSteps.Count)
</button>
<button class="plan-button plan-button-reject" @onclick="RejectPlan">
Reject
</button>
</div>
</div>
}
else if (CurrentPlan is not null && !AwaitingConfirmation && !WasRejected)
{
<div class="plan-card @(CurrentPlan.IsComplete ? "plan-completed" : "plan-executing")">
<div class="plan-header">
<h3>@(CurrentPlan.IsComplete ? "Plan Completed" : "Executing Plan")</h3>
@if (CurrentPlan.IsComplete)
{
<span class="completion-badge">✓ All Done</span>
}
else
{
<div class="plan-progress">
<span class="progress-count">@CurrentPlan.CompletedCount / @CurrentPlan.TotalCount completed</span>
</div>
}
</div>
<div class="plan-steps">
@foreach (var step in CurrentPlan.Steps)
{
<div class="plan-step @(step.IsCompleted ? "step-completed" : "step-pending")">
<span class="checkmark @(step.IsCompleted ? "checkmark-completed" : "")">
@if (step.IsCompleted)
{
<span class="check-icon">✓</span>
}
</span>
<span class="step-description @(step.IsCompleted ? "description-completed" : "")">@step.Description</span>
<span class="step-status @(step.IsCompleted ? "status-completed" : "status-pending")">
@(step.IsCompleted ? "Done" : "Pending")
</span>
</div>
}
</div>
</div>
}
else if (WasRejected)
{
<div class="plan-card plan-rejected">
<div class="plan-header">
<h3>Plan Rejected</h3>
<span class="rejection-badge">✗ Cancelled</span>
</div>
</div>
}
@code {
[CascadingParameter]
public InvocationContext Invocation { get; set; } = default!;
[CascadingParameter]
public MessageListContext? MessageListContext { get; set; }
[CascadingParameter]
public IAgentBoundaryContext? BoundaryContext { get; set; }
private Plan? CurrentPlan;
private HashSet<int> selectedSteps = new();
private bool AwaitingConfirmation;
private bool WasRejected;
private bool IsWaitingForPlan => CurrentPlan is null && !WasRejected;
private ResponseUpdateSubscription? _responseSubscription;
private string? _confirmPlanCallId;
protected override void OnInitialized()
{
// The plan comes from the create_plan function result
if (Invocation?.HasResult == true)
{
TryParsePlanFromResult();
}
else if (Invocation is not null)
{
// Subscribe to wait for the result
Invocation.ResultArrived += OnCreatePlanResultArrived;
}
// Subscribe to response updates to detect confirm_plan and update_plan_step calls
if (BoundaryContext is not null)
{
_responseSubscription = BoundaryContext.SubscribeToResponseUpdates(OnResponseUpdate);
}
}
private void OnCreatePlanResultArrived()
{
TryParsePlanFromResult();
InvokeAsync(StateHasChanged);
}
private void TryParsePlanFromResult()
{
var plan = Invocation?.GetResult<Plan>();
if (plan is not null)
{
CurrentPlan = plan;
InitializeSelectedSteps();
}
}
private void InitializeSelectedSteps()
{
// Select all pending steps by default
if (CurrentPlan is not null)
{
selectedSteps.Clear();
for (int i = 0; i < CurrentPlan.Steps.Count; i++)
{
if (!CurrentPlan.Steps[i].IsCompleted)
{
selectedSteps.Add(i);
}
}
}
}
private void OnResponseUpdate()
{
// Check the current update for tool calls
var update = BoundaryContext?.CurrentUpdate;
if (update is null)
{
return;
}
if (update.Contents is not null)
{
foreach (var content in update.Contents)
{
if (content is FunctionCallContent call)
{
if (string.Equals(call.Name, "confirm_plan", StringComparison.OrdinalIgnoreCase))
{
HandleConfirmPlanCall(call);
}
else if (string.Equals(call.Name, "update_plan_step", StringComparison.OrdinalIgnoreCase))
{
HandleUpdatePlanStepCall(call);
}
}
}
}
InvokeAsync(StateHasChanged);
}
private void HandleConfirmPlanCall(FunctionCallContent call)
{
// When confirm_plan is called, show the confirmation UI
if (!AwaitingConfirmation && !WasRejected && _confirmPlanCallId is null)
{
_confirmPlanCallId = call.CallId;
AwaitingConfirmation = true;
}
}
private void HandleUpdatePlanStepCall(FunctionCallContent call)
{
if (CurrentPlan is null)
{
return;
}
// Get the index and status from arguments
if (call.Arguments is null)
{
return;
}
int? index = null;
string? status = null;
string? description = null;
if (call.Arguments.TryGetValue("index", out var indexObj))
{
if (indexObj is int i) index = i;
else if (indexObj is long l) index = (int)l;
else if (indexObj is JsonElement je && je.ValueKind == JsonValueKind.Number) index = je.GetInt32();
}
if (call.Arguments.TryGetValue("status", out var statusObj))
{
if (statusObj is string s) status = s;
else if (statusObj is JsonElement je && je.ValueKind == JsonValueKind.String) status = je.GetString();
}
if (call.Arguments.TryGetValue("description", out var descObj))
{
if (descObj is string s) description = s;
else if (descObj is JsonElement je && je.ValueKind == JsonValueKind.String) description = je.GetString();
}
// Apply the update
if (index.HasValue && index.Value >= 0 && index.Value < CurrentPlan.Steps.Count)
{
if (status is not null)
{
CurrentPlan.Steps[index.Value].Status = status.ToLowerInvariant();
}
if (description is not null)
{
CurrentPlan.Steps[index.Value].Description = description;
}
}
}
private void ToggleStepSelection(int index)
{
if (selectedSteps.Contains(index))
{
selectedSteps.Remove(index);
}
else
{
selectedSteps.Add(index);
}
StateHasChanged();
}
private void ConfirmPlan()
{
AwaitingConfirmation = false;
var result = new PlanConfirmationResult
{
Confirmed = true,
SelectedStepIndices = selectedSteps.ToList()
};
BoundaryContext?.ProvideResponse("confirm_plan", result);
}
private void RejectPlan()
{
WasRejected = true;
AwaitingConfirmation = false;
var result = new PlanConfirmationResult
{
Confirmed = false,
SelectedStepIndices = []
};
BoundaryContext?.ProvideResponse("confirm_plan", result);
}
public void Dispose()
{
if (Invocation is not null)
{
Invocation.ResultArrived -= OnCreatePlanResultArrived;
}
_responseSubscription?.Dispose();
}
}
@@ -0,0 +1,244 @@
.plan-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 20px;
color: white;
max-width: 420px;
box-shadow: 0 10px 40px rgba(102, 126, 234, 0.4);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.plan-card.plan-active {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.plan-card.plan-completed {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
}
.plan-card.plan-rejected {
background: linear-gradient(135deg, #f5576c 0%, #f093fb 100%);
}
.plan-card.plan-loading {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.plan-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.plan-header h3 {
margin: 0;
font-size: 1.3rem;
font-weight: 600;
}
.plan-progress .progress-count {
font-size: 0.85rem;
opacity: 0.9;
}
.completion-badge,
.rejection-badge {
font-size: 0.9rem;
padding: 4px 10px;
border-radius: 12px;
font-weight: 500;
}
.completion-badge {
background: rgba(255, 255, 255, 0.25);
}
.rejection-badge {
background: rgba(255, 255, 255, 0.25);
}
.plan-steps {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.plan-step {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.15);
border-radius: 10px;
transition: all 0.2s ease;
}
.plan-step.step-selected {
background: rgba(255, 255, 255, 0.25);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
}
.plan-step.step-completed {
background: rgba(255, 255, 255, 0.1);
}
.step-checkbox-label {
display: flex;
align-items: center;
cursor: pointer;
}
.step-checkbox {
position: absolute;
opacity: 0;
cursor: pointer;
}
.checkmark {
width: 24px;
height: 24px;
border: 2px solid rgba(255, 255, 255, 0.6);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
transition: all 0.2s ease;
flex-shrink: 0;
}
.step-checkbox:checked + .checkmark {
background: rgba(255, 255, 255, 0.3);
border-color: white;
}
.checkmark-completed {
background: rgba(255, 255, 255, 0.3);
border-color: white;
}
.check-icon {
font-size: 14px;
font-weight: bold;
}
.step-description {
flex: 1;
font-size: 0.95rem;
line-height: 1.4;
}
.description-completed {
opacity: 0.7;
text-decoration: line-through;
}
.step-status {
font-size: 0.75rem;
padding: 3px 8px;
border-radius: 6px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-pending {
background: rgba(255, 255, 255, 0.2);
}
.status-completed {
background: rgba(255, 255, 255, 0.3);
}
.plan-actions {
display: flex;
gap: 12px;
margin-top: 16px;
}
.plan-button {
flex: 1;
padding: 12px 20px;
border: none;
border-radius: 10px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.plan-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.plan-button-confirm {
background: rgba(255, 255, 255, 0.25);
color: white;
}
.plan-button-confirm:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.35);
transform: translateY(-1px);
}
.plan-button-reject {
background: rgba(0, 0, 0, 0.15);
color: white;
}
.plan-button-reject:hover {
background: rgba(0, 0, 0, 0.25);
transform: translateY(-1px);
}
.plan-button-skeleton {
background: rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.5);
}
/* Loading/Skeleton state */
.skeleton-text {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.2) 25%, rgba(255, 255, 255, 0.4) 50%, rgba(255, 255, 255, 0.2) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
border-radius: 4px;
display: inline-block;
}
.skeleton-progress {
width: 100px;
height: 1rem;
}
.skeleton-step {
padding: 14px 12px;
}
.skeleton-checkbox {
width: 24px;
height: 24px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.2) 25%, rgba(255, 255, 255, 0.4) 50%, rgba(255, 255, 255, 0.2) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
border-radius: 6px;
flex-shrink: 0;
}
.skeleton-description {
width: 200px;
height: 1rem;
}
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@@ -0,0 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
/// <summary>
/// Result of user's plan confirmation decision.
/// </summary>
public class PlanConfirmationResult
{
public bool Confirmed { get; set; }
public List<int> SelectedStepIndices { get; set; } = [];
}
@@ -0,0 +1,87 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.RegularExpressions;
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
/// <summary>
/// Applies JSON Patch operations to a Plan.
/// Uses hardcoded path parsing for the expected paths:
/// - /steps/{index}/status
/// - /steps/{index}/description
/// </summary>
public static partial class PlanPatcher
{
// Regex to match paths like /steps/0/status or /steps/1/description
[GeneratedRegex(@"^/steps/(\d+)/(status|description)$")]
private static partial Regex StepPropertyPathRegex();
/// <summary>
/// Applies a JSON Patch operation to the plan.
/// Only supports "replace" operations on /steps/{index}/status and /steps/{index}/description paths.
/// </summary>
/// <param name="plan">The plan to modify.</param>
/// <param name="operation">The patch operation to apply.</param>
/// <returns>True if the operation was applied successfully, false otherwise.</returns>
public static bool ApplyPatch(Plan plan, JsonPatchOperation operation)
{
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(operation);
// Only support "replace" operations
if (!string.Equals(operation.Op, "replace", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var match = StepPropertyPathRegex().Match(operation.Path);
if (!match.Success)
{
return false;
}
if (!int.TryParse(match.Groups[1].Value, out int stepIndex))
{
return false;
}
if (stepIndex < 0 || stepIndex >= plan.Steps.Count)
{
return false;
}
var propertyName = match.Groups[2].Value;
var step = plan.Steps[stepIndex];
switch (propertyName.ToUpperInvariant())
{
case "STATUS":
step.Status = operation.Value?.ToString() ?? "pending";
return true;
case "DESCRIPTION":
step.Description = operation.Value?.ToString() ?? string.Empty;
return true;
default:
return false;
}
}
/// <summary>
/// Applies multiple JSON Patch operations to the plan.
/// </summary>
/// <param name="plan">The plan to modify.</param>
/// <param name="operations">The patch operations to apply.</param>
/// <returns>The number of operations successfully applied.</returns>
public static int ApplyPatches(Plan plan, IEnumerable<JsonPatchOperation> operations)
{
int appliedCount = 0;
foreach (var operation in operations)
{
if (ApplyPatch(plan, operation))
{
appliedCount++;
}
}
return appliedCount;
}
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
/// <summary>
/// Represents a single step in a plan.
/// </summary>
public sealed class Step
{
/// <summary>
/// The description of the step.
/// </summary>
[JsonPropertyName("description")]
public string Description { get; set; } = string.Empty;
/// <summary>
/// The status of the step (pending or completed).
/// </summary>
[JsonPropertyName("status")]
public string Status { get; set; } = "pending";
/// <summary>
/// Gets whether this step is completed.
/// </summary>
[JsonIgnore]
public bool IsCompleted => string.Equals(this.Status, "completed", StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
namespace AGUIDojoClient.Components.Demos.PredictiveStateUpdates;
/// <summary>
/// Represents the result of the confirm_changes frontend tool.
/// </summary>
public sealed class ConfirmChangesResult
{
/// <summary>
/// Gets or sets a value indicating whether the user confirmed the changes.
/// </summary>
public bool Confirmed { get; set; }
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoClient.Components.Demos.PredictiveStateUpdates;
/// <summary>
/// Represents the document state for the Predictive State Updates demo.
/// This model mirrors the server-side DocumentState and is updated via streaming state updates.
/// </summary>
public sealed class DocumentState
{
/// <summary>
/// Gets or sets the document content in Markdown format.
/// </summary>
[JsonPropertyName("document")]
public string Document { get; set; } = string.Empty;
}
@@ -0,0 +1,249 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@using Microsoft.AspNetCore.Components.AI
@using Microsoft.Agents.AI
@using Microsoft.Extensions.AI
@using Microsoft.Extensions.DependencyInjection
@using AGUIDojoClient.Components.Shared
@using System.Text.Json
@using System.ComponentModel
@implements IDisposable
@inject IServiceProvider ServiceProvider
<PageTitle>AI Document Editor</PageTitle>
<div class="predictive-state-layout">
@* Left panel: Document editor *@
<div class="document-panel">
<div class="document-header">
<h2>Document Editor</h2>
@if (isStreaming)
{
<span class="streaming-indicator">
<span class="streaming-dot"></span>
Writing...
</span>
}
</div>
<div class="document-content">
@if (string.IsNullOrWhiteSpace(currentDocument))
{
<div class="document-placeholder">
Write whatever you want here in Markdown format...
</div>
}
else
{
<pre class="document-text">@currentDocument</pre>
}
</div>
</div>
@* Right panel: Chat sidebar *@
<div class="chat-panel">
<div class="chat-header">
<div class="chat-title">AI Document Editor</div>
<button class="new-chat-button" @onclick="ResetConversation">
<span class="button-icon">+</span> New chat
</button>
</div>
<AgentBoundary Agent="@agent" OnContextCreated="OnContextCreated">
<div class="chat-content">
<Messages>
<ContentTemplates>
<TextTemplate />
<FunctionCallTemplate />
<FunctionResultTemplate />
<ErrorTemplate />
<DataContentTemplate />
</ContentTemplates>
</Messages>
<AgentLoadingIndicator />
</div>
<AgentState TState="DocumentState"
CurrentState="@currentDocumentState"
OnSnapshot="@DeserializeDocumentState"
CurrentStateChanged="@OnDocumentStateChanged" />
<div class="chat-input-container">
<AgentSuggestions Suggestions="@suggestions" />
<AgentInput Placeholder="Ask the AI to write or edit..." />
</div>
</AgentBoundary>
</div>
</div>
@* Confirmation modal *@
@if (awaitingConfirmation)
{
<div class="confirmation-overlay">
<div class="confirmation-modal">
<h3>Confirm Changes</h3>
<p>Do you want to accept the changes?</p>
<div class="confirmation-actions">
<button class="confirm-button reject" @onclick="RejectChanges">
Reject
</button>
<button class="confirm-button accept" @onclick="ConfirmChanges">
Confirm
</button>
</div>
</div>
</div>
}
@code {
private AIAgent? agent;
private IAgentBoundaryContext? boundaryContext;
private ResponseUpdateSubscription? responseSubscription;
private DocumentState? currentDocumentState;
private string currentDocument = string.Empty;
private string previousDocument = string.Empty;
private bool isStreaming;
private bool awaitingConfirmation;
private Suggestion[] suggestions = [
new Suggestion("Write a pirate story", new ChatMessage(ChatRole.User, "Please write a story about a pirate named Candy Beard")),
new Suggestion("Write a mermaid story", new ChatMessage(ChatRole.User, "Please write a story about a mermaid named Pearl")),
new Suggestion("Add character", new ChatMessage(ChatRole.User, "Add a new character to the story"))
];
[Parameter]
public string ScenarioId { get; set; } = "predictive_state_updates";
protected override void OnInitialized()
{
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("predictive-state-updates");
}
private void OnContextCreated(IAgentBoundaryContext context)
{
boundaryContext = context;
// Register the confirm_changes frontend tool
var confirmChangesTool = AIFunctionFactory.Create(
() => ConfirmChangesAsync(context),
"confirm_changes",
"Ask the user to confirm or reject the document changes.");
context.RegisterTools(confirmChangesTool);
// Subscribe to response updates to detect when streaming starts/stops
responseSubscription = context.SubscribeToResponseUpdates(OnResponseUpdate);
}
private void OnResponseUpdate()
{
var update = boundaryContext?.CurrentUpdate;
if (update is null)
{
return;
}
// Check for function calls to detect confirm_changes
if (update.Contents is not null)
{
foreach (var content in update.Contents)
{
if (content is FunctionCallContent call &&
string.Equals(call.Name, "confirm_changes", StringComparison.OrdinalIgnoreCase))
{
// Show confirmation dialog when confirm_changes is called
awaitingConfirmation = true;
isStreaming = false;
}
}
}
InvokeAsync(StateHasChanged);
}
private DocumentState? DeserializeDocumentState(ReadOnlyMemory<byte> data)
{
try
{
return JsonSerializer.Deserialize<DocumentState>(data.Span);
}
catch
{
return null;
}
}
private void OnDocumentStateChanged(DocumentState? state)
{
if (state is null)
{
return;
}
currentDocumentState = state;
// Store the previous document before updating (for reject functionality)
if (string.IsNullOrEmpty(previousDocument) && !string.IsNullOrEmpty(currentDocument))
{
previousDocument = currentDocument;
}
// Check if we're starting to stream (document changed)
if (currentDocument != state.Document)
{
isStreaming = true;
}
currentDocument = state.Document;
StateHasChanged();
}
/// <summary>
/// Frontend tool that waits for user confirmation via the UI.
/// </summary>
[Description("Ask the user to confirm or reject the document changes.")]
private static async Task<ConfirmChangesResult> ConfirmChangesAsync(IAgentBoundaryContext context)
{
// Wait for the user to click Confirm or Reject
var response = await context.WaitForResponse("confirm_changes");
return (ConfirmChangesResult)response;
}
private void ConfirmChanges()
{
awaitingConfirmation = false;
isStreaming = false;
// Update the previous document to the current one (changes accepted)
previousDocument = currentDocument;
boundaryContext?.ProvideResponse("confirm_changes", new ConfirmChangesResult { Confirmed = true });
StateHasChanged();
}
private void RejectChanges()
{
awaitingConfirmation = false;
isStreaming = false;
// Revert to the previous document
currentDocument = previousDocument;
boundaryContext?.ProvideResponse("confirm_changes", new ConfirmChangesResult { Confirmed = false });
StateHasChanged();
}
private void ResetConversation()
{
currentDocument = string.Empty;
previousDocument = string.Empty;
currentDocumentState = null;
isStreaming = false;
awaitingConfirmation = false;
StateHasChanged();
}
public void Dispose()
{
responseSubscription?.Dispose();
}
}
@@ -0,0 +1,242 @@
/* Predictive State Updates Demo Styles */
/* Main layout - two column */
.predictive-state-layout {
display: flex;
height: 100%;
width: 100%;
background: #f5f5f5;
}
/* Document Panel (Left) */
.document-panel {
flex: 1;
display: flex;
flex-direction: column;
background: #ffffff;
border-right: 1px solid #e0e0e0;
}
.document-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid #e0e0e0;
background: #fafafa;
}
.document-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #333;
}
.streaming-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #6366f1;
font-weight: 500;
}
.streaming-dot {
width: 8px;
height: 8px;
background: #6366f1;
border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.8);
}
}
.document-content {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.document-placeholder {
color: #9ca3af;
font-style: italic;
font-size: 16px;
}
.document-text {
margin: 0;
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
line-height: 1.7;
color: #333;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Chat Panel (Right) */
.chat-panel {
width: 400px;
min-width: 350px;
display: flex;
flex-direction: column;
background: #ffffff;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
}
.chat-title {
font-size: 16px;
font-weight: 600;
color: #111827;
}
.new-chat-button {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
color: #374151;
cursor: pointer;
transition: all 0.15s ease;
}
.new-chat-button:hover {
background: #e5e7eb;
border-color: #d1d5db;
}
.button-icon {
font-size: 16px;
font-weight: 600;
}
.chat-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.chat-input-container {
padding: 12px 16px;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
/* Confirmation Modal */
.confirmation-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.confirmation-modal {
background: #ffffff;
border-radius: 12px;
padding: 24px 32px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
min-width: 320px;
text-align: center;
}
.confirmation-modal h3 {
margin: 0 0 12px 0;
font-size: 18px;
font-weight: 600;
color: #111827;
}
.confirmation-modal p {
margin: 0 0 24px 0;
font-size: 14px;
color: #6b7280;
}
.confirmation-actions {
display: flex;
gap: 12px;
justify-content: center;
}
.confirm-button {
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border: none;
}
.confirm-button.reject {
background: #f3f4f6;
color: #374151;
border: 1px solid #e5e7eb;
}
.confirm-button.reject:hover {
background: #e5e7eb;
}
.confirm-button.accept {
background: #6366f1;
color: #ffffff;
}
.confirm-button.accept:hover {
background: #4f46e5;
}
/* Agent Suggestions styling overrides */
::deep .agent-suggestions {
margin-bottom: 12px;
}
::deep .agent-suggestions .suggestion-button {
background: #f3f4f6;
border: 1px solid #e5e7eb;
padding: 8px 14px;
border-radius: 20px;
font-size: 13px;
color: #374151;
cursor: pointer;
transition: all 0.15s ease;
}
::deep .agent-suggestions .suggestion-button:hover:not(:disabled) {
background: #e5e7eb;
border-color: #d1d5db;
}
::deep .agent-suggestions .suggestion-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoClient.Components.Demos.SharedState;
public sealed class Ingredient
{
[JsonPropertyName("icon")]
public string Icon { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("amount")]
public string Amount { get; set; } = string.Empty;
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoClient.Components.Demos.SharedState;
public sealed class Recipe
{
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("skill_level")]
public string SkillLevel { get; set; } = string.Empty;
[JsonPropertyName("cooking_time")]
public string CookingTime { get; set; } = string.Empty;
[JsonPropertyName("special_preferences")]
public List<string> SpecialPreferences { get; set; } = [];
[JsonPropertyName("ingredients")]
public List<Ingredient> Ingredients { get; set; } = [];
[JsonPropertyName("instructions")]
public List<string> Instructions { get; set; } = [];
}
@@ -0,0 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoClient.Components.Demos.SharedState;
public sealed class RecipeResponse
{
[JsonPropertyName("recipe")]
public Recipe Recipe { get; set; } = new();
}
@@ -0,0 +1,289 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@using Microsoft.AspNetCore.Components.AI
@using Microsoft.Agents.AI
@using Microsoft.Extensions.AI
@using Microsoft.Extensions.DependencyInjection
@using AGUIDojoClient.Components.Shared
@using System.Text.Json
@inject IServiceProvider ServiceProvider
<PageTitle>Shared State</PageTitle>
<div class="shared-state-layout">
<AgentBoundary Agent="@agent" OnContextCreated="@OnContextCreated">
<AgentState TState="Recipe"
CurrentState="@currentRecipe"
OnSnapshot="@DeserializeRecipe"
CurrentStateChanged="@OnRecipeChanged">
<div class="recipe-panel-container">
<div class="recipe-panel">
<div class="recipe-header">
<input type="text" class="recipe-title" @bind="currentRecipe.Title" @bind:event="oninput" placeholder="Recipe name" />
<div class="recipe-meta">
<div class="meta-item">
<span class="meta-icon">🕒</span>
<select @bind="currentRecipe.CookingTime">
<option value="5 min">5 min</option>
<option value="15 min">15 min</option>
<option value="30 min">30 min</option>
<option value="45 min">45 min</option>
<option value="60+ min">60+ min</option>
</select>
</div>
<div class="meta-item">
<span class="meta-icon">🏆</span>
<select @bind="currentRecipe.SkillLevel">
<option value="Beginner">Beginner</option>
<option value="Intermediate">Intermediate</option>
<option value="Advanced">Advanced</option>
</select>
</div>
</div>
</div>
<div class="preferences-section">
<h2>Dietary Preferences</h2>
<div class="preferences-grid">
@foreach (var pref in dietaryPreferences)
{
<label class="preference-item">
<input type="checkbox"
checked="@currentRecipe.SpecialPreferences.Contains(pref)"
@onchange="@(e => TogglePreference(pref, (bool?)e.Value ?? false))" />
<span>@pref</span>
</label>
}
</div>
</div>
<div class="ingredients-section">
<div class="section-header">
<h2>Ingredients</h2>
<button class="add-button" @onclick="AddIngredient">+ Add Ingredient</button>
</div>
<div class="ingredients-list">
@for (int i = 0; i < currentRecipe.Ingredients.Count; i++)
{
var index = i;
var ingredient = currentRecipe.Ingredients[index];
<div class="ingredient-row">
<span class="ingredient-icon">@ingredient.Icon</span>
<div class="ingredient-inputs">
<input type="text" placeholder="Ingredient name"
value="@ingredient.Name"
@onchange="@(e => UpdateIngredientName(index, e.Value?.ToString() ?? ""))" />
<input type="text" placeholder="Amount"
value="@ingredient.Amount"
@onchange="@(e => UpdateIngredientAmount(index, e.Value?.ToString() ?? ""))" />
</div>
<button class="remove-button" @onclick="@(() => RemoveIngredient(index))">×</button>
</div>
}
</div>
</div>
<div class="instructions-section">
<div class="section-header">
<h2>Instructions</h2>
<button class="add-button" @onclick="AddInstruction">+ Add Step</button>
</div>
<div class="instructions-list">
@for (int i = 0; i < currentRecipe.Instructions.Count; i++)
{
var index = i;
<div class="instruction-row">
<span class="step-number">@(index + 1)</span>
<div class="instruction-input-wrapper">
<input type="text"
value="@currentRecipe.Instructions[index]"
@onchange="@(e => UpdateInstruction(index, e.Value?.ToString() ?? ""))" />
<button class="remove-button" @onclick="@(() => RemoveInstruction(index))">×</button>
</div>
</div>
}
</div>
</div>
<button class="improve-button" @onclick="ImproveWithAI" disabled="@isProcessing">
@(isProcessing ? "Please Wait..." : "Improve with AI")
</button>
</div>
</div>
<div class="chat-panel">
<div class="chat-header">
<span class="chat-title">AI Recipe Assistant</span>
</div>
<div class="chat-messages">
<Messages>
<ContentTemplates>
<TextTemplate />
<FunctionCallTemplate />
<FunctionResultTemplate />
<ErrorTemplate />
<DataContentTemplate />
</ContentTemplates>
</Messages>
<AgentLoadingIndicator />
</div>
<div class="chat-input-container">
<AgentInput Placeholder="Ask about your recipe..." />
</div>
</div>
</AgentState>
</AgentBoundary>
</div>
@code {
private AIAgent? agent;
private IAgentBoundaryContext? boundaryContext;
private Recipe currentRecipe = CreateDefaultRecipe();
private bool isProcessing;
private static readonly string[] dietaryPreferences = [
"High Protein", "Low Carb", "Spicy", "Budget-Friendly",
"One-Pot Meal", "Vegetarian", "Vegan"
];
[Parameter]
public string ScenarioId { get; set; } = "shared_state";
protected override void OnInitialized()
{
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("shared-state");
}
private void OnContextCreated(IAgentBoundaryContext context)
{
boundaryContext = context;
}
private static Recipe CreateDefaultRecipe() => new()
{
Title = "Make Your Recipe",
SkillLevel = "Intermediate",
CookingTime = "45 min",
SpecialPreferences = [],
Ingredients =
[
new Ingredient { Icon = "🥕", Name = "Carrots", Amount = "3 large, grated" },
new Ingredient { Icon = "🌾", Name = "All-Purpose Flour", Amount = "2 cups" }
],
Instructions =
[
"Preheat oven to 350°F (175°C)"
]
};
private Recipe? DeserializeRecipe(ReadOnlyMemory<byte> data)
{
try
{
var response = JsonSerializer.Deserialize<RecipeResponse>(data.Span);
return response?.Recipe;
}
catch
{
return null;
}
}
private void OnRecipeChanged(Recipe? recipe)
{
if (recipe is not null)
{
currentRecipe = recipe;
isProcessing = false;
StateHasChanged();
}
}
private async Task ImproveWithAI()
{
if (boundaryContext is null)
{
return;
}
isProcessing = true;
StateHasChanged();
// Serialize current recipe state with wrapper
var stateWrapper = new { recipe = currentRecipe };
byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes(stateWrapper, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
// Create message with state attached as DataContent
var message = new ChatMessage(ChatRole.User,
[
new TextContent("Improve the recipe"),
new DataContent(stateBytes, "application/json")
]);
await boundaryContext.SendAsync(message);
}
private void TogglePreference(string preference, bool isChecked)
{
if (isChecked && !currentRecipe.SpecialPreferences.Contains(preference))
{
currentRecipe.SpecialPreferences.Add(preference);
}
else if (!isChecked)
{
currentRecipe.SpecialPreferences.Remove(preference);
}
}
private void AddIngredient()
{
currentRecipe.Ingredients.Add(new Ingredient { Icon = "🥄", Name = "", Amount = "" });
}
private void RemoveIngredient(int index)
{
if (index >= 0 && index < currentRecipe.Ingredients.Count)
{
currentRecipe.Ingredients.RemoveAt(index);
}
}
private void UpdateIngredientName(int index, string name)
{
if (index >= 0 && index < currentRecipe.Ingredients.Count)
{
currentRecipe.Ingredients[index].Name = name;
}
}
private void UpdateIngredientAmount(int index, string amount)
{
if (index >= 0 && index < currentRecipe.Ingredients.Count)
{
currentRecipe.Ingredients[index].Amount = amount;
}
}
private void AddInstruction()
{
currentRecipe.Instructions.Add("");
}
private void RemoveInstruction(int index)
{
if (index >= 0 && index < currentRecipe.Instructions.Count)
{
currentRecipe.Instructions.RemoveAt(index);
}
}
private void UpdateInstruction(int index, string instruction)
{
if (index >= 0 && index < currentRecipe.Instructions.Count)
{
currentRecipe.Instructions[index] = instruction;
}
}
}
@@ -0,0 +1,403 @@
/* Shared State Demo Layout */
.shared-state-layout {
display: flex;
height: 100%;
gap: 0;
background-color: #f5f5f5;
}
/* Recipe Panel Container - centers the card */
.recipe-panel-container {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 24px;
overflow-y: auto;
}
/* Recipe Panel - the actual card */
.recipe-panel {
display: flex;
flex-direction: column;
gap: 24px;
background-color: white;
border-radius: 16px;
padding: 32px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
width: 100%;
max-width: 700px;
}
/* Recipe Header */
.recipe-header {
display: flex;
flex-direction: column;
gap: 16px;
}
.recipe-title {
font-size: 28px;
font-weight: 600;
border: none;
padding: 0;
background: transparent;
color: #111827;
width: 100%;
}
.recipe-title:focus {
outline: none;
}
.recipe-title::placeholder {
color: #9ca3af;
}
.recipe-meta {
display: flex;
gap: 24px;
}
.meta-item {
display: flex;
align-items: center;
gap: 8px;
}
.meta-icon {
font-size: 18px;
}
.meta-item select {
padding: 4px 8px;
border: none;
background-color: transparent;
font-size: 14px;
color: #374151;
cursor: pointer;
appearance: auto;
min-width: 80px;
}
.meta-item select:focus {
outline: none;
}
/* Section Styles */
.preferences-section,
.ingredients-section,
.instructions-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.preferences-section h2,
.ingredients-section h2,
.instructions-section h2 {
font-size: 16px;
font-weight: 600;
color: #111827;
margin: 0;
padding-bottom: 8px;
border-bottom: 2px solid #f97316;
display: inline-block;
width: fit-content;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
/* Dietary Preferences */
.preferences-grid {
display: grid;
grid-template-columns: repeat(5, auto);
gap: 8px 32px;
justify-content: start;
}
.preference-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #374151;
cursor: pointer;
white-space: nowrap;
}
.preference-item input[type="checkbox"] {
width: 14px;
height: 14px;
accent-color: #3b82f6;
cursor: pointer;
margin: 0;
}
/* Ingredients */
.ingredients-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.ingredient-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background-color: white;
border: 1px solid #f0f0f0;
border-radius: 10px;
min-width: 0;
flex: 1 1 calc(33.333% - 8px);
max-width: calc(33.333% - 8px);
position: relative;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
transition: box-shadow 0.2s;
}
.ingredient-row:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* Hide ingredient remove button by default, show on hover */
.ingredient-row .remove-button {
position: absolute;
top: 4px;
right: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.ingredient-row:hover .remove-button {
opacity: 1;
}
.ingredient-icon {
font-size: 20px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background-color: #fef3e2;
border-radius: 50%;
}
.ingredient-inputs {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.ingredient-inputs input {
border: none;
background: transparent;
font-size: 14px;
color: #374151;
padding: 0;
width: 100%;
}
.ingredient-inputs input:first-child {
font-weight: 500;
color: #111827;
}
.ingredient-inputs input:last-child {
font-size: 13px;
color: #6b7280;
}
.ingredient-inputs input:focus {
outline: none;
}
.ingredient-inputs input::placeholder {
color: #9ca3af;
}
/* Instructions */
.instructions-list {
display: flex;
flex-direction: column;
gap: 0;
position: relative;
padding-left: 12px;
}
/* Vertical connecting line */
.instructions-list::before {
content: '';
position: absolute;
left: 23px;
top: 20px;
bottom: 20px;
width: 2px;
border-left: 2px dashed #fdba74;
}
.instruction-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 8px 0;
position: relative;
}
.step-number {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f97316;
color: white;
font-size: 12px;
font-weight: 600;
border-radius: 50%;
flex-shrink: 0;
margin-top: 8px;
position: relative;
z-index: 1;
}
/* Instruction input wrapper for positioning delete button inside */
.instruction-input-wrapper {
flex: 1;
position: relative;
}
.instruction-input-wrapper input,
.instruction-input-wrapper textarea {
width: 100%;
padding: 8px 32px 8px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 14px;
color: #374151;
resize: vertical;
min-height: 40px;
font-family: inherit;
box-sizing: border-box;
}
.instruction-input-wrapper input:focus,
.instruction-input-wrapper textarea:focus {
outline: none;
border-color: #f97316;
}
/* Instruction remove button - hidden by default, shown on hover, positioned inside textarea */
.instruction-input-wrapper .remove-button {
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
transition: opacity 0.2s;
z-index: 1;
}
.instruction-input-wrapper:hover .remove-button,
.instruction-input-wrapper:focus-within .remove-button {
opacity: 1;
}
/* Buttons */
.add-button {
padding: 8px 16px;
background-color: transparent;
border: 1px solid #f97316;
border-radius: 8px;
font-size: 14px;
color: #f97316;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.add-button:hover {
background-color: #fff7ed;
}
.remove-button {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
border: none;
font-size: 18px;
color: #9ca3af;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.remove-button:hover {
color: #ef4444;
}
.improve-button {
padding: 14px 32px;
background-color: #f97316;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
color: white;
cursor: pointer;
transition: background-color 0.2s;
align-self: center;
margin-top: 8px;
}
.improve-button:hover:not(:disabled) {
background-color: #ea580c;
}
.improve-button:disabled {
background-color: #fdba74;
cursor: not-allowed;
}
/* Chat Panel */
.chat-panel {
width: 320px;
display: flex;
flex-direction: column;
background-color: white;
border-left: 1px solid #e5e7eb;
flex-shrink: 0;
}
.chat-header {
padding: 16px 20px;
border-bottom: 1px solid #e5e7eb;
}
.chat-title {
font-size: 16px;
font-weight: 600;
color: #111827;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.chat-input-container {
padding: 16px;
border-top: 1px solid #e5e7eb;
}
@@ -0,0 +1,57 @@
// Copyright (c) Microsoft. All rights reserved.
namespace AGUIDojoClient.Components.Demos.ToolBasedGenerativeUI;
/// <summary>
/// Represents a haiku with Japanese and English translations, along with display properties.
/// </summary>
public class Haiku
{
/// <summary>
/// Gets or sets the three lines of the haiku in Japanese.
/// </summary>
public IReadOnlyList<string> Japanese { get; set; } = [];
/// <summary>
/// Gets or sets the three lines of the haiku translated to English.
/// </summary>
public IReadOnlyList<string> English { get; set; } = [];
/// <summary>
/// Gets or sets the name of the image associated with the haiku.
/// </summary>
public string? ImageName { get; set; }
/// <summary>
/// Gets or sets the CSS gradient for the haiku card background.
/// </summary>
public string Gradient { get; set; } = string.Empty;
/// <summary>
/// List of valid image names that can be used with haikus.
/// </summary>
public static readonly string[] ValidImageNames =
[
"Osaka_Castle_Turret_Stone_Wall_Pine_Trees_Daytime.jpg",
"Tokyo_Skyline_Night_Tokyo_Tower_Mount_Fuji_View.jpg",
"Itsukushima_Shrine_Miyajima_Floating_Torii_Gate_Sunset_Long_Exposure.jpg",
"Takachiho_Gorge_Waterfall_River_Lush_Greenery_Japan.jpg",
"Bonsai_Tree_Potted_Japanese_Art_Green_Foliage.jpeg",
"Shirakawa-go_Gassho-zukuri_Thatched_Roof_Village_Aerial_View.jpg",
"Ginkaku-ji_Silver_Pavilion_Kyoto_Japanese_Garden_Pond_Reflection.jpg",
"Senso-ji_Temple_Asakusa_Cherry_Blossoms_Kimono_Umbrella.jpg",
"Cherry_Blossoms_Sakura_Night_View_City_Lights_Japan.jpg",
"Mount_Fuji_Lake_Reflection_Cherry_Blossoms_Sakura_Spring.jpg"
];
/// <summary>
/// Creates a default placeholder haiku.
/// </summary>
public static Haiku CreatePlaceholder() => new()
{
Japanese = ["仮の句よ", "まっさらながら", "花を呼ぶ"],
English = ["A placeholder verse—", "even in a blank canvas,", "it beckons flowers."],
ImageName = null,
Gradient = string.Empty
};
}
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.AI;
using Microsoft.Extensions.AI;
namespace AGUIDojoClient.Components.Demos.ToolBasedGenerativeUI;
/// <summary>
/// Template for rendering generate_haiku function call content.
/// Renders a HaikuCard component inline in the chat messages.
/// </summary>
public class HaikuCallTemplate : ContentTemplateBase
{
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
public override void Attach(RenderHandle renderHandle)
{
// This component never renders anything by itself.
this.ChildContent = this.RenderHaikuCall;
}
public override Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
this.Context.RegisterContentTemplate(this);
return Task.CompletedTask;
}
/// <summary>
/// Determines if this template should handle the given content.
/// Matches FunctionCallContent for the generate_haiku function.
/// </summary>
public override bool When(ContentContext context)
{
// Only match FunctionCallContent for the generate_haiku function
return context.Content is FunctionCallContent call &&
string.Equals(call.Name, "generate_haiku", StringComparison.OrdinalIgnoreCase);
}
private RenderFragment RenderHaikuCall(ContentContext content) => builder =>
{
if (content.Content is FunctionCallContent call)
{
// Get the invocation context which tracks both call and result
var invocation = this.Context.GetOrCreateInvocation(call);
// Provide the invocation context to child components
builder.OpenComponent<CascadingValue<InvocationContext>>(0);
builder.AddComponentParameter(1, "Value", invocation);
builder.AddComponentParameter(2, "IsFixed", true);
builder.AddComponentParameter(3, "ChildContent", (RenderFragment)(innerBuilder =>
{
// Render the HaikuCard component which uses InvocationContext
innerBuilder.OpenComponent<HaikuCard>(0);
innerBuilder.CloseComponent();
}));
builder.CloseComponent();
}
};
}
@@ -0,0 +1,112 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@using Microsoft.AspNetCore.Components.AI
<div class="haiku-card @(IsLoading ? "haiku-loading" : "")" style="@GetBackgroundStyle()">
@if (IsLoading)
{
<div class="haiku-content">
<div class="haiku-lines">
<div class="haiku-line skeleton-line"></div>
<div class="haiku-line skeleton-line"></div>
<div class="haiku-line skeleton-line"></div>
</div>
</div>
}
else if (CurrentHaiku is not null)
{
<div class="haiku-content">
<div class="haiku-lines">
@for (int i = 0; i < CurrentHaiku.Japanese.Count && i < 3; i++)
{
var index = i;
<div class="haiku-line" style="animation-delay: @(index * 100)ms">
<p class="japanese-text" data-testid="haiku-japanese-line">@CurrentHaiku.Japanese[index]</p>
@if (index < CurrentHaiku.English.Count)
{
<p class="english-text" data-testid="haiku-english-line">@CurrentHaiku.English[index]</p>
}
</div>
}
</div>
@if (!string.IsNullOrEmpty(CurrentHaiku.ImageName))
{
<div class="haiku-image-container">
<img src="images/@CurrentHaiku.ImageName"
alt="@CurrentHaiku.ImageName"
class="haiku-image"
data-testid="haiku-image" />
</div>
}
</div>
}
</div>
@code {
/// <summary>
/// Gets the haiku to display from the cascaded InvocationContext.
/// </summary>
[CascadingParameter]
public InvocationContext? Invocation { get; set; }
/// <summary>
/// Gets or sets the haiku to display directly (used when not rendering from a tool call).
/// </summary>
[Parameter]
public Haiku? Haiku { get; set; }
private Haiku? CurrentHaiku => Haiku ?? GetHaikuFromInvocation();
private bool IsLoading => CurrentHaiku is null;
protected override void OnInitialized()
{
if (Invocation is not null && !Invocation.HasResult)
{
Invocation.ResultArrived += OnResultArrived;
}
}
private void OnResultArrived()
{
InvokeAsync(StateHasChanged);
}
private Haiku? GetHaikuFromInvocation()
{
if (Invocation?.HasResult == true)
{
return Invocation.GetResult<Haiku>();
}
// Try to get partial data from arguments while streaming
if (Invocation is not null)
{
var japanese = Invocation.GetArgument<string[]>("japanese");
var english = Invocation.GetArgument<string[]>("english");
var imageName = Invocation.GetArgument<string>("image_name");
var gradient = Invocation.GetArgument<string>("gradient");
if (japanese is not null && japanese.Length > 0)
{
return new Haiku
{
Japanese = japanese,
English = english ?? [],
ImageName = imageName,
Gradient = gradient ?? string.Empty
};
}
}
return null;
}
private string GetBackgroundStyle()
{
if (!string.IsNullOrEmpty(CurrentHaiku?.Gradient))
{
return $"background: {CurrentHaiku.Gradient};";
}
return string.Empty;
}
}
@@ -0,0 +1,150 @@
/* Haiku Card Styles */
.haiku-card {
background: linear-gradient(120deg, #ffffff 0%, #fdfdfd 50%, #ffffff 100%);
background-size: 200% 200%;
animation: animated-gradient 10s ease infinite;
border: 1px solid #dee2e6;
border-top: 10px solid #ff6f61;
padding: 2rem 2.5rem;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.07),
inset 0 1px 2px rgba(0, 0, 0, 0.01),
0 0 15px rgba(255, 111, 97, 0.25);
text-align: center;
max-width: 600px;
margin: 1.5rem auto;
transition: transform 0.35s ease, box-shadow 0.35s ease, border-top-width 0.35s ease, border-top-color 0.35s ease;
}
.haiku-card:hover {
transform: translateY(-8px) scale(1.03);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1),
inset 0 1px 2px rgba(0, 0, 0, 0.01),
0 0 25px rgba(255, 91, 74, 0.5);
border-top-width: 14px;
border-top-color: #ff5b4a;
}
.haiku-card.haiku-loading {
min-height: 250px;
}
@keyframes animated-gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes fade-slide-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Haiku Content */
.haiku-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.haiku-lines {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
width: 100%;
}
.haiku-line {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
animation: fade-slide-in 0.5s ease-out forwards;
opacity: 0;
}
.japanese-text {
font-family: serif;
font-weight: bold;
font-size: 2.5rem;
background: linear-gradient(to right, #1e293b, #475569);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: 0.05em;
margin: 0;
}
.english-text {
font-weight: 300;
font-size: 1rem;
color: #64748b;
font-style: italic;
max-width: 400px;
margin: 0;
}
/* Skeleton Loading */
.skeleton-line {
width: 80%;
height: 3rem;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
border-radius: 8px;
margin: 0.5rem 0;
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Image Container */
.haiku-image-container {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #e2e8f0;
width: 100%;
}
.haiku-image {
width: 100%;
max-height: 320px;
object-fit: cover;
border-radius: 1rem;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
transition: transform 0.5s ease;
}
.haiku-image:hover {
transform: scale(1.02);
}
/* Responsive Styles */
@media (max-width: 768px) {
.haiku-card {
padding: 1.5rem;
margin: 1rem;
}
.japanese-text {
font-size: 1.75rem;
}
.english-text {
font-size: 0.875rem;
}
.haiku-image {
max-height: 200px;
}
}
@@ -0,0 +1,104 @@
@* Copyright (c) Microsoft. All rights reserved. *@
<div class="haiku-carousel-container" data-testid="haiku-carousel">
@if (Haikus.Count > 1)
{
<button class="carousel-button carousel-prev" @onclick="PreviousHaiku" disabled="@(currentIndex == 0)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
}
<div class="carousel-content">
@if (Haikus.Count > 0 && currentIndex < Haikus.Count)
{
<div class="carousel-item" data-testid="carousel-item-@currentIndex">
<HaikuCard Haiku="@Haikus[currentIndex]" />
</div>
}
</div>
@if (Haikus.Count > 1)
{
<button class="carousel-button carousel-next" @onclick="NextHaiku" disabled="@(currentIndex >= Haikus.Count - 1)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
}
</div>
@if (Haikus.Count > 1)
{
<div class="carousel-indicators">
@for (int i = 0; i < Haikus.Count; i++)
{
var index = i;
<button class="indicator @(index == currentIndex ? "active" : "")"
@onclick="() => GoToHaiku(index)">
</button>
}
</div>
}
@code {
private int currentIndex = 0;
/// <summary>
/// Gets or sets the list of haikus to display in the carousel.
/// </summary>
[Parameter]
public IReadOnlyList<Haiku> Haikus { get; set; } = [];
/// <summary>
/// Event callback when the current haiku index changes.
/// </summary>
[Parameter]
public EventCallback<int> OnIndexChanged { get; set; }
protected override void OnParametersSet()
{
// Reset to first haiku when new haiku is added at the beginning
if (Haikus.Count > 0 && currentIndex >= Haikus.Count)
{
currentIndex = 0;
}
}
private async Task NextHaiku()
{
if (currentIndex < Haikus.Count - 1)
{
currentIndex++;
await OnIndexChanged.InvokeAsync(currentIndex);
}
}
private async Task PreviousHaiku()
{
if (currentIndex > 0)
{
currentIndex--;
await OnIndexChanged.InvokeAsync(currentIndex);
}
}
private async Task GoToHaiku(int index)
{
if (index >= 0 && index < Haikus.Count)
{
currentIndex = index;
await OnIndexChanged.InvokeAsync(currentIndex);
}
}
/// <summary>
/// Resets the carousel to show the first (newest) haiku.
/// </summary>
public void ResetToFirst()
{
currentIndex = 0;
StateHasChanged();
}
}
@@ -0,0 +1,113 @@
/* Haiku Carousel Styles */
.haiku-carousel-container {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
width: 100%;
max-width: 800px;
margin: 0 auto;
padding: 1rem;
}
.carousel-content {
flex: 1;
display: flex;
justify-content: center;
overflow: hidden;
}
.carousel-item {
width: 100%;
animation: fade-in 0.3s ease-out;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Navigation Buttons */
.carousel-button {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid #e2e8f0;
background: white;
color: #64748b;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.carousel-button:hover:not(:disabled) {
background: #f8fafc;
border-color: #ff6f61;
color: #ff6f61;
box-shadow: 0 4px 12px rgba(255, 111, 97, 0.2);
}
.carousel-button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.carousel-button svg {
width: 24px;
height: 24px;
}
/* Carousel Indicators */
.carousel-indicators {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-top: 1rem;
}
.indicator {
width: 10px;
height: 10px;
border-radius: 50%;
border: none;
background: #e2e8f0;
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
}
.indicator:hover {
background: #cbd5e1;
}
.indicator.active {
background: #ff6f61;
transform: scale(1.2);
}
/* Responsive Styles */
@media (max-width: 768px) {
.haiku-carousel-container {
padding: 0.5rem;
gap: 0.5rem;
}
.carousel-button {
width: 36px;
height: 36px;
}
.carousel-button svg {
width: 18px;
height: 18px;
}
}
@@ -0,0 +1,118 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@using Microsoft.AspNetCore.Components.AI
@using Microsoft.Agents.AI
@using Microsoft.Extensions.AI
@using Microsoft.Extensions.DependencyInjection
@using AGUIDojoClient.Components.Shared
@using System.ComponentModel
@inject IServiceProvider ServiceProvider
<PageTitle>Tool Based Generative UI</PageTitle>
<div class="tool-generative-ui-layout">
<div class="main-display">
<HaikuCarousel @ref="carouselRef" Haikus="@haikus" />
</div>
<div class="chat-panel">
<div class="chat-header">
<div class="chat-title">Haiku Generator</div>
<button class="new-chat-button" @onclick="ResetConversation">
<span class="button-icon">+</span> New chat
</button>
</div>
<AgentBoundary Agent="@agent" OnContextCreated="OnContextCreated">
<div class="chat-content">
<Messages>
<ContentTemplates>
<HaikuCallTemplate />
<TextTemplate />
<FunctionCallTemplate />
<FunctionResultTemplate />
<ErrorTemplate />
<DataContentTemplate />
</ContentTemplates>
</Messages>
<AgentLoadingIndicator />
</div>
<div class="chat-input-container">
<AgentSuggestions Suggestions="@suggestions" />
<AgentInput Placeholder="Ask for a haiku..." />
</div>
</AgentBoundary>
</div>
</div>
@code {
private AIAgent? agent;
private HaikuCarousel? carouselRef;
private List<Haiku> haikus = [Haiku.CreatePlaceholder()];
private Suggestion[] suggestions = [
new Suggestion("Nature Haiku", new ChatMessage(ChatRole.User, "Write me a haiku about nature.")),
new Suggestion("Ocean Haiku", new ChatMessage(ChatRole.User, "Create a haiku about the ocean.")),
new Suggestion("Spring Haiku", new ChatMessage(ChatRole.User, "Generate a haiku about spring."))
];
[Parameter]
public string ScenarioId { get; set; } = "tool_based_generative_ui";
protected override void OnInitialized()
{
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("tool-based-generative-ui");
}
private void OnContextCreated(IAgentBoundaryContext context)
{
// Register the generate_haiku tool as a frontend tool
var generateHaikuTool = AIFunctionFactory.Create(
(string[] japanese, string[] english, string? image_name, string? gradient) =>
GenerateHaiku(japanese, english, image_name, gradient),
"generate_haiku",
$"Generate a haiku with Japanese text, English translation, and an optional image. Valid image names: {string.Join(", ", Haiku.ValidImageNames)}");
context.RegisterTool(generateHaikuTool);
}
/// <summary>
/// Frontend tool handler that creates a new haiku and adds it to the carousel.
/// </summary>
[Description("Generate a haiku with Japanese and English text, plus an optional image.")]
private Haiku GenerateHaiku(
[Description("3 lines of haiku in Japanese")] string[] japanese,
[Description("3 lines of haiku translated to English")] string[] english,
[Description("One relevant image name from the valid list")] string? image_name,
[Description("CSS Gradient color for the background")] string? gradient)
{
var newHaiku = new Haiku
{
Japanese = japanese ?? [],
English = english ?? [],
ImageName = image_name,
Gradient = gradient ?? string.Empty
};
// Add to beginning of list (newest first), removing placeholder if present
var updatedHaikus = new List<Haiku> { newHaiku };
updatedHaikus.AddRange(haikus.Where(h => h.English.Count == 0 || h.English[0] != "A placeholder verse—"));
haikus = updatedHaikus;
// Reset carousel to show the new haiku
InvokeAsync(() =>
{
carouselRef?.ResetToFirst();
StateHasChanged();
});
return newHaiku;
}
private void ResetConversation()
{
haikus = [Haiku.CreatePlaceholder()];
carouselRef?.ResetToFirst();
StateHasChanged();
}
}
@@ -0,0 +1,136 @@
/* Tool Based Generative UI Demo Layout */
.tool-generative-ui-layout {
display: grid;
grid-template-columns: 1fr 400px;
height: 100%;
background: linear-gradient(170deg, #e9ecef 0%, #ced4da 100%);
}
/* Main Display Area (Carousel) */
.main-display {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
overflow: auto;
}
/* Chat Panel */
.chat-panel {
display: flex;
flex-direction: column;
height: 100%;
background: white;
border-left: 1px solid #e2e8f0;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.05);
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
.chat-title {
font-size: 1.125rem;
font-weight: 600;
color: #1e293b;
}
.new-chat-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 8px;
color: #64748b;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
}
.new-chat-button:hover {
background: #e2e8f0;
color: #475569;
}
.button-icon {
font-size: 1rem;
font-weight: bold;
}
.chat-content {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.chat-input-container {
padding: 1rem;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
/* Override agent suggestions for this demo */
::deep .agent-suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
::deep .suggestion-button {
padding: 0.5rem 1rem;
background: white;
border: 1px solid #ff6f61;
border-radius: 20px;
color: #ff6f61;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
}
::deep .suggestion-button:hover {
background: #ff6f61;
color: white;
}
/* Responsive Layout */
@media (max-width: 1024px) {
.tool-generative-ui-layout {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
.chat-panel {
border-left: none;
border-top: 1px solid #e2e8f0;
}
.main-display {
padding: 1rem;
}
}
@media (max-width: 768px) {
.tool-generative-ui-layout {
grid-template-rows: auto 1fr;
}
.main-display {
max-height: 50vh;
}
.chat-header {
padding: 0.75rem 1rem;
}
.chat-title {
font-size: 1rem;
}
}
@@ -0,0 +1,44 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@using AGUIDojoClient.Components.Shared
@inject DemoService DemoService
@inject NavigationManager Nav
<div class="demo-sidebar">
<div class="demo-sidebar-header">
<h1>AG-UI Dojo</h1>
<p>Microsoft Agent Framework (.NET)</p>
</div>
<div class="demo-sidebar-list">
@foreach (DemoScenario scenario in DemoService.AllScenarios)
{
<button class="demo-item @(IsSelected(scenario.Id) ? "selected" : "")"
@onclick="@(() => NavigateToScenario(scenario.Id))">
<div class="demo-icon">@scenario.Icon</div>
<div class="demo-content">
<div class="demo-title">@scenario.Title</div>
<div class="demo-description">@scenario.Description</div>
<div class="demo-tags">
@foreach (string tag in scenario.Tags)
{
<span class="demo-tag">@tag</span>
}
</div>
</div>
</button>
}
</div>
</div>
@code {
/// <summary>
/// Gets or sets the currently selected scenario ID.
/// </summary>
[Parameter]
public string? CurrentScenarioId { get; set; }
private bool IsSelected(string scenarioId)
=> CurrentScenarioId?.Equals(scenarioId, StringComparison.OrdinalIgnoreCase) ?? false;
private void NavigateToScenario(string scenarioId)
=> Nav.NavigateTo($"/microsoft-agent-framework/feature/{scenarioId}");
}
@@ -0,0 +1,125 @@
/* Copyright (c) Microsoft. All rights reserved. */
.demo-sidebar {
width: 320px;
height: 100vh;
background-color: #f5f5f5;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.demo-sidebar-header {
padding: 24px 20px;
border-bottom: 1px solid #e0e0e0;
background-color: #ffffff;
}
.demo-sidebar-header h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #212121;
}
.demo-sidebar-header p {
margin: 4px 0 0 0;
font-size: 13px;
color: #666666;
}
.demo-sidebar-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.demo-item {
width: 100%;
display: flex;
gap: 12px;
padding: 12px;
margin-bottom: 8px;
background-color: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
text-align: left;
transition: all 0.2s ease;
}
.demo-item:hover {
background-color: #fafafa;
border-color: #0078d4;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.demo-item.selected {
background-color: #e6f2ff;
border-color: #0078d4;
box-shadow: 0 2px 4px rgba(0, 120, 212, 0.2);
}
.demo-icon {
font-size: 24px;
line-height: 1;
flex-shrink: 0;
}
.demo-content {
flex: 1;
min-width: 0;
}
.demo-title {
font-size: 14px;
font-weight: 600;
color: #212121;
margin-bottom: 4px;
}
.demo-description {
font-size: 12px;
color: #666666;
line-height: 1.4;
margin-bottom: 8px;
}
.demo-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.demo-tag {
font-size: 10px;
padding: 2px 6px;
background-color: #e8e8e8;
border-radius: 3px;
color: #424242;
}
.demo-item.selected .demo-tag {
background-color: #cce4ff;
color: #004578;
}
/* Scrollbar styling */
.demo-sidebar-list::-webkit-scrollbar {
width: 8px;
}
.demo-sidebar-list::-webkit-scrollbar-track {
background: #f5f5f5;
}
.demo-sidebar-list::-webkit-scrollbar-thumb {
background: #c0c0c0;
border-radius: 4px;
}
.demo-sidebar-list::-webkit-scrollbar-thumb:hover {
background: #a0a0a0;
}
@@ -0,0 +1,76 @@
@* Copyright (c) Microsoft. All rights reserved. *@
<div class="demo-view-tabs">
<div class="tabs-header">
<button class="tab-button @(CurrentTab == "preview" ? "active" : "")"
@onclick="@(() => OnTabChanged("preview"))">
Preview
</button>
<button class="tab-button @(CurrentTab == "code" ? "active" : "")"
@onclick="@(() => OnTabChanged("code"))">
Code
</button>
<button class="tab-button @(CurrentTab == "docs" ? "active" : "")"
@onclick="@(() => OnTabChanged("docs"))">
Docs
</button>
</div>
<div class="tab-content">
@if (CurrentTab == "preview")
{
<div class="tab-panel">
@PreviewContent
</div>
}
else if (CurrentTab == "code")
{
<div class="tab-panel">
@CodeContent
</div>
}
else if (CurrentTab == "docs")
{
<div class="tab-panel">
@DocsContent
</div>
}
</div>
</div>
@code {
/// <summary>
/// Gets or sets the currently active tab.
/// </summary>
[Parameter]
public string CurrentTab { get; set; } = "preview";
/// <summary>
/// Gets or sets the callback for tab changes.
/// </summary>
[Parameter]
public EventCallback<string> OnTabChange { get; set; }
/// <summary>
/// Gets or sets the content for the Preview tab.
/// </summary>
[Parameter]
public RenderFragment? PreviewContent { get; set; }
/// <summary>
/// Gets or sets the content for the Code tab.
/// </summary>
[Parameter]
public RenderFragment? CodeContent { get; set; }
/// <summary>
/// Gets or sets the content for the Docs tab.
/// </summary>
[Parameter]
public RenderFragment? DocsContent { get; set; }
private async Task OnTabChanged(string tab)
{
CurrentTab = tab;
await OnTabChange.InvokeAsync(tab);
}
}
@@ -0,0 +1,52 @@
/* Copyright (c) Microsoft. All rights reserved. */
.demo-view-tabs {
display: flex;
flex-direction: column;
height: 100%;
background-color: #ffffff;
}
.tabs-header {
display: flex;
gap: 0;
border-bottom: 1px solid #e0e0e0;
background-color: #fafafa;
padding: 0 20px;
}
.tab-button {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 2px solid transparent;
font-size: 14px;
font-weight: 500;
color: #666666;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.tab-button:hover {
color: #0078d4;
background-color: #f0f0f0;
}
.tab-button.active {
color: #0078d4;
border-bottom-color: #0078d4;
background-color: #ffffff;
}
.tab-content {
flex: 1;
overflow: hidden;
position: relative;
}
.tab-panel {
height: 100%;
width: 100%;
overflow: auto;
}
@@ -0,0 +1 @@
<div class="lds-ellipsis"><div></div><div></div><div></div><div></div></div>
@@ -0,0 +1,89 @@
/* Used under CC0 license */
.lds-ellipsis {
color: #666;
animation: fade-in 1s;
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.lds-ellipsis,
.lds-ellipsis div {
box-sizing: border-box;
}
.lds-ellipsis {
margin: auto;
display: block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ellipsis div {
position: absolute;
top: 33.33333px;
width: 10px;
height: 10px;
border-radius: 50%;
background: currentColor;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.lds-ellipsis div:nth-child(1) {
left: 8px;
animation: lds-ellipsis1 0.6s infinite;
}
.lds-ellipsis div:nth-child(2) {
left: 8px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(3) {
left: 32px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(4) {
left: 56px;
animation: lds-ellipsis3 0.6s infinite;
}
@keyframes lds-ellipsis1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes lds-ellipsis3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes lds-ellipsis2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}
@@ -0,0 +1,10 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@inherits LayoutComponentBase
@Body
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
@@ -0,0 +1,20 @@
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
@@ -0,0 +1,163 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@page "/microsoft-agent-framework/feature/{scenarioId}"
@using AGUIDojoClient.Components.Shared
@using AGUIDojoClient.Components.Demos.AgenticChat
@using AGUIDojoClient.Components.Demos.AgenticGenerativeUI
@using AGUIDojoClient.Components.Demos.BackendToolRendering
@using AGUIDojoClient.Components.Demos.HumanInTheLoop
@using AGUIDojoClient.Components.Demos.SharedState
@using AGUIDojoClient.Components.Demos.PredictiveStateUpdates
@using AGUIDojoClient.Components.Demos.ToolBasedGenerativeUI
@rendermode InteractiveServer
@inject DemoService DemoService
@inject NavigationManager Nav
<PageTitle>@(scenario?.Title ?? "AG-UI Dojo")</PageTitle>
<div class="dojo-container">
<DemoSidebar CurrentScenarioId="@ScenarioId" />
<div class="dojo-main">
@if (scenario is not null)
{
<DemoViewTabs CurrentTab="@currentTab" OnTabChange="@OnTabChanged">
<PreviewContent>
<div class="preview-panel">
@RenderScenarioDemo()
</div>
</PreviewContent>
<CodeContent>
<div class="code-panel">
<div class="placeholder-content">
<h2>Code</h2>
<p>Code view for <strong>@scenario.Title</strong> will be available soon.</p>
<p>This will show the relevant source code for this scenario.</p>
</div>
</div>
</CodeContent>
<DocsContent>
<div class="docs-panel">
<div class="placeholder-content">
<h2>Documentation</h2>
<p>Documentation for <strong>@scenario.Title</strong> will be available soon.</p>
<p>This will include:</p>
<ul>
<li>Overview of the scenario</li>
<li>Key concepts</li>
<li>How to use the demo</li>
<li>Links to related documentation</li>
</ul>
</div>
</div>
</DocsContent>
</DemoViewTabs>
}
else
{
<div class="error-panel">
<h2>Scenario Not Found</h2>
<p>The scenario "@ScenarioId" could not be found.</p>
</div>
}
</div>
</div>
@code {
private DemoScenario? scenario;
private string currentTab = "preview";
/// <summary>
/// Gets or sets the scenario ID from the route.
/// </summary>
[Parameter]
public string ScenarioId { get; set; } = string.Empty;
protected override void OnParametersSet()
{
scenario = DemoService.GetScenario(ScenarioId);
if (scenario is null)
{
// Redirect to first scenario if not found
DemoScenario? firstScenario = DemoService.AllScenarios.FirstOrDefault();
if (firstScenario is not null)
{
Nav.NavigateTo($"/microsoft-agent-framework/feature/{firstScenario.Id}", replace: true);
}
}
}
private void OnTabChanged(string tab)
{
currentTab = tab;
}
private RenderFragment RenderScenarioDemo() => builder =>
{
if (scenario is null)
{
return;
}
// For now, we only have AgenticChatDemo implemented
// Other scenarios will be added later
switch (scenario.Id.ToLowerInvariant())
{
case "agentic_chat":
builder.OpenComponent<AgenticChatDemo>(0);
builder.AddAttribute(1, nameof(AgenticChatDemo.ScenarioId), scenario.Id);
builder.CloseComponent();
break;
case "backend_tool_rendering":
builder.OpenComponent<BackendToolRenderingDemo>(0);
builder.AddAttribute(1, nameof(BackendToolRenderingDemo.ScenarioId), scenario.Id);
builder.CloseComponent();
break;
case "human_in_the_loop":
builder.OpenComponent<HumanInTheLoopDemo>(0);
builder.AddAttribute(1, nameof(HumanInTheLoopDemo.ScenarioId), scenario.Id);
builder.CloseComponent();
break;
case "tool_based_generative_ui":
builder.OpenComponent<ToolBasedGenerativeUIDemo>(0);
builder.AddAttribute(1, nameof(ToolBasedGenerativeUIDemo.ScenarioId), scenario.Id);
builder.CloseComponent();
break;
case "agentic_generative_ui":
builder.OpenComponent<AgenticGenerativeUIDemo>(0);
builder.AddAttribute(1, nameof(AgenticGenerativeUIDemo.ScenarioId), scenario.Id);
builder.CloseComponent();
break;
case "shared_state":
builder.OpenComponent<SharedStateDemo>(0);
builder.AddAttribute(1, nameof(SharedStateDemo.ScenarioId), scenario.Id);
builder.CloseComponent();
break;
case "predictive_state_updates":
builder.OpenComponent<PredictiveStateUpdatesDemo>(0);
builder.AddAttribute(1, nameof(PredictiveStateUpdatesDemo.ScenarioId), scenario.Id);
builder.CloseComponent();
break;
default:
// Placeholder for unimplemented scenarios
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "placeholder-content");
builder.OpenElement(2, "h2");
builder.AddContent(3, scenario.Title);
builder.CloseElement();
builder.OpenElement(4, "p");
builder.AddContent(5, scenario.Description);
builder.CloseElement();
builder.OpenElement(6, "p");
builder.AddContent(7, "This demo will be available soon.");
builder.CloseElement();
builder.CloseElement();
break;
}
};
}
@@ -0,0 +1,51 @@
/* Copyright (c) Microsoft. All rights reserved. */
.dojo-container {
display: flex;
height: 100vh;
width: 100%;
overflow: hidden;
}
.dojo-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.preview-panel,
.code-panel,
.docs-panel {
height: 100%;
overflow: auto;
}
.placeholder-content {
padding: 40px;
max-width: 800px;
}
.placeholder-content h2 {
margin-top: 0;
color: #212121;
}
.placeholder-content p {
color: #666666;
line-height: 1.6;
}
.placeholder-content ul {
color: #666666;
line-height: 1.8;
}
.error-panel {
padding: 40px;
text-align: center;
}
.error-panel h2 {
color: #d13438;
}
@@ -0,0 +1,20 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@page "/"
@using AGUIDojoClient.Components.Shared
@inject DemoService DemoService
@inject NavigationManager Nav
@code {
protected override void OnAfterRender(bool firstRender)
{
if (firstRender)
{
// Redirect to the first demo scenario
DemoScenario? firstScenario = DemoService.AllScenarios.FirstOrDefault();
if (firstScenario is not null)
{
Nav.NavigateTo($"/microsoft-agent-framework/feature/{firstScenario.Id}", replace: true);
}
}
}
}
@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@@ -0,0 +1,94 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@using System.ComponentModel
@inject IChatClient ChatClient
@inject NavigationManager Nav
@implements IDisposable
<PageTitle>Chat</PageTitle>
<ChatHeader OnNewChat="@ResetConversationAsync" />
<ChatMessageList Messages="@messages" InProgressMessage="@currentResponseMessage">
<NoMessagesContent>
<div>Ask the assistant a question to start a conversation.</div>
</NoMessagesContent>
</ChatMessageList>
<div class="chat-container">
<ChatSuggestions OnSelected="@AddUserMessageAsync" @ref="@chatSuggestions" />
<ChatInput OnSend="@AddUserMessageAsync" @ref="@chatInput" />
</div>
@code {
private const string SystemPrompt = @"
You are a helpful assistant.
";
private int statefulMessageCount;
private readonly ChatOptions chatOptions = new();
private readonly List<ChatMessage> messages = new();
private CancellationTokenSource? currentResponseCancellation;
private ChatMessage? currentResponseMessage;
private ChatInput? chatInput;
private ChatSuggestions? chatSuggestions;
protected override void OnInitialized()
{
statefulMessageCount = 0;
messages.Add(new(ChatRole.System, SystemPrompt));
}
private async Task AddUserMessageAsync(ChatMessage userMessage)
{
CancelAnyCurrentResponse();
// Add the user message to the conversation
messages.Add(userMessage);
chatSuggestions?.Clear();
await chatInput!.FocusAsync();
// Stream and display a new response from the IChatClient
var responseText = new TextContent("");
currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]);
StateHasChanged();
currentResponseCancellation = new();
await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token))
{
messages.AddMessages(update, filter: c => c is not TextContent);
responseText.Text += update.Text;
chatOptions.ConversationId = update.ConversationId;
ChatMessageItem.NotifyChanged(currentResponseMessage);
}
// Store the final response in the conversation, and begin getting suggestions
messages.Add(currentResponseMessage!);
statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0;
currentResponseMessage = null;
chatSuggestions?.Update(messages);
}
private void CancelAnyCurrentResponse()
{
// If a response was cancelled while streaming, include it in the conversation so it's not lost
if (currentResponseMessage is not null)
{
messages.Add(currentResponseMessage);
}
currentResponseCancellation?.Cancel();
currentResponseMessage = null;
}
private async Task ResetConversationAsync()
{
CancelAnyCurrentResponse();
messages.Clear();
messages.Add(new(ChatRole.System, SystemPrompt));
chatOptions.ConversationId = null;
statefulMessageCount = 0;
chatSuggestions?.Clear();
await chatInput!.FocusAsync();
}
public void Dispose()
=> currentResponseCancellation?.Cancel();
}
@@ -0,0 +1,11 @@
.chat-container {
position: sticky;
bottom: 0;
padding-left: 1.5rem;
padding-right: 1.5rem;
padding-top: 0.75rem;
padding-bottom: 1.5rem;
border-top-width: 1px;
background-color: #F3F4F6;
border-color: #E5E7EB;
}
@@ -0,0 +1,38 @@
@using System.Web
@if (!string.IsNullOrWhiteSpace(viewerUrl))
{
<a href="@viewerUrl" target="_blank" class="citation">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
<div class="citation-content">
<div class="citation-file">@File</div>
<div>@Quote</div>
</div>
</a>
}
@code {
[Parameter]
public required string File { get; set; }
[Parameter]
public int? PageNumber { get; set; }
[Parameter]
public required string Quote { get; set; }
private string? viewerUrl;
protected override void OnParametersSet()
{
viewerUrl = null;
// If you ingest other types of content besides PDF files, construct a URL to an appropriate viewer here
if (File.EndsWith(".pdf"))
{
var search = Quote?.Trim('.', ',', ' ', '\n', '\r', '\t', '"', '\'');
viewerUrl = $"lib/pdf_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#page={PageNumber}&search={HttpUtility.UrlEncode(search)}&phrase=true";
}
}
}
@@ -0,0 +1,37 @@
.citation {
display: inline-flex;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
margin-top: 1rem;
margin-right: 1rem;
border-bottom: 2px solid #a770de;
gap: 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
line-height: 1.25rem;
background-color: #ffffff;
}
.citation[href]:hover {
outline: 1px solid #865cb1;
}
.citation svg {
width: 1.5rem;
height: 1.5rem;
}
.citation:active {
background-color: rgba(0,0,0,0.05);
}
.citation-content {
display: flex;
flex-direction: column;
}
.citation-file {
font-weight: 600;
}
@@ -0,0 +1,17 @@
<div class="chat-header-container main-background-gradient">
<div class="chat-header-controls page-width">
<button class="btn-default" @onclick="@OnNewChat">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="new-chat-icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New chat
</button>
</div>
<h1 class="page-width">AGUI WebChat</h1>
</div>
@code {
[Parameter]
public EventCallback OnNewChat { get; set; }
}
@@ -0,0 +1,25 @@
.chat-header-container {
top: 0;
padding: 1.5rem;
}
.chat-header-controls {
margin-bottom: 1.5rem;
}
h1 {
overflow: hidden;
text-overflow: ellipsis;
}
.new-chat-icon {
width: 1.25rem;
height: 1.25rem;
color: rgb(55, 65, 81);
}
@media (min-width: 768px) {
.chat-header-container {
position: sticky;
}
}
@@ -0,0 +1,51 @@
@inject IJSRuntime JS
<EditForm Model="@this" OnValidSubmit="@SendMessageAsync">
<label class="input-box page-width">
<textarea @ref="@textArea" @bind="@messageText" placeholder="Type your message..." rows="1"></textarea>
<div class="tools">
<button type="submit" title="Send" class="send-button">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="tool-icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
</svg>
</button>
</div>
</label>
</EditForm>
@code {
private ElementReference textArea;
private string? messageText;
[Parameter]
public EventCallback<ChatMessage> OnSend { get; set; }
public ValueTask FocusAsync()
=> textArea.FocusAsync();
private async Task SendMessageAsync()
{
if (messageText is { Length: > 0 } text)
{
messageText = null;
await OnSend.InvokeAsync(new ChatMessage(ChatRole.User, text));
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./Components/Shared/Chat/ChatInput.razor.js");
await module.InvokeVoidAsync("init", textArea);
await module.DisposeAsync();
}
catch (JSDisconnectedException)
{
}
}
}
}
@@ -0,0 +1,57 @@
.input-box {
display: flex;
flex-direction: column;
background: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 8px;
padding: 0.5rem 0.75rem;
margin-top: 0.75rem;
}
.input-box:focus-within {
outline: 2px solid #4152d5;
}
textarea {
resize: none;
border: none;
outline: none;
flex-grow: 1;
}
textarea:placeholder-shown + .tools {
--send-button-color: #aaa;
}
.tools {
display: flex;
margin-top: 1rem;
align-items: center;
}
.tool-icon {
width: 1.25rem;
height: 1.25rem;
}
.send-button {
color: var(--send-button-color);
margin-left: auto;
}
.send-button:hover {
color: black;
}
.attach {
background-color: white;
border-style: dashed;
color: #888;
border-color: #888;
padding: 3px 8px;
}
.attach:hover {
background-color: #f0f0f0;
color: black;
}
@@ -0,0 +1,43 @@
export function init(elem) {
elem.focus();
// Auto-resize whenever the user types or if the value is set programmatically
elem.addEventListener('input', () => resizeToFit(elem));
afterPropertyWritten(elem, 'value', () => resizeToFit(elem));
// Auto-submit the form on 'enter' keypress
elem.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
elem.dispatchEvent(new CustomEvent('change', { bubbles: true }));
elem.closest('form').dispatchEvent(new CustomEvent('submit', { bubbles: true, cancelable: true }));
}
});
}
function resizeToFit(elem) {
const lineHeight = parseFloat(getComputedStyle(elem).lineHeight);
elem.rows = 1;
const numLines = Math.ceil(elem.scrollHeight / lineHeight);
elem.rows = Math.min(5, Math.max(1, numLines));
}
function afterPropertyWritten(target, propName, callback) {
const descriptor = getPropertyDescriptor(target, propName);
Object.defineProperty(target, propName, {
get: function () {
return descriptor.get.apply(this, arguments);
},
set: function () {
const result = descriptor.set.apply(this, arguments);
callback();
return result;
}
});
}
function getPropertyDescriptor(target, propertyName) {
return Object.getOwnPropertyDescriptor(target, propertyName)
|| getPropertyDescriptor(Object.getPrototypeOf(target), propertyName);
}
@@ -0,0 +1,73 @@
@using System.Runtime.CompilerServices
@using System.Text.RegularExpressions
@using System.Linq
@if (Message.Role == ChatRole.User)
{
<div class="user-message">
@Message.Text
</div>
}
else if (Message.Role == ChatRole.Assistant)
{
foreach (var content in Message.Contents)
{
if (content is TextContent { Text: { Length: > 0 } text })
{
<div class="assistant-message">
<div>
<div class="assistant-message-icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 0 0 1.5-.189m-1.5.189a6.01 6.01 0 0 1-1.5-.189m3.75 7.478a12.06 12.06 0 0 1-4.5 0m3.75 2.383a14.406 14.406 0 0 1-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 1 0-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
</svg>
</div>
</div>
<div class="assistant-message-header">Assistant</div>
<div class="assistant-message-text">
<div>@((MarkupString)text)</div>
</div>
</div>
}
else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true)
{
<div class="assistant-search">
<div class="assistant-search-icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</div>
<div class="assistant-search-content">
Searching:
<span class="assistant-search-phrase">@searchPhrase</span>
@if (fcc.Arguments?.TryGetValue("filenameFilter", out var filenameObj) is true && filenameObj is string filename && !string.IsNullOrEmpty(filename))
{
<text> in </text><span class="assistant-search-phrase">@filename</span>
}
</div>
</div>
}
}
}
@code {
private static readonly ConditionalWeakTable<ChatMessage, ChatMessageItem> SubscribersLookup = new();
[Parameter, EditorRequired]
public required ChatMessage Message { get; set; }
[Parameter]
public bool InProgress { get; set;}
protected override void OnInitialized()
{
SubscribersLookup.AddOrUpdate(Message, this);
}
public static void NotifyChanged(ChatMessage source)
{
if (SubscribersLookup.TryGetValue(source, out var subscriber))
{
subscriber.StateHasChanged();
}
}
}
@@ -0,0 +1,67 @@
.user-message {
background: rgb(182 215 232);
align-self: flex-end;
min-width: 25%;
max-width: calc(100% - 5rem);
padding: 0.5rem 1.25rem;
border-radius: 0.25rem;
color: #1F2937;
white-space: pre-wrap;
}
.assistant-message, .assistant-search {
display: grid;
grid-template-rows: min-content;
grid-template-columns: 2rem minmax(0, 1fr);
gap: 0.25rem;
}
.assistant-message-header {
font-weight: 600;
}
.assistant-message-text {
grid-column-start: 2;
}
.assistant-message-icon {
display: flex;
justify-content: center;
align-items: center;
border-radius: 9999px;
width: 1.5rem;
height: 1.5rem;
color: #ffffff;
background: #9b72ce;
}
.assistant-message-icon svg {
width: 1rem;
height: 1rem;
}
.assistant-search {
font-size: 0.875rem;
line-height: 1.25rem;
}
.assistant-search-icon {
display: flex;
justify-content: center;
align-items: center;
width: 1.5rem;
height: 1.5rem;
}
.assistant-search-icon svg {
width: 1rem;
height: 1rem;
}
.assistant-search-content {
align-content: center;
}
.assistant-search-phrase {
font-weight: 600;
}
@@ -0,0 +1,41 @@
@inject IJSRuntime JS
<div class="message-list-container">
<chat-messages class="page-width message-list" in-progress="@(InProgressMessage is not null)">
@foreach (var message in Messages)
{
<ChatMessageItem @key="@message" Message="@message" />
}
@if (InProgressMessage is not null)
{
<ChatMessageItem Message="@InProgressMessage" InProgress="true" />
<LoadingSpinner />
}
else if (IsEmpty)
{
<div class="no-messages">@NoMessagesContent</div>
}
</chat-messages>
</div>
@code {
[Parameter]
public required IEnumerable<ChatMessage> Messages { get; set; }
[Parameter]
public ChatMessage? InProgressMessage { get; set; }
[Parameter]
public RenderFragment? NoMessagesContent { get; set; }
private bool IsEmpty => !Messages.Any(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrEmpty(m.Text));
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("import", "./Components/Shared/Chat/ChatMessageList.razor.js");
}
}
}
@@ -0,0 +1,22 @@
.message-list-container {
margin: 2rem 1.5rem;
flex-grow: 1;
}
.message-list {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.no-messages {
text-align: center;
font-size: 1.25rem;
color: #999;
margin-top: calc(40vh - 18rem);
}
chat-messages > ::deep div:last-of-type {
/* Adds some vertical buffer to so that suggestions don't overlap the output when they appear */
margin-bottom: 2rem;
}
@@ -0,0 +1,34 @@
// The following logic provides auto-scroll behavior for the chat messages list.
// If you don't want that behavior, you can simply not load this module.
window.customElements.define('chat-messages', class ChatMessages extends HTMLElement {
static _isFirstAutoScroll = true;
connectedCallback() {
this._observer = new MutationObserver(mutations => this._scheduleAutoScroll(mutations));
this._observer.observe(this, { childList: true, attributes: true });
}
disconnectedCallback() {
this._observer.disconnect();
}
_scheduleAutoScroll(mutations) {
// Debounce the calls in case multiple DOM updates occur together
cancelAnimationFrame(this._nextAutoScroll);
this._nextAutoScroll = requestAnimationFrame(() => {
const addedUserMessage = mutations.some(m => Array.from(m.addedNodes).some(n => n.parentElement === this && n.classList?.contains('user-message')));
const elem = this.lastElementChild;
if (ChatMessages._isFirstAutoScroll || addedUserMessage || this._elemIsNearScrollBoundary(elem, 300)) {
elem.scrollIntoView({ behavior: ChatMessages._isFirstAutoScroll ? 'instant' : 'smooth' });
ChatMessages._isFirstAutoScroll = false;
}
});
}
_elemIsNearScrollBoundary(elem, threshold) {
const maxScrollPos = document.body.scrollHeight - window.innerHeight;
const remainingScrollDistance = maxScrollPos - window.scrollY;
return remainingScrollDistance < elem.offsetHeight + threshold;
}
});
@@ -0,0 +1,78 @@
@inject IChatClient ChatClient
@if (suggestions is not null)
{
<div class="page-width suggestions">
@foreach (var suggestion in suggestions)
{
<button class="btn-subtle" @onclick="@(() => AddSuggestionAsync(suggestion))">
@suggestion
</button>
}
</div>
}
@code {
private static string Prompt = @"
Suggest up to 3 follow-up questions that I could ask you to help me complete my task.
Each suggestion must be a complete sentence, maximum 6 words.
Each suggestion must be phrased as something that I (the user) would ask you (the assistant) in response to your previous message,
for example 'How do I do that?' or 'Explain ...'.
If there are no suggestions, reply with an empty list.
";
private string[]? suggestions;
private CancellationTokenSource? cancellation;
[Parameter]
public EventCallback<ChatMessage> OnSelected { get; set; }
public void Clear()
{
suggestions = null;
cancellation?.Cancel();
}
public void Update(IReadOnlyList<ChatMessage> messages)
{
// Runs in the background and handles its own cancellation/errors
_ = UpdateSuggestionsAsync(messages);
}
private async Task UpdateSuggestionsAsync(IReadOnlyList<ChatMessage> messages)
{
cancellation?.Cancel();
cancellation = new CancellationTokenSource();
try
{
var response = await ChatClient.GetResponseAsync<string[]>(
[.. ReduceMessages(messages), new(ChatRole.User, Prompt)],
cancellationToken: cancellation.Token);
if (!response.TryGetResult(out suggestions))
{
suggestions = null;
}
StateHasChanged();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
await DispatchExceptionAsync(ex);
}
}
private async Task AddSuggestionAsync(string text)
{
await OnSelected.InvokeAsync(new(ChatRole.User, text));
}
private IEnumerable<ChatMessage> ReduceMessages(IReadOnlyList<ChatMessage> messages)
{
// Get any leading system messages, plus up to 5 user/assistant messages
// This should be enough context to generate suggestions without unnecessarily resending entire conversations when long
var systemMessages = messages.TakeWhile(m => m.Role == ChatRole.System);
var otherMessages = messages.Where((m, index) => m.Role == ChatRole.User || m.Role == ChatRole.Assistant).Where(m => !string.IsNullOrEmpty(m.Text)).TakeLast(5);
return systemMessages.Concat(otherMessages);
}
}
@@ -0,0 +1,9 @@
.suggestions {
text-align: right;
white-space: nowrap;
gap: 0.5rem;
justify-content: flex-end;
flex-wrap: wrap;
display: flex;
margin-bottom: 0.75rem;
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
namespace AGUIDojoClient.Components.Shared;
/// <summary>
/// Represents a demo scenario in the AG-UI dojo.
/// </summary>
/// <param name="Id">Unique identifier for the scenario (e.g., "agentic_chat").</param>
/// <param name="Title">Display title of the scenario.</param>
/// <param name="Description">Brief description of what the scenario demonstrates.</param>
/// <param name="Tags">Collection of tags categorizing the scenario's features.</param>
/// <param name="Endpoint">Server endpoint path for the AG-UI connection.</param>
/// <param name="Icon">Optional emoji icon for the scenario.</param>
public record DemoScenario(
string Id,
string Title,
string Description,
IReadOnlyList<string> Tags,
string Endpoint,
string Icon = "💬"
);

Some files were not shown because too many files have changed in this diff Show More