Add AG-UI Blazor sample

This commit is contained in:
Javier Calvarro Nelson
2025-11-13 20:08:34 +01:00
Unverified
parent 0d9ae1920d
commit 0340531f3a
139 changed files with 9595 additions and 1 deletions
@@ -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
};
}
@@ -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();
}
};
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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;
}
}