mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Add AG-UI Blazor sample
This commit is contained in:
+70
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
+57
@@ -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);
|
||||
}
|
||||
}
|
||||
+157
@@ -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();
|
||||
}
|
||||
}
|
||||
+86
@@ -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;
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
+366
@@ -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();
|
||||
}
|
||||
}
|
||||
+244
@@ -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;
|
||||
}
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user