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,18 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System.Text.Json.Serialization;
namespace AGUIDojoClient.Components.Demos.SharedState;
public sealed class Ingredient
{
[JsonPropertyName("icon")]
public string Icon { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("amount")]
public string Amount { get; set; } = string.Empty;
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System.Text.Json.Serialization;
namespace AGUIDojoClient.Components.Demos.SharedState;
public sealed class Recipe
{
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("skill_level")]
public string SkillLevel { get; set; } = string.Empty;
[JsonPropertyName("cooking_time")]
public string CookingTime { get; set; } = string.Empty;
[JsonPropertyName("special_preferences")]
public List<string> SpecialPreferences { get; set; } = [];
[JsonPropertyName("ingredients")]
public List<Ingredient> Ingredients { get; set; } = [];
[JsonPropertyName("instructions")]
public List<string> Instructions { get; set; } = [];
}
@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System.Text.Json.Serialization;
namespace AGUIDojoClient.Components.Demos.SharedState;
public sealed class RecipeResponse
{
[JsonPropertyName("recipe")]
public Recipe Recipe { get; set; } = new();
}
@@ -0,0 +1,280 @@
@* Copyright (c) Microsoft. All rights reserved. *@
@using Microsoft.AspNetCore.Components.AI
@using Microsoft.Agents.AI
@using Microsoft.Extensions.AI
@using Microsoft.Extensions.DependencyInjection
@using AGUIDojoClient.Components.Shared
@using System.Text.Json
@inject IServiceProvider ServiceProvider
<PageTitle>Shared State</PageTitle>
<div class="shared-state-layout">
<AgentBoundary Agent="@agent" OnContextCreated="@OnContextCreated">
<AgentState TState="Recipe"
CurrentState="@currentRecipe"
OnSnapshot="@DeserializeRecipe"
CurrentStateChanged="@OnRecipeChanged">
<div class="recipe-panel">
<div class="recipe-header">
<input type="text" class="recipe-title" @bind="currentRecipe.Title" @bind:event="oninput" placeholder="Recipe name" />
<div class="recipe-meta">
<div class="meta-item">
<span class="meta-icon">🕒</span>
<select @bind="currentRecipe.CookingTime">
<option value="5 min">5 min</option>
<option value="15 min">15 min</option>
<option value="30 min">30 min</option>
<option value="45 min">45 min</option>
<option value="60+ min">60+ min</option>
</select>
</div>
<div class="meta-item">
<span class="meta-icon">🏆</span>
<select @bind="currentRecipe.SkillLevel">
<option value="Beginner">Beginner</option>
<option value="Intermediate">Intermediate</option>
<option value="Advanced">Advanced</option>
</select>
</div>
</div>
</div>
<div class="preferences-section">
<h2>Dietary Preferences</h2>
<div class="preferences-grid">
@foreach (var pref in dietaryPreferences)
{
<label class="preference-item">
<input type="checkbox"
checked="@currentRecipe.SpecialPreferences.Contains(pref)"
@onchange="@(e => TogglePreference(pref, (bool?)e.Value ?? false))" />
<span>@pref</span>
</label>
}
</div>
</div>
<div class="ingredients-section">
<div class="section-header">
<h2>Ingredients</h2>
<button class="add-button" @onclick="AddIngredient">+ Add Ingredient</button>
</div>
<div class="ingredients-list">
@for (int i = 0; i < currentRecipe.Ingredients.Count; i++)
{
var index = i;
var ingredient = currentRecipe.Ingredients[index];
<div class="ingredient-row">
<span class="ingredient-icon">@ingredient.Icon</span>
<div class="ingredient-inputs">
<input type="text" placeholder="Ingredient name"
value="@ingredient.Name"
@onchange="@(e => UpdateIngredientName(index, e.Value?.ToString() ?? ""))" />
<input type="text" placeholder="Amount"
value="@ingredient.Amount"
@onchange="@(e => UpdateIngredientAmount(index, e.Value?.ToString() ?? ""))" />
</div>
<button class="remove-button" @onclick="@(() => RemoveIngredient(index))">×</button>
</div>
}
</div>
</div>
<div class="instructions-section">
<div class="section-header">
<h2>Instructions</h2>
<button class="add-button" @onclick="AddInstruction">+ Add Step</button>
</div>
<div class="instructions-list">
@for (int i = 0; i < currentRecipe.Instructions.Count; i++)
{
var index = i;
<div class="instruction-row">
<span class="step-number">@(index + 1)</span>
<input type="text"
value="@currentRecipe.Instructions[index]"
@onchange="@(e => UpdateInstruction(index, e.Value?.ToString() ?? ""))" />
<button class="remove-button" @onclick="@(() => RemoveInstruction(index))">×</button>
</div>
}
</div>
</div>
<button class="improve-button" @onclick="ImproveWithAI" disabled="@isProcessing">
@(isProcessing ? "Please Wait..." : "Improve with AI")
</button>
</div>
<div class="chat-panel">
<div class="chat-header">
<span class="chat-title">AI Recipe Assistant</span>
</div>
<div class="chat-messages">
<Messages>
<ContentTemplates>
<TextTemplate />
</ContentTemplates>
</Messages>
</div>
<div class="chat-input-container">
<AgentInput Placeholder="Ask about your recipe..." />
</div>
</div>
</AgentState>
</AgentBoundary>
</div>
@code {
private AIAgent? agent;
private IAgentBoundaryContext? boundaryContext;
private Recipe currentRecipe = CreateDefaultRecipe();
private bool isProcessing;
private static readonly string[] dietaryPreferences = [
"High Protein", "Low Carb", "Spicy", "Budget-Friendly",
"One-Pot Meal", "Vegetarian", "Vegan"
];
[Parameter]
public string ScenarioId { get; set; } = "shared_state";
protected override void OnInitialized()
{
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("shared-state");
}
private void OnContextCreated(IAgentBoundaryContext context)
{
boundaryContext = context;
}
private static Recipe CreateDefaultRecipe() => new()
{
Title = "Make Your Recipe",
SkillLevel = "Intermediate",
CookingTime = "45 min",
SpecialPreferences = [],
Ingredients =
[
new Ingredient { Icon = "🥕", Name = "Carrots", Amount = "3 large, grated" },
new Ingredient { Icon = "🌾", Name = "All-Purpose Flour", Amount = "2 cups" }
],
Instructions =
[
"Preheat oven to 350°F (175°C)"
]
};
private Recipe? DeserializeRecipe(ReadOnlyMemory<byte> data)
{
try
{
var response = JsonSerializer.Deserialize<RecipeResponse>(data.Span);
return response?.Recipe;
}
catch
{
return null;
}
}
private void OnRecipeChanged(Recipe? recipe)
{
if (recipe is not null)
{
currentRecipe = recipe;
isProcessing = false;
StateHasChanged();
}
}
private async Task ImproveWithAI()
{
if (boundaryContext is null)
{
return;
}
isProcessing = true;
StateHasChanged();
// Serialize current recipe state with wrapper
var stateWrapper = new { recipe = currentRecipe };
byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes(stateWrapper, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
// Create message with state attached as DataContent
var message = new ChatMessage(ChatRole.User,
[
new TextContent("Improve the recipe"),
new DataContent(stateBytes, "application/json")
]);
await boundaryContext.SendAsync(message);
}
private void TogglePreference(string preference, bool isChecked)
{
if (isChecked && !currentRecipe.SpecialPreferences.Contains(preference))
{
currentRecipe.SpecialPreferences.Add(preference);
}
else if (!isChecked)
{
currentRecipe.SpecialPreferences.Remove(preference);
}
}
private void AddIngredient()
{
currentRecipe.Ingredients.Add(new Ingredient { Icon = "🥄", Name = "", Amount = "" });
}
private void RemoveIngredient(int index)
{
if (index >= 0 && index < currentRecipe.Ingredients.Count)
{
currentRecipe.Ingredients.RemoveAt(index);
}
}
private void UpdateIngredientName(int index, string name)
{
if (index >= 0 && index < currentRecipe.Ingredients.Count)
{
currentRecipe.Ingredients[index].Name = name;
}
}
private void UpdateIngredientAmount(int index, string amount)
{
if (index >= 0 && index < currentRecipe.Ingredients.Count)
{
currentRecipe.Ingredients[index].Amount = amount;
}
}
private void AddInstruction()
{
currentRecipe.Instructions.Add("");
}
private void RemoveInstruction(int index)
{
if (index >= 0 && index < currentRecipe.Instructions.Count)
{
currentRecipe.Instructions.RemoveAt(index);
}
}
private void UpdateInstruction(int index, string instruction)
{
if (index >= 0 && index < currentRecipe.Instructions.Count)
{
currentRecipe.Instructions[index] = instruction;
}
}
}
@@ -0,0 +1,305 @@
/* Shared State Demo Layout */
.shared-state-layout {
display: flex;
height: 100%;
gap: 16px;
padding: 16px;
background-color: #f8f9fa;
}
/* Recipe Panel */
.recipe-panel {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
background-color: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow-y: auto;
}
/* Recipe Header */
.recipe-header {
display: flex;
flex-direction: column;
gap: 12px;
}
.recipe-title {
font-size: 24px;
font-weight: 600;
border: none;
border-bottom: 2px solid #e5e7eb;
padding: 8px 0;
background: transparent;
color: #111827;
width: 100%;
}
.recipe-title:focus {
outline: none;
border-bottom-color: #3b82f6;
}
.recipe-meta {
display: flex;
gap: 16px;
}
.meta-item {
display: flex;
align-items: center;
gap: 8px;
}
.meta-icon {
font-size: 18px;
}
.meta-item select {
padding: 6px 12px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background-color: white;
font-size: 14px;
color: #374151;
cursor: pointer;
}
.meta-item select:focus {
outline: none;
border-color: #3b82f6;
}
/* Section Styles */
.preferences-section,
.ingredients-section,
.instructions-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.preferences-section h2,
.ingredients-section h2,
.instructions-section h2 {
font-size: 16px;
font-weight: 600;
color: #111827;
margin: 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
/* Dietary Preferences */
.preferences-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.preference-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background-color: #f3f4f6;
border-radius: 20px;
font-size: 14px;
color: #374151;
cursor: pointer;
transition: background-color 0.2s;
}
.preference-item:hover {
background-color: #e5e7eb;
}
.preference-item input[type="checkbox"] {
accent-color: #3b82f6;
}
/* Ingredients */
.ingredients-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.ingredient-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
background-color: #f9fafb;
border-radius: 8px;
}
.ingredient-icon {
font-size: 20px;
width: 32px;
text-align: center;
}
.ingredient-inputs {
flex: 1;
display: flex;
gap: 8px;
}
.ingredient-inputs input {
flex: 1;
padding: 8px 12px;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
color: #374151;
}
.ingredient-inputs input:focus {
outline: none;
border-color: #3b82f6;
}
/* Instructions */
.instructions-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.instruction-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
background-color: #f9fafb;
border-radius: 8px;
}
.step-number {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background-color: #3b82f6;
color: white;
font-size: 14px;
font-weight: 600;
border-radius: 50%;
flex-shrink: 0;
}
.instruction-row input {
flex: 1;
padding: 8px 12px;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
color: #374151;
}
.instruction-row input:focus {
outline: none;
border-color: #3b82f6;
}
/* Buttons */
.add-button {
padding: 6px 12px;
background-color: #e5e7eb;
border: none;
border-radius: 6px;
font-size: 14px;
color: #374151;
cursor: pointer;
transition: background-color 0.2s;
}
.add-button:hover {
background-color: #d1d5db;
}
.remove-button {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 18px;
color: #9ca3af;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.remove-button:hover {
background-color: #fef2f2;
border-color: #fca5a5;
color: #ef4444;
}
.improve-button {
padding: 12px 24px;
background-color: #3b82f6;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
color: white;
cursor: pointer;
transition: background-color 0.2s;
margin-top: auto;
}
.improve-button:hover:not(:disabled) {
background-color: #2563eb;
}
.improve-button:disabled {
background-color: #93c5fd;
cursor: not-allowed;
}
/* Chat Panel */
.chat-panel {
width: 400px;
display: flex;
flex-direction: column;
background-color: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.chat-header {
padding: 16px;
border-bottom: 1px solid #e5e7eb;
}
.chat-title {
font-size: 16px;
font-weight: 600;
color: #111827;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.chat-input-container {
padding: 16px;
border-top: 1px solid #e5e7eb;
}