Add AG-UI Blazor sample

This commit is contained in:
Javier Calvarro Nelson
2025-12-10 16:46:50 +01:00
parent 0d9ae1920d
commit 0340531f3a
139 changed files with 9595 additions and 1 deletions
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
namespace AGUIDojoClient.Components.Demos.PredictiveStateUpdates;
/// <summary>
/// Represents the result of the confirm_changes frontend tool.
/// </summary>
public sealed class ConfirmChangesResult
{
/// <summary>
/// Gets or sets a value indicating whether the user confirmed the changes.
/// </summary>
public bool Confirmed { get; set; }
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoClient.Components.Demos.PredictiveStateUpdates;
/// <summary>
/// Represents the document state for the Predictive State Updates demo.
/// This model mirrors the server-side DocumentState and is updated via streaming state updates.
/// </summary>
public sealed class DocumentState
{
/// <summary>
/// Gets or sets the document content in Markdown format.
/// </summary>
[JsonPropertyName("document")]
public string Document { get; set; } = string.Empty;
}
@@ -0,0 +1,247 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@using Microsoft.AspNetCore.Components.AI
@using Microsoft.Agents.AI
@using Microsoft.Extensions.AI
@using Microsoft.Extensions.DependencyInjection
@using AGUIDojoClient.Components.Shared
@using System.Text.Json
@using System.ComponentModel
@using Microsoft.Extensions.Logging
@implements IDisposable
@inject IServiceProvider ServiceProvider
@inject ILogger<PredictiveStateUpdatesDemo> Logger
<PageTitle>AI Document Editor</PageTitle>
<div class="predictive-state-layout">
@* Left panel: Document editor *@
<div class="document-panel">
<div class="document-header">
<h2>Document Editor</h2>
@if (isStreaming)
{
<span class="streaming-indicator">
<span class="streaming-dot"></span>
Writing...
</span>
}
</div>
<div class="document-content">
@if (string.IsNullOrWhiteSpace(currentDocument))
{
<div class="document-placeholder">
Write whatever you want here in Markdown format...
</div>
}
else
{
<pre class="document-text">@currentDocument</pre>
}
</div>
</div>
@* Right panel: Chat sidebar *@
<div class="chat-panel">
<div class="chat-header">
<div class="chat-title">AI Document Editor</div>
<button class="new-chat-button" @onclick="ResetConversation">
<span class="button-icon">+</span> New chat
</button>
</div>
<AgentBoundary Agent="@agent" OnContextCreated="OnContextCreated">
<div class="chat-content">
<Messages>
<ContentTemplates>
<TextTemplate />
</ContentTemplates>
</Messages>
</div>
<AgentState TState="DocumentState"
CurrentState="@currentDocumentState"
OnSnapshot="@DeserializeDocumentState"
CurrentStateChanged="@OnDocumentStateChanged" />
<div class="chat-input-container">
<AgentSuggestions Suggestions="@suggestions" />
<AgentInput Placeholder="Ask the AI to write or edit..." />
</div>
</AgentBoundary>
</div>
</div>
@* Confirmation modal *@
@if (awaitingConfirmation)
{
<div class="confirmation-overlay">
<div class="confirmation-modal">
<h3>Confirm Changes</h3>
<p>Do you want to accept the changes?</p>
<div class="confirmation-actions">
<button class="confirm-button reject" @onclick="RejectChanges">
Reject
</button>
<button class="confirm-button accept" @onclick="ConfirmChanges">
Confirm
</button>
</div>
</div>
</div>
}
@code {
private AIAgent? agent;
private IAgentBoundaryContext? boundaryContext;
private ResponseUpdateSubscription? responseSubscription;
private DocumentState? currentDocumentState;
private string currentDocument = string.Empty;
private string previousDocument = string.Empty;
private bool isStreaming;
private bool awaitingConfirmation;
private Suggestion[] suggestions = [
new Suggestion("Write a pirate story", new ChatMessage(ChatRole.User, "Please write a story about a pirate named Candy Beard")),
new Suggestion("Write a mermaid story", new ChatMessage(ChatRole.User, "Please write a story about a mermaid named Pearl")),
new Suggestion("Add character", new ChatMessage(ChatRole.User, "Add a new character to the story"))
];
[Parameter]
public string ScenarioId { get; set; } = "predictive_state_updates";
protected override void OnInitialized()
{
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("predictive-state-updates");
}
private void OnContextCreated(IAgentBoundaryContext context)
{
boundaryContext = context;
// Register the confirm_changes frontend tool
var confirmChangesTool = AIFunctionFactory.Create(
() => ConfirmChangesAsync(context),
"confirm_changes",
"Ask the user to confirm or reject the document changes.");
context.RegisterTools(confirmChangesTool);
// Subscribe to response updates to detect when streaming starts/stops
responseSubscription = context.SubscribeToResponseUpdates(OnResponseUpdate);
}
private void OnResponseUpdate()
{
var update = boundaryContext?.CurrentUpdate;
if (update is null)
{
return;
}
// Check for function calls to detect confirm_changes
if (update.Contents is not null)
{
foreach (var content in update.Contents)
{
if (content is FunctionCallContent call &&
string.Equals(call.Name, "confirm_changes", StringComparison.OrdinalIgnoreCase))
{
// Show confirmation dialog when confirm_changes is called
Logger.LogInformation("Detected confirm_changes call, showing confirmation dialog");
awaitingConfirmation = true;
isStreaming = false;
}
}
}
InvokeAsync(StateHasChanged);
}
private DocumentState? DeserializeDocumentState(ReadOnlyMemory<byte> data)
{
try
{
return JsonSerializer.Deserialize<DocumentState>(data.Span);
}
catch
{
return null;
}
}
private void OnDocumentStateChanged(DocumentState? state)
{
if (state is null)
{
return;
}
currentDocumentState = state;
// Store the previous document before updating (for reject functionality)
if (string.IsNullOrEmpty(previousDocument) && !string.IsNullOrEmpty(currentDocument))
{
previousDocument = currentDocument;
}
// Check if we're starting to stream (document changed)
if (currentDocument != state.Document)
{
isStreaming = true;
}
currentDocument = state.Document;
StateHasChanged();
}
/// <summary>
/// Frontend tool that waits for user confirmation via the UI.
/// </summary>
[Description("Ask the user to confirm or reject the document changes.")]
private static async Task<ConfirmChangesResult> ConfirmChangesAsync(IAgentBoundaryContext context)
{
// Wait for the user to click Confirm or Reject
var response = await context.WaitForResponse("confirm_changes");
return (ConfirmChangesResult)response;
}
private void ConfirmChanges()
{
awaitingConfirmation = false;
isStreaming = false;
// Update the previous document to the current one (changes accepted)
previousDocument = currentDocument;
boundaryContext?.ProvideResponse("confirm_changes", new ConfirmChangesResult { Confirmed = true });
StateHasChanged();
}
private void RejectChanges()
{
awaitingConfirmation = false;
isStreaming = false;
// Revert to the previous document
currentDocument = previousDocument;
boundaryContext?.ProvideResponse("confirm_changes", new ConfirmChangesResult { Confirmed = false });
StateHasChanged();
}
private void ResetConversation()
{
currentDocument = string.Empty;
previousDocument = string.Empty;
currentDocumentState = null;
isStreaming = false;
awaitingConfirmation = false;
StateHasChanged();
}
public void Dispose()
{
responseSubscription?.Dispose();
}
}
@@ -0,0 +1,237 @@
/* Predictive State Updates Demo Styles */
/* Main layout - two column */
.predictive-state-layout {
display: flex;
height: 100%;
width: 100%;
background: #f5f5f5;
}
/* Document Panel (Left) */
.document-panel {
flex: 1;
display: flex;
flex-direction: column;
background: #ffffff;
border-right: 1px solid #e0e0e0;
}
.document-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid #e0e0e0;
background: #fafafa;
}
.document-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #333;
}
.streaming-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #6366f1;
font-weight: 500;
}
.streaming-dot {
width: 8px;
height: 8px;
background: #6366f1;
border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.8);
}
}
.document-content {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.document-placeholder {
color: #9ca3af;
font-style: italic;
font-size: 16px;
}
.document-text {
margin: 0;
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
line-height: 1.7;
color: #333;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Chat Panel (Right) */
.chat-panel {
width: 400px;
min-width: 350px;
display: flex;
flex-direction: column;
background: #ffffff;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
}
.chat-title {
font-size: 16px;
font-weight: 600;
color: #111827;
}
.new-chat-button {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
color: #374151;
cursor: pointer;
transition: all 0.15s ease;
}
.new-chat-button:hover {
background: #e5e7eb;
border-color: #d1d5db;
}
.button-icon {
font-size: 16px;
font-weight: 600;
}
.chat-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.chat-input-container {
padding: 12px 16px;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
/* Confirmation Modal */
.confirmation-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.confirmation-modal {
background: #ffffff;
border-radius: 12px;
padding: 24px 32px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
min-width: 320px;
text-align: center;
}
.confirmation-modal h3 {
margin: 0 0 12px 0;
font-size: 18px;
font-weight: 600;
color: #111827;
}
.confirmation-modal p {
margin: 0 0 24px 0;
font-size: 14px;
color: #6b7280;
}
.confirmation-actions {
display: flex;
gap: 12px;
justify-content: center;
}
.confirm-button {
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border: none;
}
.confirm-button.reject {
background: #f3f4f6;
color: #374151;
border: 1px solid #e5e7eb;
}
.confirm-button.reject:hover {
background: #e5e7eb;
}
.confirm-button.accept {
background: #6366f1;
color: #ffffff;
}
.confirm-button.accept:hover {
background: #4f46e5;
}
/* Agent Suggestions styling overrides */
::deep .suggestions-container {
margin-bottom: 12px;
}
::deep .suggestion-chip {
background: #f3f4f6;
border: 1px solid #e5e7eb;
padding: 8px 14px;
border-radius: 20px;
font-size: 13px;
color: #374151;
cursor: pointer;
transition: all 0.15s ease;
}
::deep .suggestion-chip:hover {
background: #e5e7eb;
border-color: #d1d5db;
}