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:
+71
@@ -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;
|
||||
}
|
||||
}
|
||||
+97
@@ -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;
|
||||
}
|
||||
+106
@@ -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();
|
||||
}
|
||||
}
|
||||
+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.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);
|
||||
}
|
||||
+73
@@ -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);
|
||||
}
|
||||
+42
@@ -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; }
|
||||
}
|
||||
+139
@@ -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;
|
||||
}
|
||||
+56
@@ -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();
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
+60
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
+117
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
+162
@@ -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;
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
+14
@@ -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; }
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+247
@@ -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();
|
||||
}
|
||||
}
|
||||
+237
@@ -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();
|
||||
}
|
||||
+280
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+305
@@ -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;
|
||||
}
|
||||
+57
@@ -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
|
||||
};
|
||||
}
|
||||
+62
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
+112
@@ -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;
|
||||
}
|
||||
}
|
||||
+150
@@ -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;
|
||||
}
|
||||
}
|
||||
+104
@@ -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();
|
||||
}
|
||||
}
|
||||
+113
@@ -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;
|
||||
}
|
||||
}
|
||||
+113
@@ -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();
|
||||
}
|
||||
}
|
||||
+136
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user