Add AG-UI Blazor sample

This commit is contained in:
Javier Calvarro Nelson
2025-12-10 16:46:50 +01:00
parent 0d9ae1920d
commit 0340531f3a
139 changed files with 9595 additions and 1 deletions
@@ -0,0 +1,71 @@
@* 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 />
</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,97 @@
/* 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;
}
.agentic-chat-demo {
height: 100%;
display: flex;
flex-direction: column;
}
.chat-container {
padding: 20px;
border-top: 1px solid #e0e0e0;
}
@@ -0,0 +1,106 @@
@* 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 />
</ContentTemplates>
</Messages>
</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,56 @@
@* 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>
<WeatherCallTemplate />
</ContentTemplates>
</Messages>
</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,60 @@
// Copyright (c) Microsoft. All rights reserved.
using AGUIDojoClient.Components.Shared;
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 new 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,70 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.AI;
using Microsoft.AspNetCore.Components.Rendering;
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,57 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
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,157 @@
@* 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 />
</ContentTemplates>
</Messages>
</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,366 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@using Microsoft.AspNetCore.Components.AI
@using Microsoft.Extensions.AI
@using Microsoft.Extensions.Logging
@using System.Text.Json
@implements IDisposable
@inject ILogger<PlanCard> Logger
@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()
{
Logger.LogInformation("PlanCard OnInitialized called");
Logger.LogInformation("BoundaryContext is {Status}", BoundaryContext is null ? "NULL" : "present");
Logger.LogInformation("Invocation is {Status}, HasResult: {HasResult}",
Invocation is null ? "NULL" : "present",
Invocation?.HasResult ?? false);
// The plan comes from the create_plan function result
if (Invocation?.HasResult == true)
{
Logger.LogInformation("Invocation already has result, parsing plan");
TryParsePlanFromResult();
}
else if (Invocation is not null)
{
Logger.LogInformation("Subscribing to Invocation.ResultArrived");
// 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)
{
Logger.LogInformation("Subscribing to response updates");
_responseSubscription = BoundaryContext.SubscribeToResponseUpdates(OnResponseUpdate);
}
else
{
Logger.LogWarning("BoundaryContext is null, cannot subscribe to response updates!");
}
}
private void OnCreatePlanResultArrived()
{
Logger.LogInformation("OnCreatePlanResultArrived called");
TryParsePlanFromResult();
InvokeAsync(StateHasChanged);
}
private void TryParsePlanFromResult()
{
var plan = Invocation?.GetResult<Plan>();
Logger.LogInformation("TryParsePlanFromResult: plan is {Status}", plan is null ? "NULL" : $"present with {plan.Steps.Count} steps");
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);
}
}
Logger.LogInformation("InitializeSelectedSteps: selected {Count} steps", selectedSteps.Count);
}
}
private void OnResponseUpdate()
{
Logger.LogInformation("OnResponseUpdate called");
// Check the current update for tool calls
var update = BoundaryContext?.CurrentUpdate;
if (update is null)
{
Logger.LogInformation("CurrentUpdate is null");
return;
}
Logger.LogInformation("CurrentUpdate has {Count} contents", update.Contents?.Count ?? 0);
if (update.Contents is not null)
{
foreach (var content in update.Contents)
{
Logger.LogInformation("Content type: {Type}", content.GetType().Name);
if (content is FunctionCallContent call)
{
Logger.LogInformation("Found FunctionCallContent: {Name}, CallId: {CallId}", call.Name, call.CallId);
if (string.Equals(call.Name, "confirm_plan", StringComparison.OrdinalIgnoreCase))
{
Logger.LogInformation("Detected confirm_plan call!");
HandleConfirmPlanCall(call);
}
else if (string.Equals(call.Name, "update_plan_step", StringComparison.OrdinalIgnoreCase))
{
Logger.LogInformation("Detected update_plan_step call!");
HandleUpdatePlanStepCall(call);
}
}
}
}
InvokeAsync(StateHasChanged);
}
private void HandleConfirmPlanCall(FunctionCallContent call)
{
Logger.LogInformation("HandleConfirmPlanCall: AwaitingConfirmation={Awaiting}, WasRejected={Rejected}, _confirmPlanCallId={CallId}",
AwaitingConfirmation, WasRejected, _confirmPlanCallId);
// When confirm_plan is called, show the confirmation UI
if (!AwaitingConfirmation && !WasRejected && _confirmPlanCallId is null)
{
_confirmPlanCallId = call.CallId;
AwaitingConfirmation = true;
Logger.LogInformation("Set AwaitingConfirmation to true, _confirmPlanCallId={CallId}", _confirmPlanCallId);
}
}
private void HandleUpdatePlanStepCall(FunctionCallContent call)
{
if (CurrentPlan is null)
{
Logger.LogWarning("HandleUpdatePlanStepCall: CurrentPlan is null");
return;
}
// Get the index and status from arguments
if (call.Arguments is null)
{
Logger.LogWarning("HandleUpdatePlanStepCall: 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();
}
Logger.LogInformation("HandleUpdatePlanStepCall: index={Index}, status={Status}, description={Description}",
index, status, description);
// 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;
}
Logger.LogInformation("Updated step {Index}", index.Value);
}
}
private void ToggleStepSelection(int index)
{
if (selectedSteps.Contains(index))
{
selectedSteps.Remove(index);
}
else
{
selectedSteps.Add(index);
}
StateHasChanged();
}
private void ConfirmPlan()
{
Logger.LogInformation("ConfirmPlan called with {Count} selected steps", selectedSteps.Count);
AwaitingConfirmation = false;
var result = new PlanConfirmationResult
{
Confirmed = true,
SelectedStepIndices = selectedSteps.ToList()
};
BoundaryContext?.ProvideResponse("confirm_plan", result);
}
private void RejectPlan()
{
Logger.LogInformation("RejectPlan called");
WasRejected = true;
AwaitingConfirmation = false;
var result = new PlanConfirmationResult
{
Confirmed = false,
SelectedStepIndices = []
};
BoundaryContext?.ProvideResponse("confirm_plan", result);
}
public void Dispose()
{
Logger.LogInformation("PlanCard Dispose called");
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,247 @@
@* 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
@using Microsoft.Extensions.Logging
@implements IDisposable
@inject IServiceProvider ServiceProvider
@inject ILogger<PredictiveStateUpdatesDemo> Logger
<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 />
</ContentTemplates>
</Messages>
</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
Logger.LogInformation("Detected confirm_changes call, showing confirmation dialog");
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,237 @@
/* 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 .suggestions-container {
margin-bottom: 12px;
}
::deep .suggestion-chip {
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 .suggestion-chip:hover {
background: #e5e7eb;
border-color: #d1d5db;
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
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,27 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
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,12 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
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,280 @@
@* 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">
<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>
<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>
<button class="improve-button" @onclick="ImproveWithAI" disabled="@isProcessing">
@(isProcessing ? "Please Wait..." : "Improve with AI")
</button>
</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 />
</ContentTemplates>
</Messages>
</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,305 @@
/* Shared State Demo Layout */
.shared-state-layout {
display: flex;
height: 100%;
gap: 16px;
padding: 16px;
background-color: #f8f9fa;
}
/* Recipe Panel */
.recipe-panel {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
background-color: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow-y: auto;
}
/* Recipe Header */
.recipe-header {
display: flex;
flex-direction: column;
gap: 12px;
}
.recipe-title {
font-size: 24px;
font-weight: 600;
border: none;
border-bottom: 2px solid #e5e7eb;
padding: 8px 0;
background: transparent;
color: #111827;
width: 100%;
}
.recipe-title:focus {
outline: none;
border-bottom-color: #3b82f6;
}
.recipe-meta {
display: flex;
gap: 16px;
}
.meta-item {
display: flex;
align-items: center;
gap: 8px;
}
.meta-icon {
font-size: 18px;
}
.meta-item select {
padding: 6px 12px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background-color: white;
font-size: 14px;
color: #374151;
cursor: pointer;
}
.meta-item select:focus {
outline: none;
border-color: #3b82f6;
}
/* 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;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
/* Dietary Preferences */
.preferences-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.preference-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background-color: #f3f4f6;
border-radius: 20px;
font-size: 14px;
color: #374151;
cursor: pointer;
transition: background-color 0.2s;
}
.preference-item:hover {
background-color: #e5e7eb;
}
.preference-item input[type="checkbox"] {
accent-color: #3b82f6;
}
/* Ingredients */
.ingredients-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.ingredient-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
background-color: #f9fafb;
border-radius: 8px;
}
.ingredient-icon {
font-size: 20px;
width: 32px;
text-align: center;
}
.ingredient-inputs {
flex: 1;
display: flex;
gap: 8px;
}
.ingredient-inputs input {
flex: 1;
padding: 8px 12px;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
color: #374151;
}
.ingredient-inputs input:focus {
outline: none;
border-color: #3b82f6;
}
/* Instructions */
.instructions-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.instruction-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
background-color: #f9fafb;
border-radius: 8px;
}
.step-number {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background-color: #3b82f6;
color: white;
font-size: 14px;
font-weight: 600;
border-radius: 50%;
flex-shrink: 0;
}
.instruction-row input {
flex: 1;
padding: 8px 12px;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
color: #374151;
}
.instruction-row input:focus {
outline: none;
border-color: #3b82f6;
}
/* Buttons */
.add-button {
padding: 6px 12px;
background-color: #e5e7eb;
border: none;
border-radius: 6px;
font-size: 14px;
color: #374151;
cursor: pointer;
transition: background-color 0.2s;
}
.add-button:hover {
background-color: #d1d5db;
}
.remove-button {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 18px;
color: #9ca3af;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.remove-button:hover {
background-color: #fef2f2;
border-color: #fca5a5;
color: #ef4444;
}
.improve-button {
padding: 12px 24px;
background-color: #3b82f6;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
color: white;
cursor: pointer;
transition: background-color 0.2s;
margin-top: auto;
}
.improve-button:hover:not(:disabled) {
background-color: #2563eb;
}
.improve-button:disabled {
background-color: #93c5fd;
cursor: not-allowed;
}
/* Chat Panel */
.chat-panel {
width: 400px;
display: flex;
flex-direction: column;
background-color: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.chat-header {
padding: 16px;
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,62 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.AI;
using Microsoft.AspNetCore.Components.Rendering;
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,113 @@
@* 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 />
</ContentTemplates>
</Messages>
</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;
}
}