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