diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index ec197f58c0..3ce847dc6a 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -20,9 +20,12 @@ - + + + + @@ -107,6 +110,29 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/AGUIDojo/.gitignore b/dotnet/samples/AGUIDojo/.gitignore new file mode 100644 index 0000000000..6f7a6bdeca --- /dev/null +++ b/dotnet/samples/AGUIDojo/.gitignore @@ -0,0 +1 @@ +.playwright-mcp/ diff --git a/dotnet/samples/AGUIDojo/AGUIDojo.slnx b/dotnet/samples/AGUIDojo/AGUIDojo.slnx new file mode 100644 index 0000000000..8d835dc18f --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojo.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/AGUIDojoClient.csproj b/dotnet/samples/AGUIDojo/AGUIDojoClient/AGUIDojoClient.csproj new file mode 100644 index 0000000000..ade81ba321 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/AGUIDojoClient.csproj @@ -0,0 +1,20 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + + + + + diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/AgentBoundary.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/AgentBoundary.cs new file mode 100644 index 0000000000..8f30237f76 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/AgentBoundary.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.AI; + +[CascadingTypeParameter(nameof(TState))] +public partial class AgentStateBoundary : IComponent, IDisposable +{ + private RenderHandle _renderHandle; + private RenderFragment? _renderWithState; + private protected AgentBoundaryContext? _context; + private AgentThread? _currentThread; + private AIAgent? _currentAgent; + private bool _disposed; + + [EditorRequired][Parameter] public AIAgent? Agent { get; set; } + + [Parameter] public AgentThread? Thread { get; set; } + + [Parameter] public TState? State { get; set; } + + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// + /// Callback invoked when the boundary context is created or recreated. + /// Use this to register tools or perform other initialization. + /// + [Parameter] public EventCallback OnContextCreated { get; set; } + + [Inject] private ILogger> Logger { get; set; } = default!; + + public void Attach(RenderHandle renderHandle) + { + this._renderHandle = renderHandle; + this._renderWithState = this.RenderWithState; + Log.AgentBoundaryAttached(this.Logger); + } + + // Agent boundary renders once when it receives the initial set of parameters + // and only ever renders again if the Agent, Thread, or State changes. + // Re-rendering the agent boundary will dispose the existing context and create + // a new one. + public Task SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + + if (this.Agent == null) + { + throw new InvalidOperationException("AgentBoundary requires an Agent parameter."); + } + + // Use Thread parameter if provided, otherwise keep existing thread or create new one + var thread = this.Thread ?? this._currentThread ?? this.Agent.GetNewThread(); + + TState? currentState = this._context != null ? this._context.CurrentState : default; + + bool agentChanged = this.Agent != this._currentAgent; + bool threadChanged = thread != this._currentThread; + bool stateChanged = !EqualityComparer.Default.Equals(this.State, currentState); + + bool refresh = agentChanged || threadChanged || stateChanged; + + Log.AgentBoundaryRefreshCheck( + this.Logger, + refresh, + agentChanged, + threadChanged, + stateChanged, + this.Agent?.GetHashCode() ?? 0, + this._currentAgent?.GetHashCode() ?? 0, + thread?.GetHashCode() ?? 0, + this._currentThread?.GetHashCode() ?? 0); + + if (refresh) + { + this._context?.Dispose(); + this._context = null; + this._currentThread = null; + } + + Log.AgentBoundaryParametersSet(this.Logger, refresh); + + this._currentAgent = this.Agent; + this._currentThread = thread; + + // Agent is validated non-null above, thread is either from Thread parameter or GetNewThread() + bool isNewContext = this._context == null; + this._context ??= new AgentBoundaryContext(this.Agent!, thread!, this.Logger); + this._context.CurrentState = this.State; + + // Invoke the context created callback if we created a new context + if (isNewContext && this.OnContextCreated.HasDelegate) + { + _ = this.OnContextCreated.InvokeAsync(this._context); + } + + if (refresh) + { + this.Render(); + } + + return Task.CompletedTask; + } + + private void Render() + { + Log.AgentBoundaryRendering(this.Logger); + this._renderHandle.Render(builder => + { + builder.OpenComponent>>(0); + builder.AddComponentParameter(1, "Value", this._context); + builder.AddComponentParameter(2, "IsFixed", true); + builder.AddComponentParameter(3, "ChildContent", this._renderWithState); + builder.CloseComponent(); + }); + } + + protected virtual void RenderWithState(RenderTreeBuilder builder) + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "Value", this.State); + builder.AddComponentParameter(2, "IsFixed", false); + builder.AddComponentParameter(3, "ChildContent", this.ChildContent); + builder.CloseComponent(); + } + + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!this._disposed) + { + if (disposing) + { + Log.AgentBoundaryDisposed(this.Logger); + this._context?.Dispose(); + } + this._disposed = true; + } + } + + private static partial class Log + { + [LoggerMessage(Level = LogLevel.Debug, Message = "AgentStateBoundary attached to render handle")] + public static partial void AgentBoundaryAttached(ILogger logger); + + [LoggerMessage(Level = LogLevel.Debug, Message = "AgentStateBoundary refresh check: refresh={Refresh}, agentChanged={AgentChanged}, threadChanged={ThreadChanged}, stateChanged={StateChanged}, agentHash={AgentHash}, currentAgentHash={CurrentAgentHash}, threadHash={ThreadHash}, currentThreadHash={CurrentThreadHash}")] + public static partial void AgentBoundaryRefreshCheck(ILogger logger, bool refresh, bool agentChanged, bool threadChanged, bool stateChanged, int agentHash, int currentAgentHash, int threadHash, int currentThreadHash); + + [LoggerMessage(Level = LogLevel.Debug, Message = "AgentStateBoundary parameters set, refresh required: {RefreshRequired}")] + public static partial void AgentBoundaryParametersSet(ILogger logger, bool refreshRequired); + + [LoggerMessage(Level = LogLevel.Debug, Message = "AgentStateBoundary rendering")] + public static partial void AgentBoundaryRendering(ILogger logger); + + [LoggerMessage(Level = LogLevel.Debug, Message = "AgentStateBoundary disposed")] + public static partial void AgentBoundaryDisposed(ILogger logger); + } +} + +public class AgentBoundary : AgentStateBoundary +{ + protected override void RenderWithState(RenderTreeBuilder builder) + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "Value", this._context); + builder.AddComponentParameter(2, "IsFixed", true); + builder.AddComponentParameter(3, "ChildContent", this.ChildContent); + builder.CloseComponent(); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/AgentBoundaryContext.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/AgentBoundaryContext.cs new file mode 100644 index 0000000000..348e538376 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/AgentBoundaryContext.cs @@ -0,0 +1,391 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace Microsoft.AspNetCore.Components.AI; + +public sealed partial class AgentBoundaryContext : IAgentBoundaryContext, IDisposable +{ + private readonly AIAgent _agent; + private readonly AgentThread _thread; + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly ILogger _logger; + private readonly List _messages = []; + private readonly List _pendingMessages = []; + private readonly List _messageChangeSubscribers = []; + private readonly List _responseUpdateSubscribers = []; + private readonly List _tools = []; + private readonly Dictionary> _pendingResponses = []; + + public CancellationToken CancellationToken => this._cancellationTokenSource.Token; + + public IReadOnlyList CompletedMessages => this._messages.AsReadOnly(); + + public TState? CurrentState { get; set; } + + public IReadOnlyList PendingMessages => this._pendingMessages.AsReadOnly(); + + public ChatMessage? CurrentMessage { get; private set; } + + public ChatResponseUpdate? CurrentUpdate { get; private set; } + + ILogger IAgentBoundaryContext.Logger => this._logger; + + public AgentBoundaryContext(AIAgent agent, AgentThread thread, ILogger logger) + { + this._agent = agent; + this._thread = thread; + this._cancellationTokenSource = new CancellationTokenSource(); + this._logger = logger; + Log.AgentBoundaryContextCreated(this._logger); + } + + /// + /// Registers a client-side tool that will be passed to the agent during invocation. + /// These tools are sent to the server via ChatClientAgentRunOptions.ChatOptions.Tools. + /// + public void RegisterTool(AITool tool) + { + ArgumentNullException.ThrowIfNull(tool); + this._tools.Add(tool); + Log.ToolRegistered(this._logger, tool.Name); + } + + /// + /// Registers multiple client-side tools that will be passed to the agent during invocation. + /// + public void RegisterTools(params AITool[] tools) + { + foreach (var tool in tools) + { + this.RegisterTool(tool); + } + } + + /// + /// Creates a pending response for the given key and returns a task that completes when ProvideResponse is called. + /// Used by frontend tools to wait for user input from UI components. + /// + /// A unique key to identify this pending response (e.g., function call ID). + /// A task that completes with the response object when ProvideResponse is called. + public Task WaitForResponse(string key) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + this._pendingResponses[key] = tcs; + Log.WaitForResponseStarted(this._logger, key); + return tcs.Task; + } + + /// + /// Provides a response for a pending request, completing the task returned by WaitForResponse. + /// Called by UI components when user interaction is complete. + /// + /// The key used when calling WaitForResponse. + /// The response object to return. + /// True if the response was provided successfully, false if no pending request was found. + public bool ProvideResponse(string key, object response) + { + if (this._pendingResponses.TryGetValue(key, out var tcs)) + { + this._pendingResponses.Remove(key); + tcs.TrySetResult(response); + Log.ResponseProvided(this._logger, key); + return true; + } + + Log.ResponseNotFound(this._logger, key); + return false; + } + + public void Dispose() + { + Log.AgentBoundaryContextDisposed(this._logger); + this._cancellationTokenSource.Cancel(); + this._cancellationTokenSource.Dispose(); + GC.SuppressFinalize(this); + } + + public async Task SendAsync(params ChatMessage[] userMessages) + { + Log.SendAsyncStarted(this._logger, userMessages.Length); + + // This starts a new turn. Once completed, we will add the new messages to the conversation. + this._pendingMessages.Clear(); + this._pendingMessages.AddRange(userMessages); + + // User messages added, notify subscribers. + this.TriggerMessageChanges(); + + // Build run options with registered tools if any + AgentRunOptions? options = null; + if (this._tools.Count > 0) + { + options = new ChatClientAgentRunOptions(new ChatOptions + { + Tools = [.. this._tools] + }); + Log.RunningWithTools(this._logger, this._tools.Count); + } + + // Start a turn. Collect all updates as we stream them. + await foreach (var update in this._agent.RunStreamingAsync( + userMessages, + this._thread, + options, + this._cancellationTokenSource.Token)) + { + var chatUpdate = update.AsChatResponseUpdate(); + + this.CurrentUpdate = chatUpdate; + Log.ChatResponseUpdateReceived(this._logger); + + // Notify subscribers of the new update, this always happens before a new message is created/updated. + this.TriggerChatResponseUpdate(); + + // This creates or adds a new message to the pending messages as needed. + var isNewMessage = MessageHelpers.ProcessUpdate(chatUpdate, this._pendingMessages, this._logger); + if (isNewMessage) + { + Log.NewMessageCreated(this._logger, this._pendingMessages.Count); + this.CurrentMessage = this._pendingMessages[this._pendingMessages.Count - 1]; + + // Finalize the previous message's content if we have 2 or more messages now. + if (this._pendingMessages.Count > userMessages.Length + 1) + { + MessageHelpers.CoalesceContent(this._pendingMessages[this._pendingMessages.Count - 2].Contents); + } + + // Notify subscribers of new message + this.TriggerMessageChanges(); + } + } + + // Process any remaining updates to finalize the last message. + if (this._pendingMessages.Count > userMessages.Length) + { + MessageHelpers.CoalesceContent(this._pendingMessages[this._pendingMessages.Count - 1].Contents); + } + + // Add the new messages to the conversation + this._messages.AddRange(this._pendingMessages); + + // Finish the turn + this._pendingMessages.Clear(); + this.CurrentMessage = null; + this.CurrentUpdate = null; + + // Notify subscribers + this.TriggerChatResponseUpdate(); + this.TriggerMessageChanges(); + + Log.SendAsyncCompleted(this._logger, this._messages.Count); + } + + private void TriggerChatResponseUpdate() + { + Log.ResponseUpdateSubscribersNotified(this._logger, this._responseUpdateSubscribers.Count); + // Iterate backwards to avoid issues if subscribers are removed during iteration + for (var i = this._responseUpdateSubscribers.Count - 1; i >= 0; i--) + { + this._responseUpdateSubscribers[i](); + } + } + + private void TriggerMessageChanges() + { + Log.MessageChangeSubscribersNotified(this._logger, this._messageChangeSubscribers.Count); + // Iterate backwards to avoid issues if subscribers are removed during iteration + for (var i = this._messageChangeSubscribers.Count - 1; i >= 0; i--) + { + this._messageChangeSubscribers[i](); + } + } + + public MessageSubscription SubscribeToMessageChanges(Action onNewMessage) + { + return new MessageSubscription(this._messageChangeSubscribers, onNewMessage); + } + + public ResponseUpdateSubscription SubscribeToResponseUpdates(Action onChatResponse) + { + return new ResponseUpdateSubscription(this._responseUpdateSubscribers, onChatResponse); + } + + private static partial class Log + { + [LoggerMessage(Level = LogLevel.Debug, Message = "AgentBoundaryContext created for thread")] + public static partial void AgentBoundaryContextCreated(ILogger logger); + + [LoggerMessage(Level = LogLevel.Debug, Message = "AgentBoundaryContext disposed")] + public static partial void AgentBoundaryContextDisposed(ILogger logger); + + [LoggerMessage(Level = LogLevel.Debug, Message = "SendAsync started with {MessageCount} user message(s)")] + public static partial void SendAsyncStarted(ILogger logger, int messageCount); + + [LoggerMessage(Level = LogLevel.Debug, Message = "SendAsync completed, total messages: {TotalMessages}")] + public static partial void SendAsyncCompleted(ILogger logger, int totalMessages); + + [LoggerMessage(Level = LogLevel.Debug, Message = "New message created during streaming, pending count: {PendingCount}")] + public static partial void NewMessageCreated(ILogger logger, int pendingCount); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Chat response update received")] + public static partial void ChatResponseUpdateReceived(ILogger logger); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Message change subscribers notified, subscriber count: {SubscriberCount}")] + public static partial void MessageChangeSubscribersNotified(ILogger logger, int subscriberCount); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Response update subscribers notified, subscriber count: {SubscriberCount}")] + public static partial void ResponseUpdateSubscribersNotified(ILogger logger, int subscriberCount); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Tool registered: {ToolName}")] + public static partial void ToolRegistered(ILogger logger, string toolName); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Running with {ToolCount} registered tool(s)")] + public static partial void RunningWithTools(ILogger logger, int toolCount); + + [LoggerMessage(Level = LogLevel.Debug, Message = "WaitForResponse started for key: {Key}")] + public static partial void WaitForResponseStarted(ILogger logger, string key); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Response provided for key: {Key}")] + public static partial void ResponseProvided(ILogger logger, string key); + + [LoggerMessage(Level = LogLevel.Warning, Message = "No pending response found for key: {Key}")] + public static partial void ResponseNotFound(ILogger logger, string key); + } +} + +public interface IAgentBoundaryContext +{ + // Push new messages to the agent + Task SendAsync(params ChatMessage[] userMessages); + + // All message interactions from previous turns + IReadOnlyList CompletedMessages { get; } + + // All message interactions from the current turn. These represent completed messages only. + IReadOnlyList PendingMessages { get; } + + // The current message being processed by the agent. + ChatMessage? CurrentMessage { get; } + + ChatResponseUpdate? CurrentUpdate { get; } + + ILogger Logger { get; } + + // Triggered any time there is a change on a message. + MessageSubscription SubscribeToMessageChanges(Action onNewMessage); + + ResponseUpdateSubscription SubscribeToResponseUpdates(Action onChatResponse); + + CancellationToken CancellationToken { get; } + + /// + /// Registers a client-side tool that will be passed to the agent during invocation. + /// + void RegisterTool(AITool tool); + + /// + /// Registers multiple client-side tools that will be passed to the agent during invocation. + /// + void RegisterTools(params AITool[] tools); + + /// + /// Creates a pending response for the given key and returns a task that completes when ProvideResponse is called. + /// Used by frontend tools to wait for user input from UI components. + /// + /// A unique key to identify this pending response (e.g., function call ID). + /// A task that completes with the response object when ProvideResponse is called. + Task WaitForResponse(string key); + + /// + /// Provides a response for a pending request, completing the task returned by WaitForResponse. + /// Called by UI components when user interaction is complete. + /// + /// The key used when calling WaitForResponse. + /// The response object to return. + /// True if the response was provided successfully, false if no pending request was found. + bool ProvideResponse(string key, object response); +} + +public readonly struct MessageSubscription : IDisposable, IEquatable +{ + internal readonly List _subscribers; + internal readonly Action _subscription; + + internal MessageSubscription(List subscribers, Action subscription) + { + this._subscribers = subscribers; + this._subscription = subscription; + this._subscribers.Add(this._subscription); + } + + public void Dispose() => this._subscribers.Remove(this._subscription); + + public override bool Equals(object? obj) + { + return obj is MessageSubscription subscription && this.Equals(subscription); + } + + public bool Equals(MessageSubscription other) + { + return EqualityComparer>.Default.Equals(this._subscribers, other._subscribers) && + EqualityComparer.Default.Equals(this._subscription, other._subscription); + } + + public override int GetHashCode() + { + return HashCode.Combine(this._subscribers, this._subscription); + } + + public static bool operator ==(MessageSubscription left, MessageSubscription right) + { + return left.Equals(right); + } + + public static bool operator !=(MessageSubscription left, MessageSubscription right) + { + return !(left == right); + } +} + +public readonly struct ResponseUpdateSubscription : IDisposable, IEquatable +{ + private readonly List _subscribers; + private readonly Action _subscription; + + public ResponseUpdateSubscription(List subscribers, Action subscription) + { + this._subscribers = subscribers; + this._subscription = subscription; + this._subscribers.Add(this._subscription); + } + + public void Dispose() => this._subscribers.Remove(this._subscription); + + public override bool Equals(object? obj) + { + return obj is ResponseUpdateSubscription subscription && this.Equals(subscription); + } + + public bool Equals(ResponseUpdateSubscription other) + { + return EqualityComparer>.Default.Equals(this._subscribers, other._subscribers) && + EqualityComparer.Default.Equals(this._subscription, other._subscription); + } + + public override int GetHashCode() + { + return HashCode.Combine(this._subscribers, this._subscription); + } + + public static bool operator ==(ResponseUpdateSubscription left, ResponseUpdateSubscription right) + { + return left.Equals(right); + } + + public static bool operator !=(ResponseUpdateSubscription left, ResponseUpdateSubscription right) + { + return !(left == right); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/AgentInput.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/AgentInput.cs new file mode 100644 index 0000000000..cfd361d4f3 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/AgentInput.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.AspNetCore.Components.AI; + +public partial class AgentInput : IComponent +{ + private RenderHandle _renderHandle; + private AgentBoundaryContext? _context; + private string? _inputText; + + [CascadingParameter] public AgentBoundaryContext? AgentContext { get; set; } + + [Inject] private ILogger Logger { get; set; } = default!; + + [Parameter] public string Placeholder { get; set; } = "Type a message..."; + + public void Attach(RenderHandle renderHandle) + { + this._renderHandle = renderHandle; + } + + public Task SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + this._context = this.AgentContext; + Log.AgentInputAttached(this.Logger); + this.Render(); + return Task.CompletedTask; + } + + private void Render() + { + Log.AgentInputRendering(this.Logger); + this._renderHandle.Render(builder => + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "agent-input"); + + builder.OpenElement(2, "textarea"); + builder.AddAttribute(3, "value", this._inputText); + builder.AddAttribute(4, "oninput", EventCallback.Factory.Create(this, e => { this._inputText = e.Value?.ToString(); this.Render(); })); + builder.AddAttribute(5, "placeholder", this.Placeholder); + builder.AddAttribute(6, "rows", "1"); + builder.CloseElement(); + + builder.OpenElement(7, "button"); + builder.AddAttribute(8, "class", "send-button"); + builder.AddAttribute(9, "onclick", EventCallback.Factory.Create(this, this.SendAsync)); + builder.AddAttribute(10, "disabled", string.IsNullOrWhiteSpace(this._inputText)); + builder.AddMarkupContent(11, """"""); + builder.CloseElement(); // close button + + builder.CloseElement(); // close div + }); + } + + private async Task SendAsync() + { + if (!string.IsNullOrWhiteSpace(this._inputText) && this._context != null) + { + var text = this._inputText; + Log.AgentInputSendingMessage(this.Logger, text.Length); + this._inputText = ""; // Clear input immediately + this.Render(); // Re-render to clear input + + await this._context.SendAsync(new ChatMessage(ChatRole.User, text)); + Log.AgentInputMessageSent(this.Logger); + } + } + + private static partial class Log + { + [LoggerMessage(Level = LogLevel.Debug, Message = "AgentInput attached to render handle")] + public static partial void AgentInputAttached(ILogger logger); + + [LoggerMessage(Level = LogLevel.Debug, Message = "AgentInput rendering")] + public static partial void AgentInputRendering(ILogger logger); + + [LoggerMessage(Level = LogLevel.Debug, Message = "AgentInput sending message: {MessageLength} characters")] + public static partial void AgentInputSendingMessage(ILogger logger, int messageLength); + + [LoggerMessage(Level = LogLevel.Debug, Message = "AgentInput message sent successfully")] + public static partial void AgentInputMessageSent(ILogger logger); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/AgentState.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/AgentState.razor new file mode 100644 index 0000000000..7b0f53f307 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/AgentState.razor @@ -0,0 +1,93 @@ +@* Copyright (c) Microsoft. All rights reserved. *@ +@namespace Microsoft.AspNetCore.Components.AI +@using Microsoft.Extensions.AI +@typeparam TState +@implements IDisposable + + + @ChildContent + + +@code { + private ResponseUpdateSubscription? _subscription; + + /// + /// The agent boundary context to subscribe to for state updates. + /// + [CascadingParameter] + public IAgentBoundaryContext? BoundaryContext { get; set; } + + /// + /// The current state value. Can be set initially and will be updated by state events. + /// + [Parameter] + public TState? CurrentState { get; set; } + + /// + /// Callback to deserialize a STATE_SNAPSHOT (application/json) into TState. + /// + [Parameter] + public Func, TState?>? OnSnapshot { get; set; } + + /// + /// Callback to apply a STATE_DELTA (application/json-patch+json) to the current state. + /// Returns the updated state. + /// + [Parameter] + public Func, TState?>? OnDelta { get; set; } + + /// + /// Optional callback invoked whenever state changes. + /// + [Parameter] + public EventCallback CurrentStateChanged { get; set; } + + /// + /// Child content that will receive the cascaded state. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + protected override void OnInitialized() + { + if (BoundaryContext is not null) + { + _subscription = BoundaryContext.SubscribeToResponseUpdates(OnResponseUpdate); + } + } + + private void OnResponseUpdate() + { + var update = BoundaryContext?.CurrentUpdate; + if (update?.Contents is null) + { + return; + } + + foreach (var content in update.Contents) + { + if (content is DataContent dataContent) + { + if (string.Equals(dataContent.MediaType, "application/json", StringComparison.OrdinalIgnoreCase) && OnSnapshot is not null) + { + // STATE_SNAPSHOT - let app deserialize + CurrentState = OnSnapshot(dataContent.Data); + _ = CurrentStateChanged.InvokeAsync(CurrentState); + InvokeAsync(StateHasChanged); + } + else if (string.Equals(dataContent.MediaType, "application/json-patch+json", StringComparison.OrdinalIgnoreCase) && OnDelta is not null) + { + // STATE_DELTA - let app apply the patch + CurrentState = OnDelta(CurrentState, dataContent.Data); + _ = CurrentStateChanged.InvokeAsync(CurrentState); + InvokeAsync(StateHasChanged); + } + } + } + } + + public void Dispose() + { + _subscription?.Dispose(); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/AgentSuggestions.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/AgentSuggestions.cs new file mode 100644 index 0000000000..b1e0a066df --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/AgentSuggestions.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.AspNetCore.Components.AI; + +/// +/// Represents a suggestion that can be displayed and sent to the agent. +/// +public readonly struct Suggestion : IEquatable +{ + /// + /// Initializes a new instance of the struct. + /// + /// The display text for the suggestion. + /// The message to send when the suggestion is selected. + public Suggestion(string text, ChatMessage message) + { + Text = text; + Message = message; + } + + /// + /// Initializes a new instance of the struct with a simple text message. + /// + /// The display text for the suggestion, also used as the message content. + public Suggestion(string text) + { + Text = text; + Message = new ChatMessage(ChatRole.User, text); + } + + /// + /// Gets the display text for the suggestion. + /// + public string Text { get; } + + /// + /// Gets the message to send when the suggestion is selected. + /// + public ChatMessage Message { get; } + + /// + public override bool Equals(object? obj) => obj is Suggestion other && Equals(other); + + /// + public bool Equals(Suggestion other) => Text == other.Text; + + /// + public override int GetHashCode() => Text?.GetHashCode() ?? 0; + + /// + /// Determines whether two instances are equal. + /// + public static bool operator ==(Suggestion left, Suggestion right) => left.Equals(right); + + /// + /// Determines whether two instances are not equal. + /// + public static bool operator !=(Suggestion left, Suggestion right) => !left.Equals(right); +} + +public partial class AgentSuggestions : IComponent +{ + private RenderHandle _renderHandle; + private AgentBoundaryContext? _context; + private IReadOnlyList? _suggestions; + + [CascadingParameter] public AgentBoundaryContext? AgentContext { get; set; } + + [Parameter] public IReadOnlyList? Suggestions { get; set; } + + public void Attach(RenderHandle renderHandle) + { + this._renderHandle = renderHandle; + } + + public Task SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + this._context = this.AgentContext; + this._suggestions = this.Suggestions; + this.Render(); + return Task.CompletedTask; + } + + private void Render() + { + this._renderHandle.Render(builder => + { + if (this._suggestions is null || this._suggestions.Count == 0) + { + return; + } + + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "agent-suggestions"); + + for (var i = 0; i < this._suggestions.Count; i++) + { + var suggestion = this._suggestions[i]; + builder.OpenElement(2, "button"); + builder.SetKey(suggestion.Text); + builder.AddAttribute(3, "class", "suggestion-button"); + builder.AddAttribute(4, "onclick", EventCallback.Factory.Create(this, () => this.SelectSuggestionAsync(suggestion))); + builder.AddContent(5, suggestion.Text); + builder.CloseElement(); + } + + builder.CloseElement(); + }); + } + + private async Task SelectSuggestionAsync(Suggestion suggestion) + { + if (this._context != null) + { + await this._context.SendAsync(suggestion.Message); + } + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/ContentBlock.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/ContentBlock.cs new file mode 100644 index 0000000000..28f85187f3 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/ContentBlock.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.AI; + +#pragma warning disable CA1812 // Internal class is apparently never instantiated +internal sealed class ContentBlock : IComponent +#pragma warning restore CA1812 // Internal class is apparently never instantiated +{ + private RenderHandle _renderHandle; + + [Parameter] public RenderFragment ChildContent { get; set; } = default!; + + public void Attach(RenderHandle renderHandle) + { + this._renderHandle = renderHandle; + } + + public Task SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + // Always render when parameters are set - the parent component (MessageList) + // only triggers renders when there are actual updates to show. + this._renderHandle.Render(this.Render); + return Task.CompletedTask; + } + + private void Render(RenderTreeBuilder builder) + { + builder.AddContent(0, this.ChildContent); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Contents/ContentContext.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Contents/ContentContext.cs new file mode 100644 index 0000000000..7d318d1871 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Contents/ContentContext.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.AspNetCore.Components.AI; + +public class ContentContext(AIContent content) +{ + public AIContent Content { get; init; } = content; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Contents/ContentTemplateBase.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Contents/ContentTemplateBase.cs new file mode 100644 index 0000000000..59773268d8 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Contents/ContentTemplateBase.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.AspNetCore.Components.AI; + +public abstract class ContentTemplateBase : IComponent +{ + public abstract void Attach(RenderHandle renderHandle); + + public abstract Task SetParametersAsync(ParameterView parameters); + + public virtual bool When(ContentContext context) => true; + + [Parameter] public RenderFragment ChildContent { get; set; } = (content) => builder => { }; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Contents/FunctionCallTemplate.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Contents/FunctionCallTemplate.cs new file mode 100644 index 0000000000..b4b94ee32a --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Contents/FunctionCallTemplate.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.AspNetCore.Components.AI; + +/// +/// Template for rendering function/tool call content in messages. +/// Provides access to both the call and its result (when available) via InvocationContext. +/// +public class FunctionCallTemplate : ContentTemplateBase +{ + /// + /// Gets or sets the tool name to filter on. If null, matches all function calls. + /// + [Parameter] public string? ToolName { get; set; } + + [CascadingParameter] internal MessageListContext Context { get; set; } = default!; + + public override void Attach(RenderHandle renderHandle) + { + // This component never renders anything by itself. + this.ChildContent = this.RenderFunctionCall; + } + + public override Task SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + this.Context.RegisterContentTemplate(this); + return Task.CompletedTask; + } + + /// + /// Determines if this template should handle the given content. + /// + /// The content context. + /// True if this template should render the content. + public new bool When(ContentContext context) + { + if (context.Content is not FunctionCallContent call) + { + return false; + } + + // Filter by tool name if specified + if (this.ToolName != null && !string.Equals(call.Name, this.ToolName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return true; + } + + private RenderFragment RenderFunctionCall(ContentContext content) => builder => + { + if (content.Content is FunctionCallContent call) + { + // Get or create the invocation context which tracks call and result + var invocation = this.Context.GetOrCreateInvocation(call); + + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "Value", invocation); + builder.AddComponentParameter(2, "IsFixed", true); + builder.AddComponentParameter(3, "ChildContent", (RenderFragment)(innerBuilder => + { + // Default fallback rendering - shows function name and loading/result status + innerBuilder.OpenElement(0, "div"); + innerBuilder.AddAttribute(1, "class", "function-call"); + innerBuilder.AddContent(2, $"Function: {call.Name}"); + if (invocation.HasResult) + { + innerBuilder.AddContent(3, " [Result available]"); + } + else + { + innerBuilder.AddContent(3, " [Loading...]"); + } + + innerBuilder.CloseElement(); + })); + builder.CloseComponent(); + } + }; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Contents/InvocationContext.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Contents/InvocationContext.cs new file mode 100644 index 0000000000..df56b12795 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Contents/InvocationContext.cs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.AspNetCore.Components.AI; + +/// +/// Context for a function invocation, tracking the call and its result. +/// +public class InvocationContext +{ + private Action? _resultArrived; + + /// + /// Initializes a new instance of the class. + /// + /// The function call content. + public InvocationContext(FunctionCallContent call) + { + this.Call = call; + } + + /// + /// Gets the function call content. + /// + public FunctionCallContent Call { get; } + + /// + /// Gets the function result content, if available. + /// + public FunctionResultContent? ResultContent { get; private set; } + + /// + /// Gets a value indicating whether the result has arrived. + /// + public bool HasResult => this.ResultContent != null; + + /// + /// Gets the function name from the call. + /// + public string FunctionName => this.Call.Name; + + /// + /// Gets the call ID. + /// + public string CallId => this.Call.CallId; + + /// + /// Gets the arguments from the call. + /// + public IDictionary? Arguments => this.Call.Arguments; + + /// + /// Event raised when the result arrives. + /// +#pragma warning disable CA1003 // Use generic event handler instances + public event Action? ResultArrived +#pragma warning restore CA1003 // Use generic event handler instances + { + add => this._resultArrived += value; + remove => this._resultArrived -= value; + } + + /// + /// Sets the result and raises the ResultArrived event. + /// + /// The function result content. + internal void SetResult(FunctionResultContent result) + { + this.ResultContent = result; + this._resultArrived?.Invoke(); + this._resultArrived = null; // Clear invocation list after firing + } + + /// + /// Gets an argument value by name, deserializing from JSON if necessary. + /// + /// The type to deserialize to. + /// The argument name. + /// The argument value, or default if not found. + public T? GetArgument(string name) + { + if (this.Arguments is null || !this.Arguments.TryGetValue(name, out var value)) + { + return default; + } + + if (value is null) + { + return default; + } + + if (value is T typed) + { + return typed; + } + + if (value is JsonElement jsonElement) + { + return jsonElement.Deserialize(); + } + + // Try to convert via JSON serialization + var json = JsonSerializer.Serialize(value); + return JsonSerializer.Deserialize(json); + } + + /// + /// Gets the result as a specific type, deserializing from JSON if necessary. + /// + /// The type to deserialize to. + /// The result value, or default if not available. + public T? GetResult() + { + if (this.ResultContent?.Result is null) + { + return default; + } + + if (this.ResultContent.Result is T typed) + { + return typed; + } + + if (this.ResultContent.Result is JsonElement jsonElement) + { + return jsonElement.Deserialize(); + } + + // Try to convert via JSON serialization + var json = JsonSerializer.Serialize(this.ResultContent.Result); + return JsonSerializer.Deserialize(json); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Contents/TextTemplate.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Contents/TextTemplate.cs new file mode 100644 index 0000000000..f698ac8e6e --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Contents/TextTemplate.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.AspNetCore.Components.AI; + +public class TextTemplate : ContentTemplateBase +{ + [CascadingParameter] internal MessageListContext Context { get; set; } = default!; + + public override void Attach(RenderHandle renderHandle) + { + // This component never renders anything by itself. + this.ChildContent = this.RenderText; + } + + public override Task SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + this.Context.RegisterContentTemplate(this); + return Task.CompletedTask; + } + + private RenderFragment RenderText(ContentContext content) => builder => + { + if (content.Content is TextContent textContent) + { + builder.AddContent(0, textContent.Text); + } + }; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/DefaultMessageTemplate.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/DefaultMessageTemplate.cs new file mode 100644 index 0000000000..294e0c51b5 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/DefaultMessageTemplate.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.AspNetCore.Components.AI; + +#pragma warning disable CA1812 // Internal class is apparently never instantiated +internal sealed class DefaultMessageTemplate : MessageTemplateBase +#pragma warning restore CA1812 // Internal class is apparently never instantiated +{ + public override bool When(MessageContext context) => true; + + // Buffer to convert response updates to chat messages. + private readonly List _buffer = []; + + public DefaultMessageTemplate() + { + this.ChildContent = this.SelectTemplate; + } + + private RenderFragment SelectTemplate(MessageContext messageContext) + { + if (messageContext.ChatMessage is not null) + { + var getRenderContents = messageContext.RenderContents(); + return CreateRenderMessage( + messageContext.ChatMessage.Role, + messageContext.ChatMessage.MessageId, + getRenderContents); + } + else + { + var getRenderContents = messageContext.RenderContents(); + var updates = messageContext.ResponseUpdates ?? []; + if (updates.Count == 0) + { + throw new InvalidOperationException("MessageContext must have either a ChatMessage or at least one ResponseUpdate."); + } + this._buffer.Clear(); + this._buffer.AddMessages(updates); + if (this._buffer.Count != 1) + { + throw new InvalidOperationException("DefaultMessageTemplate only supports a single ResponseUpdate."); + } + + return CreateRenderMessage( + this._buffer[0].Role, + this._buffer[0].MessageId, + getRenderContents); + } + } + + private static RenderFragment CreateRenderMessage( + ChatRole role, + string? messageId, + RenderFragment getRenderContents) + { + var roleClass = $"{role}-message"; + return builder => + { + builder.OpenElement(0, "div"); + if (!string.IsNullOrEmpty(messageId)) + { + builder.AddAttribute(1, "id", messageId); + } + else + { + builder.AddAttribute(2, "class", $"chat-message {roleClass}"); + } + builder.AddContent(3, getRenderContents); + builder.CloseElement(); + }; + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/MessageContext.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/MessageContext.cs new file mode 100644 index 0000000000..b25712085f --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/MessageContext.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using Microsoft.Extensions.AI; + +namespace Microsoft.AspNetCore.Components.AI; + +public class MessageContext +{ + private readonly MessageListContext _messageListContext; + private RenderFragment? _contentsRenderer; + private MessageTemplateBase? _template; + private readonly Dictionary _contentRenderers = []; + + internal MessageContext(ChatMessage message, MessageListContext messageListContext) + { + this.ChatMessage = message; + this._messageListContext = messageListContext; + } + + public ChatMessage? ChatMessage { get; init; } + + public IList? ResponseUpdates { get; init; } + + public RenderFragment RenderContents() => this.GetOrCreateContentsRenderer(); + + internal void SetTemplate(MessageTemplateBase template) + { + this._template = template; + } + + private RenderFragment GetOrCreateContentsRenderer() + { + if (this._contentsRenderer != null) + { + return this._contentsRenderer; + } + + if (this._template == null) + { + throw new InvalidOperationException("Message template has not been set for this message context."); + } + + this._contentsRenderer = builder => + { + if (this.ChatMessage != null) + { + for (int i = 0; i < this.ChatMessage.Contents.Count; i++) + { + var content = this.ChatMessage.Contents[i]; + builder.AddContent(0, this.ResolveContentRenderer(content)); + } + } + else if (this.ResponseUpdates != null) + { + for (int i = 0; i < this.ResponseUpdates.Count; i++) + { + var update = this.ResponseUpdates[i]; + for (int j = 0; j < update.Contents.Count; j++) + { + var content = update.Contents[j]; + if (this._contentRenderers.TryGetValue(content, out var contentRenderer)) + { + builder.AddContent(0, contentRenderer); + } + } + } + } + else + { + throw new InvalidOperationException("MessageContext must have either a ChatMessage or ResponseUpdates to render contents."); + } + }; + + return this._contentsRenderer; + } + + private RenderFragment ResolveContentRenderer(AIContent content) + { + if (this._contentRenderers.TryGetValue(content, out var contentRenderer)) + { + return contentRenderer; + } + + Debug.Assert(this._template != null); + contentRenderer = this._template.GetContentTemplate(content); + if (contentRenderer != null) + { + this._contentRenderers[content] = contentRenderer; + return contentRenderer; + } + contentRenderer = this._messageListContext.GetContentTemplate(content); + this._contentRenderers[content] = contentRenderer; + return contentRenderer; + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/MessageHelpers.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/MessageHelpers.cs new file mode 100644 index 0000000000..fce41e9d6c --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/MessageHelpers.cs @@ -0,0 +1,331 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components.AI; + +// Roughly lifted from src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs +internal static class MessageHelpers +{ + internal static void CoalesceContent(IList contents) + { + Coalesce( + contents, + mergeSingle: false, + canMerge: null, + static (contents, start, end) => new(MergeText(contents, start, end)) { AdditionalProperties = contents[start].AdditionalProperties?.Clone() }); + + Coalesce( + contents, + mergeSingle: false, + canMerge: static (r1, r2) => string.IsNullOrEmpty(r1.ProtectedData), // we allow merging if the first item has no ProtectedData, even if the second does + static (contents, start, end) => + { + TextReasoningContent content = new(MergeText(contents, start, end)) + { + AdditionalProperties = contents[start].AdditionalProperties?.Clone() + }; + +#if DEBUG + for (int i = start; i < end - 1; i++) + { + Debug.Assert(contents[i] is TextReasoningContent { ProtectedData: null }, "Expected all but the last to have a null ProtectedData"); + } +#endif + + if (((TextReasoningContent)contents[end - 1]).ProtectedData is { } protectedData) + { + content.ProtectedData = protectedData; + } + + return content; + }); + + Coalesce( + contents, + mergeSingle: false, + canMerge: static (r1, r2) => r1.MediaType == r2.MediaType && r1.HasTopLevelMediaType("text") && r1.Name == r2.Name, + static (contents, start, end) => + { + Debug.Assert(end - start > 1, "Expected multiple contents to merge"); + + MemoryStream ms = new(); + for (int i = start; i < end; i++) + { + var current = (DataContent)contents[i]; +#if NET + ms.Write(current.Data.Span); +#else + if (!MemoryMarshal.TryGetArray(current.Data, out var segment)) + { + segment = new(current.Data.ToArray()); + } + + ms.Write(segment.Array!, segment.Offset, segment.Count); +#endif + } + + var first = (DataContent)contents[start]; + return new DataContent(new ReadOnlyMemory(ms.GetBuffer(), 0, (int)ms.Length), first.MediaType) { Name = first.Name }; + }); + + Coalesce( + contents, + mergeSingle: true, + canMerge: static (r1, r2) => r1.CallId == r2.CallId, + static (contents, start, end) => + { + var firstContent = (CodeInterpreterToolCallContent)contents[start]; + + if (start == end - 1) + { + if (firstContent.Inputs is not null) + { + CoalesceContent(firstContent.Inputs); + } + + return firstContent; + } + + List? inputs = null; + + for (int i = start; i < end; i++) + { + (inputs ??= []).AddRange(((CodeInterpreterToolCallContent)contents[i]).Inputs ?? []); + } + + if (inputs is not null) + { + CoalesceContent(inputs); + } + + return new() + { + CallId = firstContent.CallId, + Inputs = inputs, + AdditionalProperties = firstContent.AdditionalProperties?.Clone(), + }; + }); + + Coalesce( + contents, + mergeSingle: true, + canMerge: static (r1, r2) => r1.CallId is not null && r2.CallId is not null && r1.CallId == r2.CallId, + static (contents, start, end) => + { + var firstContent = (CodeInterpreterToolResultContent)contents[start]; + + if (start == end - 1) + { + if (firstContent.Outputs is not null) + { + CoalesceContent(firstContent.Outputs); + } + + return firstContent; + } + + List? output = null; + + for (int i = start; i < end; i++) + { + (output ??= []).AddRange(((CodeInterpreterToolResultContent)contents[i]).Outputs ?? []); + } + + if (output is not null) + { + CoalesceContent(output); + } + + return new() + { + CallId = firstContent.CallId, + Outputs = output, + AdditionalProperties = firstContent.AdditionalProperties?.Clone(), + }; + }); + + static string MergeText(IList contents, int start, int end) + { + Debug.Assert(end - start > 1, "Expected multiple contents to merge"); + + StringBuilder sb = new(); + for (int i = start; i < end; i++) + { + _ = sb.Append(contents[i]); + } + + return sb.ToString(); + } + + static void Coalesce( + IList contents, + bool mergeSingle, + Func? canMerge, + Func, int, int, TContent> merge) + where TContent : AIContent + { + // Iterate through all of the items in the list looking for contiguous items that can be coalesced. + int start = 0; + while (start < contents.Count) + { + if (!TryAsCoalescable(contents[start], out var firstContent)) + { + start++; + continue; + } + + // Iterate until we find a non-coalescable item. + int i = start + 1; + TContent prev = firstContent; + while (i < contents.Count && TryAsCoalescable(contents[i], out TContent? next) && (canMerge is null || canMerge(prev, next))) + { + i++; + prev = next; + } + + // If there's only one item in the run, and we don't want to merge single items, skip it. + if (start == i - 1 && !mergeSingle) + { + start++; + continue; + } + + // Store the replacement node and null out all of the nodes that we coalesced. + // We can then remove all coalesced nodes in one O(N) operation via RemoveAll. + // Leave start positioned at the start of the next run. + contents[start] = merge(contents, start, i); + + start++; + while (start < i) + { + contents[start++] = null!; + } + + static bool TryAsCoalescable(AIContent content, [NotNullWhen(true)] out TContent? coalescable) + { + if (content is TContent tmp && tmp.Annotations is not { Count: > 0 }) + { + coalescable = tmp; + return true; + } + + coalescable = null; + return false; + } + } + + // Remove all of the null slots left over from the coalescing process. + RemoveNullContents(contents); + } + } + + private static void RemoveNullContents(IList contents) + where T : class + { + if (contents is List contentsList) + { + _ = contentsList.RemoveAll(u => u is null); + } + else + { + int nextSlot = 0; + int contentsCount = contents.Count; + for (int i = 0; i < contentsCount; i++) + { + if (contents[i] is { } content) + { + contents[nextSlot++] = content; + } + } + + for (int i = contentsCount - 1; i >= nextSlot; i--) + { + contents.RemoveAt(i); + } + + Debug.Assert(nextSlot == contents.Count, "Expected final count to equal list length."); + } + } + + internal static bool ProcessUpdate(ChatResponseUpdate update, List messages, ILogger? logger = null) + { + // If there is no message created yet, or if the last update we saw had a different + // identifying parts, create a new message. + bool isNewMessage = true; + if (messages.Count != 0) + { + var lastMessage = messages[messages.Count - 1]; + isNewMessage = + NotEmptyOrEqual(update.AuthorName, lastMessage.AuthorName) || + NotEmptyOrEqual(update.MessageId, lastMessage.MessageId) || + NotNullOrEqual(update.Role, lastMessage.Role); + + logger?.LogDebug( + "ProcessUpdate checking: update.MessageId={UpdateMessageId}, lastMessage.MessageId={LastMessageId}, isNewMessage={IsNew}", + update.MessageId, lastMessage.MessageId, isNewMessage); + } + + // Get the message to target, either a new one or the last ones. + ChatMessage message; + if (isNewMessage) + { + message = new(ChatRole.Assistant, []); + messages.Add(message); + logger?.LogDebug("ProcessUpdate: Created new message, total count={Count}", messages.Count); + } + else + { + message = messages[messages.Count - 1]; + } + + // Some members on ChatResponseUpdate map to members of ChatMessage. + // Incorporate those into the latest message; in cases where the message + // stores a single value, prefer the latest update's value over anything + // stored in the message. + + if (update.AuthorName is not null) + { + message.AuthorName = update.AuthorName; + } + + if (message.CreatedAt is null || (update.CreatedAt is not null && update.CreatedAt > message.CreatedAt)) + { + message.CreatedAt = update.CreatedAt; + } + + if (update.Role is ChatRole role) + { + message.Role = role; + } + + if (update.MessageId is { Length: > 0 }) + { + // Note that this must come after the message checks earlier, as they depend + // on this value for change detection. + var oldId = message.MessageId; + message.MessageId = update.MessageId; + logger?.LogDebug("ProcessUpdate: Set MessageId from {OldId} to {NewId}", oldId, update.MessageId); + } + + foreach (var content in update.Contents) + { + message.Contents.Add(content); + logger?.LogDebug("ProcessUpdate: Added content type {ContentType}", content.GetType().Name); + } + + return isNewMessage; + } + + /// Gets whether both strings are not null/empty and not the same as each other. + private static bool NotEmptyOrEqual(string? s1, string? s2) => + s1 is { Length: > 0 } str1 && s2 is { Length: > 0 } str2 && str1 != str2; + + /// Gets whether two roles are not null and not the same as each other. + private static bool NotNullOrEqual(ChatRole? r1, ChatRole? r2) => + r1.HasValue && r2.HasValue && r1.Value != r2.Value; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/MessageList.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/MessageList.cs new file mode 100644 index 0000000000..81b7f405e4 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/MessageList.cs @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.Extensions.AI; + +namespace Microsoft.AspNetCore.Components.AI; + +#pragma warning disable CA1812 // Internal class is apparently never instantiated +internal sealed partial class MessageList : IComponent, IDisposable +#pragma warning restore CA1812 // Internal class is apparently never instantiated +{ + private RenderHandle _renderHandle; + private MessageSubscription _messageSubscription; + private ResponseUpdateSubscription _responseSubscription; + + [CascadingParameter] public MessageListContext MessageListContext { get; set; } = default!; + + public void Attach(RenderHandle renderHandle) + { + this._renderHandle = renderHandle; + } + + public Task SetParametersAsync(ParameterView parameters) + { + var previousContext = this.MessageListContext; + parameters.SetParameterProperties(this); + + if (previousContext != null && this.MessageListContext != previousContext) + { + throw new InvalidOperationException( + $"{nameof(MessageList)} does not support changing the {nameof(this.MessageListContext)} once it has been set."); + } + + // Subscribe to message updates and response updates for streaming + this._messageSubscription = this.MessageListContext.AgentBoundaryContext.SubscribeToMessageChanges(this.ProcessUpdate); + this._responseSubscription = this.MessageListContext.AgentBoundaryContext.SubscribeToResponseUpdates(this.ProcessUpdate); + Log.MessageListAttached(this.MessageListContext.AgentBoundaryContext.Logger); + Log.MessageListSubscribed(this.MessageListContext.AgentBoundaryContext.Logger); + + // Initial render. This component will only render once since the only parameter is a cascading parameter and it's fixed. + this._renderHandle.Render(this.Render); + + return Task.CompletedTask; + } + + private void ProcessUpdate() + { + // Scan all messages for function calls and results to track invocations. + // This allows us to associate results with their calls when results arrive. + foreach (var message in this.MessageListContext.AgentBoundaryContext.CompletedMessages) + { + this.ProcessMessageContents(message); + } + + foreach (var message in this.MessageListContext.AgentBoundaryContext.PendingMessages) + { + this.ProcessMessageContents(message); + } + + this._renderHandle.Render(this.Render); + } + + private void ProcessMessageContents(ChatMessage message) + { + foreach (var content in message.Contents) + { + if (content is FunctionCallContent call) + { + this.MessageListContext.GetOrCreateInvocation(call); + } + else if (content is FunctionResultContent result) + { + this.MessageListContext.AssociateResult(result); + } + } + } + + public void Render(RenderTreeBuilder builder) + { + Log.MessageListRendering( + this.MessageListContext.AgentBoundaryContext.Logger, + this.MessageListContext.AgentBoundaryContext.CompletedMessages.Count, + this.MessageListContext.AgentBoundaryContext.PendingMessages.Count); + + // Track all render keys to detect duplicates + var allRenderKeys = new HashSet(); + + foreach (var message in this.MessageListContext.AgentBoundaryContext.CompletedMessages) + { + var renderKey = GetUniqueRenderKey(message); + + Log.RenderingCompletedMessage( + this.MessageListContext.AgentBoundaryContext.Logger, + message.MessageId, + renderKey, + message.Role.Value, + message.Contents.Count); + + if (renderKey != null && !allRenderKeys.Add(renderKey)) + { + Log.DuplicateRenderKeyDetected( + this.MessageListContext.AgentBoundaryContext.Logger, + renderKey, + "completed"); + } + + // Calling GetTemplate will stop template collection on the first message if it + // was still ongoing. + builder.OpenComponent(0); + builder.SetKey(renderKey); + builder.AddComponentParameter(1, "ChildContent", this.MessageListContext.GetTemplate(message)); + builder.CloseComponent(); + } + + foreach (var message in this.MessageListContext.AgentBoundaryContext.PendingMessages) + { + var renderKey = GetUniqueRenderKey(message); + + Log.RenderingPendingMessage( + this.MessageListContext.AgentBoundaryContext.Logger, + message.MessageId, + renderKey, + message.Role.Value, + message.Contents.Count); + + if (renderKey != null && !allRenderKeys.Add(renderKey)) + { + Log.DuplicateRenderKeyDetected( + this.MessageListContext.AgentBoundaryContext.Logger, + renderKey, + "pending"); + } + + builder.OpenComponent(1); + builder.SetKey(renderKey); + builder.AddComponentParameter(1, "ChildContent", this.MessageListContext.GetTemplate(message)); + builder.CloseComponent(); + } + } + + /// + /// Gets a unique render key for a message. + /// For tool result messages (role=tool), we use MessageId + CallId to ensure uniqueness + /// because the AGUI protocol may reuse MessageId for multiple tool results in the same batch. + /// + private static string? GetUniqueRenderKey(ChatMessage message) + { + // For tool result messages, combine MessageId with CallId to ensure uniqueness + // This works around a bug in the AGUI protocol where multiple tool results + // can share the same MessageId when processed in the same update batch. + if (message.Role == ChatRole.Tool) + { + var resultContent = message.Contents.OfType().FirstOrDefault(); + if (resultContent != null && !string.IsNullOrEmpty(resultContent.CallId)) + { + return $"{message.MessageId}_{resultContent.CallId}"; + } + } + + return message.MessageId; + } + + public void Dispose() + { + Log.MessageListDisposed(this.MessageListContext.AgentBoundaryContext.Logger); + ((IDisposable)this._messageSubscription).Dispose(); + ((IDisposable)this._responseSubscription).Dispose(); + } + + private static partial class Log + { + [LoggerMessage(Level = LogLevel.Debug, Message = "MessageList attached to render handle")] + public static partial void MessageListAttached(ILogger logger); + + [LoggerMessage(Level = LogLevel.Debug, Message = "MessageList subscribed to message changes")] + public static partial void MessageListSubscribed(ILogger logger); + + [LoggerMessage(Level = LogLevel.Debug, Message = "MessageList rendering, completed: {CompletedCount}, pending: {PendingCount}")] + public static partial void MessageListRendering(ILogger logger, int completedCount, int pendingCount); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Rendering completed message: MessageId={MessageId}, RenderKey={RenderKey}, Role={Role}, ContentCount={ContentCount}")] + public static partial void RenderingCompletedMessage(ILogger logger, string? messageId, string? renderKey, string role, int contentCount); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Rendering pending message: MessageId={MessageId}, RenderKey={RenderKey}, Role={Role}, ContentCount={ContentCount}")] + public static partial void RenderingPendingMessage(ILogger logger, string? messageId, string? renderKey, string role, int contentCount); + + [LoggerMessage(Level = LogLevel.Warning, Message = "DUPLICATE RenderKey detected: {RenderKey} in {Location} messages")] + public static partial void DuplicateRenderKeyDetected(ILogger logger, string renderKey, string location); + + [LoggerMessage(Level = LogLevel.Debug, Message = "MessageList disposed")] + public static partial void MessageListDisposed(ILogger logger); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/MessageListContext.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/MessageListContext.cs new file mode 100644 index 0000000000..fe33e1f460 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/MessageListContext.cs @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.AspNetCore.Components.AI; + +public sealed partial class MessageListContext +{ + private bool _collectingTemplates; + + private readonly List _templates = []; + private readonly List _contentTemplates = []; + + // We compute a render fragment to render each message only once and cache it here. + private readonly Dictionary _templateCache = []; + + // Track function invocations by CallId to associate calls with results. + private readonly Dictionary _invocationMap = []; + + public MessageListContext(IAgentBoundaryContext context) + { + this.AgentBoundaryContext = context; + Log.MessageListContextCreated(this.AgentBoundaryContext.Logger); + } + + public IAgentBoundaryContext AgentBoundaryContext { get; } + + public void BeginCollectingTemplates() + { + // This is triggered by the Messages component before rendering its children. + // In this situation we are going to render again the MessageTemplates and + // ContentTemplates and since we can't tell if they have changed we have to + // recompute all the templates again. + this._collectingTemplates = true; + this._templates.Clear(); + this._contentTemplates.Clear(); + this._templateCache.Clear(); + Log.BeganCollectingTemplates(this.AgentBoundaryContext.Logger); + } + + public void RegisterTemplate(MessageTemplateBase template) + { + if (this._collectingTemplates) + { + this._templates.Add(template); + Log.MessageTemplateRegistered(this.AgentBoundaryContext.Logger, this._templates.Count); + } + } + + public void RegisterContentTemplate(ContentTemplateBase template) + { + if (this._collectingTemplates) + { + this._contentTemplates.Add(template); + Log.ContentTemplateRegistered(this.AgentBoundaryContext.Logger, this._contentTemplates.Count); + } + } + + /// + /// Gets or creates an invocation context for the given function call. + /// + /// The function call content. + /// The invocation context for this call. + public InvocationContext GetOrCreateInvocation(FunctionCallContent call) + { + if (!this._invocationMap.TryGetValue(call.CallId, out var context)) + { + context = new InvocationContext(call); + this._invocationMap[call.CallId] = context; + Log.InvocationRegistered(this.AgentBoundaryContext.Logger, call.CallId, call.Name); + } + + return context; + } + + /// + /// Associates a function result with its corresponding call. + /// + /// The function result content. + public void AssociateResult(FunctionResultContent result) + { + if (this._invocationMap.TryGetValue(result.CallId, out var context)) + { + context.SetResult(result); + Log.ResultAssociated(this.AgentBoundaryContext.Logger, result.CallId); + } + } + + /// + /// Gets an invocation context by call ID. + /// + /// The call ID. + /// The invocation context, or null if not found. + public InvocationContext? GetInvocation(string callId) + { + return this._invocationMap.TryGetValue(callId, out var context) ? context : null; + } + + internal RenderFragment GetTemplate(ChatMessage message) + { + // We are about to render the first message. If we were collecting templates, stop now. + this._collectingTemplates = false; + if (this._templateCache.TryGetValue(message, out var cachedTemplate)) + { + return cachedTemplate; + } + + var messageContext = new MessageContext(message, this); + foreach (var template in this._templates) + { + if (template.When(messageContext)) + { + var chosen = template; + messageContext.SetTemplate(chosen); + // We ask the template to create a RenderFragment for the message. + // The template will render a wrapper and use the messageContext to + // render the contents. + // The template might call back through the messageContext to get renderers for + // contents if the message template doesn't override the full rendering or + // if it doesn't define the rendering for a content type. + var renderer = chosen.ChildContent(messageContext); + this._templateCache[message] = renderer; + Log.TemplateResolved(this.AgentBoundaryContext.Logger, message.Role.Value); + return renderer; + } + } + + throw new InvalidOperationException($"No message template found for message of type {message.Role}."); + } + + internal RenderFragment GetContentTemplate(AIContent content) + { + foreach (var template in this._contentTemplates) + { + var contentContext = new ContentContext(content); + if (template.When(contentContext)) + { + return template.ChildContent(contentContext); + } + } + + throw new InvalidOperationException($"No content template found for content of type {content.GetType().Name}."); + } + + private static partial class Log + { + [LoggerMessage(Level = LogLevel.Debug, Message = "MessageListContext created")] + public static partial void MessageListContextCreated(ILogger logger); + + [LoggerMessage(Level = LogLevel.Debug, Message = "MessageListContext began collecting templates")] + public static partial void BeganCollectingTemplates(ILogger logger); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Message template registered, total: {TemplateCount}")] + public static partial void MessageTemplateRegistered(ILogger logger, int templateCount); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Content template registered, total: {TemplateCount}")] + public static partial void ContentTemplateRegistered(ILogger logger, int templateCount); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Template resolved for message with role: {Role}")] + public static partial void TemplateResolved(ILogger logger, string role); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Invocation registered for CallId: {CallId}, Function: {FunctionName}")] + public static partial void InvocationRegistered(ILogger logger, string callId, string functionName); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Result associated with CallId: {CallId}")] + public static partial void ResultAssociated(ILogger logger, string callId); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/MessageTemplateBase.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/MessageTemplateBase.cs new file mode 100644 index 0000000000..fdb0b25510 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/MessageTemplateBase.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.AspNetCore.Components.AI; + +public abstract class MessageTemplateBase : IComponent +{ + [CascadingParameter] internal MessageListContext Context { get; set; } = default!; + + public abstract bool When(MessageContext context); + + [Parameter] public RenderFragment ChildContent { get; set; } = (message) => builder => { }; + + public void Attach(RenderHandle renderHandle) + { + } + + public Task SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + this.Context.RegisterTemplate(this); + return Task.CompletedTask; + } + + internal RenderFragment? GetContentTemplate(AIContent content) + { + return this.Context.GetContentTemplate(content); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Messages.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Messages.cs new file mode 100644 index 0000000000..40911c5af9 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/AI/Messages/Messages.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.AI; + +public partial class Messages : IComponent +{ + private RenderHandle _renderHandle; + private RenderFragment? _renderContents; + + private IAgentBoundaryContext? _context; + private MessageListContext? _messageListContext; + + [CascadingParameter] public IAgentBoundaryContext? AgentContext { get; set; } + + [Parameter] + public RenderFragment MessageTemplates { get; set; } = builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }; + + [Parameter] + public RenderFragment ContentTemplates { get; set; } = builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }; + + public void Attach(RenderHandle renderHandle) + { + this._renderHandle = renderHandle; + this._renderContents = this.RenderContents; + } + + public Task SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + + if (this.AgentContext == null) + { + throw new InvalidOperationException("Messages component must be used within an AgentBoundary."); + } + + if (this._context != null && this.AgentContext != this._context) + { + throw new InvalidOperationException("Messages component cannot change AgentBoundaryContext."); + } + + this._context = this.AgentContext; + + if (this._messageListContext == null) + { + Log.MessagesAttached(this._context.Logger); + this._messageListContext = new MessageListContext(this.AgentContext); + Log.MessagesInitialized(this._context.Logger); + this.Render(); + } + + return Task.CompletedTask; + } + + private void Render() + { + Log.MessagesRendering(this._context?.Logger!); + this._renderHandle.Render(this.RenderCore); + } + + private void RenderCore(RenderTreeBuilder builder) + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "Value", this._messageListContext); + builder.AddComponentParameter(2, "IsFixed", true); + builder.AddComponentParameter(3, "ChildContent", this._renderContents); + builder.CloseComponent(); + } + + private void RenderContents(RenderTreeBuilder builder) + { + Debug.Assert(this._messageListContext != null); + this._messageListContext.BeginCollectingTemplates(); + builder.AddContent(1, this.MessageTemplates); + builder.AddContent(2, this.ContentTemplates); + builder.OpenComponent(3); + builder.CloseComponent(); + } + + private static partial class Log + { + [LoggerMessage(Level = LogLevel.Debug, Message = "Messages component attached to render handle")] + public static partial void MessagesAttached(ILogger logger); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Messages component initialized with MessageListContext")] + public static partial void MessagesInitialized(ILogger logger); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Messages component rendering")] + public static partial void MessagesRendering(ILogger logger); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/App.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/App.razor new file mode 100644 index 0000000000..f7416e6ea3 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/App.razor @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + +@code { + private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false); +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticChat/AgenticChatDemo.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticChat/AgenticChatDemo.razor new file mode 100644 index 0000000000..da7ba1f975 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticChat/AgenticChatDemo.razor @@ -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 + +Agentic Chat + +
+
+
AGUI WebChat
+ +
+ + +
+ +
+ +
+ + +
+
+
+ +@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("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; + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticChat/AgenticChatDemo.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticChat/AgenticChatDemo.razor.css new file mode 100644 index 0000000000..85196f9504 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticChat/AgenticChatDemo.razor.css @@ -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; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/AgenticGenerativeUIDemo.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/AgenticGenerativeUIDemo.razor new file mode 100644 index 0000000000..79d7926463 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/AgenticGenerativeUIDemo.razor @@ -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 + +Agentic Generative UI + +
+
+
Agentic Generative UI
+ +
+ + + +
+ + + + + + +
+ +
+ + +
+
+
+
+ +@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("agentic-generative-ui"); + } + + private Plan? DeserializePlan(ReadOnlyMemory data) + { + try + { + return JsonSerializer.Deserialize(data.Span); + } + catch + { + return null; + } + } + + private Plan? ApplyPlanDelta(Plan? current, ReadOnlyMemory deltaData) + { + if (current is null) + { + return null; + } + + try + { + var operations = JsonSerializer.Deserialize>(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(); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/AgenticGenerativeUIDemo.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/AgenticGenerativeUIDemo.razor.css new file mode 100644 index 0000000000..aab5e59a3a --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/AgenticGenerativeUIDemo.razor.css @@ -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; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/JsonPatchOperation.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/JsonPatchOperation.cs new file mode 100644 index 0000000000..7db6f578b9 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/JsonPatchOperation.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace AGUIDojoClient.Components.Demos.AgenticGenerativeUI; + +/// +/// Represents a JSON Patch operation (RFC 6902). +/// +public sealed class JsonPatchOperation +{ + /// + /// The operation to perform (e.g., "replace", "add", "remove"). + /// + [JsonPropertyName("op")] + public string Op { get; set; } = string.Empty; + + /// + /// The JSON Pointer path to the target location. + /// + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + /// + /// The value for the operation (used with "replace", "add", "test"). + /// + [JsonPropertyName("value")] + public object? Value { get; set; } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/Plan.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/Plan.cs new file mode 100644 index 0000000000..3255e00c16 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/Plan.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace AGUIDojoClient.Components.Demos.AgenticGenerativeUI; + +/// +/// Represents a plan with multiple steps. +/// +public sealed class Plan +{ + /// + /// The list of steps in the plan. + /// + [JsonPropertyName("steps")] + public List Steps { get; set; } = []; + + /// + /// Gets the count of completed steps. + /// + [JsonIgnore] + public int CompletedCount => this.Steps.Count(s => s.IsCompleted); + + /// + /// Gets the total number of steps. + /// + [JsonIgnore] + public int TotalCount => this.Steps.Count; + + /// + /// Gets whether all steps are completed. + /// + [JsonIgnore] + public bool IsComplete => this.Steps.Count > 0 && this.Steps.All(s => s.IsCompleted); +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/PlanPatcher.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/PlanPatcher.cs new file mode 100644 index 0000000000..cdd3798e5d --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/PlanPatcher.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace AGUIDojoClient.Components.Demos.AgenticGenerativeUI; + +/// +/// Applies JSON Patch operations to a Plan object. +/// +public static partial class PlanPatcher +{ + /// + /// Applies a list of JSON Patch operations to the given plan. + /// + /// The plan to modify. + /// The patch operations to apply. + public static void Apply(Plan plan, IEnumerable 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/(?\d+)/(?\w+)$")] + private static partial Regex StepPathRegex(); +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/Step.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/Step.cs new file mode 100644 index 0000000000..179b81c9ee --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/Step.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace AGUIDojoClient.Components.Demos.AgenticGenerativeUI; + +/// +/// Represents a single step in a plan. +/// +public sealed class Step +{ + /// + /// The description of the step. + /// + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// + /// The status of the step (pending or completed). + /// + [JsonPropertyName("status")] + public string Status { get; set; } = "pending"; + + /// + /// Gets whether this step is completed. + /// + [JsonIgnore] + public bool IsCompleted => string.Equals(this.Status, "completed", StringComparison.OrdinalIgnoreCase); +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/TaskProgressTemplate.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/TaskProgressTemplate.razor new file mode 100644 index 0000000000..d335878c8e --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/TaskProgressTemplate.razor @@ -0,0 +1,42 @@ +@* Copyright (c) Microsoft. All rights reserved. *@ + +@if (Plan is not null && Plan.Steps.Count > 0) +{ +
+
+

Task Progress

+ @Plan.CompletedCount/@Plan.TotalCount Complete +
+
+ @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); + +
+ + @if (step.IsCompleted) + { + + } + else if (isCurrentStep) + { + + } + else + { + + } + + @step.Description +
+ } +
+
+} + +@code { + [CascadingParameter] + public Plan? Plan { get; set; } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/TaskProgressTemplate.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/TaskProgressTemplate.razor.css new file mode 100644 index 0000000000..6e03d5b3a0 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/AgenticGenerativeUI/TaskProgressTemplate.razor.css @@ -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; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/BackendToolRendering/BackendToolRenderingDemo.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/BackendToolRendering/BackendToolRenderingDemo.razor new file mode 100644 index 0000000000..65b62fb30d --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/BackendToolRendering/BackendToolRenderingDemo.razor @@ -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 + +Backend Tool Rendering + +
+
+
Backend Tool Rendering
+ +
+ + +
+ + + + + +
+ +
+ + +
+
+
+ +@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("backend-tool-rendering"); + } + + private void ResetConversation() + { + // Reset would need to be implemented - for now just trigger re-render + StateHasChanged(); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/BackendToolRendering/BackendToolRenderingDemo.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/BackendToolRendering/BackendToolRenderingDemo.razor.css new file mode 100644 index 0000000000..aab5e59a3a --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/BackendToolRendering/BackendToolRenderingDemo.razor.css @@ -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; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/BackendToolRendering/WeatherCallTemplate.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/BackendToolRendering/WeatherCallTemplate.cs new file mode 100644 index 0000000000..3d38a28402 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/BackendToolRendering/WeatherCallTemplate.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUIDojoClient.Components.Shared; +using Microsoft.Extensions.AI; + +namespace Microsoft.AspNetCore.Components.AI; + +/// +/// Template for rendering weather function call content with its result. +/// Uses InvocationContext to access both the call arguments and result. +/// +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; + } + + /// + /// Determines if this template should handle the given content. + /// Matches FunctionCallContent for the get_weather function. + /// + 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>(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(0); + innerBuilder.CloseComponent(); + })); + builder.CloseComponent(); + } + }; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/BackendToolRendering/WeatherCard.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/BackendToolRendering/WeatherCard.razor new file mode 100644 index 0000000000..7a1164fd41 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/BackendToolRendering/WeatherCard.razor @@ -0,0 +1,117 @@ +@* Copyright (c) Microsoft. All rights reserved. *@ +@using AGUIDojoClient.Components.Shared +@using Microsoft.AspNetCore.Components.AI + +@if (Weather is null) +{ +
+
+
+

@Location

+

Loading weather...

+
+ +
+ +
+
+ + +
+
+
+ +
+
+

Humidity

+

+
+
+

Wind

+

+
+
+

Feels Like

+

+
+
+
+} +else +{ +
+
+
+

@Location

+

Current Weather

+
+ @Weather.ConditionIcon +
+ +
+
+ @Weather.Temperature° C + / @Weather.TemperatureFahrenheit.ToString("F1")° F +
+
@Weather.Conditions
+
+ +
+
+

Humidity

+

@(Weather.Humidity)%

+
+
+

Wind

+

@Weather.WindSpeed mph

+
+
+

Feels Like

+

@(Weather.FeelsLike)°

+
+
+
+} + +@code { + [CascadingParameter] + public InvocationContext Invocation { get; set; } = default!; + + private string Location => Invocation?.GetArgument("location") ?? "Unknown Location"; + + private WeatherInfo? Weather => Invocation?.HasResult == true + ? Invocation.GetResult() + : 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" + }; + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/BackendToolRendering/WeatherCard.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/BackendToolRendering/WeatherCard.razor.css new file mode 100644 index 0000000000..8d229a17de --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/BackendToolRendering/WeatherCard.razor.css @@ -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; + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/CreatePlanCallTemplate.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/CreatePlanCallTemplate.cs new file mode 100644 index 0000000000..e96181fd7f --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/CreatePlanCallTemplate.cs @@ -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; + +/// +/// Template for rendering create_plan function call content. +/// The PlanCard will subscribe to events to track confirm_plan and update_plan_step calls. +/// +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; + } + + /// + /// Determines if this template should handle the given content. + /// Matches FunctionCallContent for the create_plan function. + /// + 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>(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>(0); + innerBuilder.AddComponentParameter(1, "Value", this.Context); + innerBuilder.AddComponentParameter(2, "IsFixed", true); + innerBuilder.AddComponentParameter(3, "ChildContent", (RenderFragment)(cardBuilder => + { + // Render the PlanCard component + cardBuilder.OpenComponent(0); + cardBuilder.CloseComponent(); + })); + innerBuilder.CloseComponent(); + })); + builder.CloseComponent(); + } + }; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/HumanInTheLoopAgent.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/HumanInTheLoopAgent.cs new file mode 100644 index 0000000000..a3c671f4c9 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/HumanInTheLoopAgent.cs @@ -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; + +/// +/// A delegating agent that prepends instructions for the human-in-the-loop workflow. +/// +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 RunAsync( + IEnumerable 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 RunStreamingAsync( + IEnumerable 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); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/HumanInTheLoopDemo.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/HumanInTheLoopDemo.razor new file mode 100644 index 0000000000..f38f35d26f --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/HumanInTheLoopDemo.razor @@ -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 + +Human in the Loop + +
+
+
Human in the Loop
+ +
+ + +
+ + + + + + +
+ +
+ + +
+
+
+ +@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("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 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); + } + + /// + /// Frontend tool that creates a plan with the given steps. + /// + [Description("Create a plan with multiple steps.")] + private Plan CreatePlan([Description("List of step descriptions to create the plan.")] List steps) + { + currentPlan = new Plan + { + Steps = [.. steps.Select(s => new Step { Description = s, Status = "pending" })] + }; + return currentPlan; + } + + /// + /// Frontend tool that waits for user confirmation via the UI. + /// The PlanCard component will call ProvideResponse when the user confirms/rejects. + /// + [Description("Present the plan to the user for confirmation.")] + private static async Task 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; + } + + /// + /// Frontend tool that updates a step in the plan. + /// + [Description("Update a step in the plan with new description or status.")] + private List 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(); + + 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(); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/HumanInTheLoopDemo.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/HumanInTheLoopDemo.razor.css new file mode 100644 index 0000000000..aab5e59a3a --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/HumanInTheLoopDemo.razor.css @@ -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; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/JsonPatchOperation.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/JsonPatchOperation.cs new file mode 100644 index 0000000000..db154e4fd5 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/JsonPatchOperation.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace AGUIDojoClient.Components.Demos.HumanInTheLoop; + +/// +/// Represents a JSON Patch operation. +/// +public sealed class JsonPatchOperation +{ + /// + /// The operation type (e.g., "replace", "add", "remove"). + /// + [JsonPropertyName("op")] + public string Op { get; set; } = string.Empty; + + /// + /// The JSON Pointer path to the target location. + /// + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + /// + /// The value for the operation. + /// + [JsonPropertyName("value")] + public object? Value { get; set; } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/Plan.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/Plan.cs new file mode 100644 index 0000000000..a5bfbc6591 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/Plan.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace AGUIDojoClient.Components.Demos.HumanInTheLoop; + +/// +/// Represents a plan with multiple steps. +/// +public sealed class Plan +{ + /// + /// The list of steps in the plan. + /// + [JsonPropertyName("steps")] + public List Steps { get; set; } = []; + + /// + /// Gets the count of completed steps. + /// + [JsonIgnore] + public int CompletedCount => this.Steps.Count(s => s.IsCompleted); + + /// + /// Gets the total number of steps. + /// + [JsonIgnore] + public int TotalCount => this.Steps.Count; + + /// + /// Gets whether all steps are completed. + /// + [JsonIgnore] + public bool IsComplete => this.Steps.Count > 0 && this.Steps.All(s => s.IsCompleted); +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/PlanCard.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/PlanCard.razor new file mode 100644 index 0000000000..5111a751a3 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/PlanCard.razor @@ -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 Logger + +@if (IsWaitingForPlan) +{ +
+
+

Loading Plan...

+
+ +
+
+
+ @for (int i = 0; i < 3; i++) + { +
+ + +
+ } +
+
+ +
+
+} +else if (CurrentPlan is not null && AwaitingConfirmation) +{ +
+
+

Plan Confirmation

+
+ @CurrentPlan.CompletedCount / @CurrentPlan.TotalCount completed +
+
+
+ @for (int i = 0; i < CurrentPlan.Steps.Count; i++) + { + var step = CurrentPlan.Steps[i]; + var index = i; +
+ + @step.Description + + @(step.IsCompleted ? "Done" : "Pending") + +
+ } +
+
+ + +
+
+} +else if (CurrentPlan is not null && !AwaitingConfirmation && !WasRejected) +{ +
+
+

@(CurrentPlan.IsComplete ? "Plan Completed" : "Executing Plan")

+ @if (CurrentPlan.IsComplete) + { + ✓ All Done + } + else + { +
+ @CurrentPlan.CompletedCount / @CurrentPlan.TotalCount completed +
+ } +
+
+ @foreach (var step in CurrentPlan.Steps) + { +
+ + @if (step.IsCompleted) + { + + } + + @step.Description + + @(step.IsCompleted ? "Done" : "Pending") + +
+ } +
+
+} +else if (WasRejected) +{ +
+
+

Plan Rejected

+ ✗ Cancelled +
+
+} + +@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 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(); + 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(); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/PlanCard.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/PlanCard.razor.css new file mode 100644 index 0000000000..64f2072c71 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/PlanCard.razor.css @@ -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; + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/PlanConfirmationResult.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/PlanConfirmationResult.cs new file mode 100644 index 0000000000..6af0bf3486 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/PlanConfirmationResult.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace AGUIDojoClient.Components.Demos.HumanInTheLoop; + +/// +/// Result of user's plan confirmation decision. +/// +public class PlanConfirmationResult +{ + public bool Confirmed { get; set; } + public List SelectedStepIndices { get; set; } = []; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/PlanPatcher.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/PlanPatcher.cs new file mode 100644 index 0000000000..074c152801 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/PlanPatcher.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.RegularExpressions; + +namespace AGUIDojoClient.Components.Demos.HumanInTheLoop; + +/// +/// Applies JSON Patch operations to a Plan. +/// Uses hardcoded path parsing for the expected paths: +/// - /steps/{index}/status +/// - /steps/{index}/description +/// +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(); + + /// + /// Applies a JSON Patch operation to the plan. + /// Only supports "replace" operations on /steps/{index}/status and /steps/{index}/description paths. + /// + /// The plan to modify. + /// The patch operation to apply. + /// True if the operation was applied successfully, false otherwise. + 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; + } + } + + /// + /// Applies multiple JSON Patch operations to the plan. + /// + /// The plan to modify. + /// The patch operations to apply. + /// The number of operations successfully applied. + public static int ApplyPatches(Plan plan, IEnumerable operations) + { + int appliedCount = 0; + foreach (var operation in operations) + { + if (ApplyPatch(plan, operation)) + { + appliedCount++; + } + } + return appliedCount; + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/Step.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/Step.cs new file mode 100644 index 0000000000..38348f9859 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/HumanInTheLoop/Step.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace AGUIDojoClient.Components.Demos.HumanInTheLoop; + +/// +/// Represents a single step in a plan. +/// +public sealed class Step +{ + /// + /// The description of the step. + /// + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// + /// The status of the step (pending or completed). + /// + [JsonPropertyName("status")] + public string Status { get; set; } = "pending"; + + /// + /// Gets whether this step is completed. + /// + [JsonIgnore] + public bool IsCompleted => string.Equals(this.Status, "completed", StringComparison.OrdinalIgnoreCase); +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/PredictiveStateUpdates/ConfirmChangesResult.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/PredictiveStateUpdates/ConfirmChangesResult.cs new file mode 100644 index 0000000000..4b94f13a05 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/PredictiveStateUpdates/ConfirmChangesResult.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace AGUIDojoClient.Components.Demos.PredictiveStateUpdates; + +/// +/// Represents the result of the confirm_changes frontend tool. +/// +public sealed class ConfirmChangesResult +{ + /// + /// Gets or sets a value indicating whether the user confirmed the changes. + /// + public bool Confirmed { get; set; } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/PredictiveStateUpdates/DocumentState.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/PredictiveStateUpdates/DocumentState.cs new file mode 100644 index 0000000000..96efae04e6 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/PredictiveStateUpdates/DocumentState.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace AGUIDojoClient.Components.Demos.PredictiveStateUpdates; + +/// +/// Represents the document state for the Predictive State Updates demo. +/// This model mirrors the server-side DocumentState and is updated via streaming state updates. +/// +public sealed class DocumentState +{ + /// + /// Gets or sets the document content in Markdown format. + /// + [JsonPropertyName("document")] + public string Document { get; set; } = string.Empty; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/PredictiveStateUpdates/PredictiveStateUpdatesDemo.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/PredictiveStateUpdates/PredictiveStateUpdatesDemo.razor new file mode 100644 index 0000000000..b3d7f77190 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/PredictiveStateUpdates/PredictiveStateUpdatesDemo.razor @@ -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 Logger + +AI Document Editor + +
+ @* Left panel: Document editor *@ +
+
+

Document Editor

+ @if (isStreaming) + { + + + Writing... + + } +
+
+ @if (string.IsNullOrWhiteSpace(currentDocument)) + { +
+ Write whatever you want here in Markdown format... +
+ } + else + { +
@currentDocument
+ } +
+
+ + @* Right panel: Chat sidebar *@ +
+
+
AI Document Editor
+ +
+ + +
+ + + + + +
+ + + +
+ + +
+
+
+
+ +@* Confirmation modal *@ +@if (awaitingConfirmation) +{ +
+
+

Confirm Changes

+

Do you want to accept the changes?

+
+ + +
+
+
+} + +@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("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 data) + { + try + { + return JsonSerializer.Deserialize(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(); + } + + /// + /// Frontend tool that waits for user confirmation via the UI. + /// + [Description("Ask the user to confirm or reject the document changes.")] + private static async Task 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(); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/PredictiveStateUpdates/PredictiveStateUpdatesDemo.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/PredictiveStateUpdates/PredictiveStateUpdatesDemo.razor.css new file mode 100644 index 0000000000..6585abceb1 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/PredictiveStateUpdates/PredictiveStateUpdatesDemo.razor.css @@ -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; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/SharedState/Ingredient.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/SharedState/Ingredient.cs new file mode 100644 index 0000000000..f86a8c14fd --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/SharedState/Ingredient.cs @@ -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; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/SharedState/Recipe.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/SharedState/Recipe.cs new file mode 100644 index 0000000000..a054d096ea --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/SharedState/Recipe.cs @@ -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 SpecialPreferences { get; set; } = []; + + [JsonPropertyName("ingredients")] + public List Ingredients { get; set; } = []; + + [JsonPropertyName("instructions")] + public List Instructions { get; set; } = []; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/SharedState/RecipeResponse.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/SharedState/RecipeResponse.cs new file mode 100644 index 0000000000..6b95d838aa --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/SharedState/RecipeResponse.cs @@ -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(); +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/SharedState/SharedStateDemo.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/SharedState/SharedStateDemo.razor new file mode 100644 index 0000000000..c7af468c08 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/SharedState/SharedStateDemo.razor @@ -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 + +Shared State + +
+ + +
+
+ +
+
+ 🕒 + +
+
+ 🏆 + +
+
+
+ +
+

Dietary Preferences

+
+ @foreach (var pref in dietaryPreferences) + { + + } +
+
+ +
+
+

Ingredients

+ +
+
+ @for (int i = 0; i < currentRecipe.Ingredients.Count; i++) + { + var index = i; + var ingredient = currentRecipe.Ingredients[index]; +
+ @ingredient.Icon +
+ + +
+ +
+ } +
+
+ +
+
+

Instructions

+ +
+
+ @for (int i = 0; i < currentRecipe.Instructions.Count; i++) + { + var index = i; +
+ @(index + 1) + + +
+ } +
+
+ + +
+ +
+
+ AI Recipe Assistant +
+
+ + + + + +
+
+ +
+
+
+
+
+ +@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("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 data) + { + try + { + var response = JsonSerializer.Deserialize(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; + } + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/SharedState/SharedStateDemo.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/SharedState/SharedStateDemo.razor.css new file mode 100644 index 0000000000..618715390c --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/SharedState/SharedStateDemo.razor.css @@ -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; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/Haiku.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/Haiku.cs new file mode 100644 index 0000000000..df361dea39 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/Haiku.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace AGUIDojoClient.Components.Demos.ToolBasedGenerativeUI; + +/// +/// Represents a haiku with Japanese and English translations, along with display properties. +/// +public class Haiku +{ + /// + /// Gets or sets the three lines of the haiku in Japanese. + /// + public IReadOnlyList Japanese { get; set; } = []; + + /// + /// Gets or sets the three lines of the haiku translated to English. + /// + public IReadOnlyList English { get; set; } = []; + + /// + /// Gets or sets the name of the image associated with the haiku. + /// + public string? ImageName { get; set; } + + /// + /// Gets or sets the CSS gradient for the haiku card background. + /// + public string Gradient { get; set; } = string.Empty; + + /// + /// List of valid image names that can be used with haikus. + /// + 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" + ]; + + /// + /// Creates a default placeholder haiku. + /// + public static Haiku CreatePlaceholder() => new() + { + Japanese = ["仮の句よ", "まっさらながら", "花を呼ぶ"], + English = ["A placeholder verse—", "even in a blank canvas,", "it beckons flowers."], + ImageName = null, + Gradient = string.Empty + }; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/HaikuCallTemplate.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/HaikuCallTemplate.cs new file mode 100644 index 0000000000..6106bbe9c5 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/HaikuCallTemplate.cs @@ -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; + +/// +/// Template for rendering generate_haiku function call content. +/// Renders a HaikuCard component inline in the chat messages. +/// +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; + } + + /// + /// Determines if this template should handle the given content. + /// Matches FunctionCallContent for the generate_haiku function. + /// + 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>(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(0); + innerBuilder.CloseComponent(); + })); + builder.CloseComponent(); + } + }; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/HaikuCard.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/HaikuCard.razor new file mode 100644 index 0000000000..eb8d53e683 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/HaikuCard.razor @@ -0,0 +1,112 @@ +@* Copyright (c) Microsoft. All rights reserved. *@ +@using Microsoft.AspNetCore.Components.AI + +
+ @if (IsLoading) + { +
+
+
+
+
+
+
+ } + else if (CurrentHaiku is not null) + { +
+
+ @for (int i = 0; i < CurrentHaiku.Japanese.Count && i < 3; i++) + { + var index = i; +
+

@CurrentHaiku.Japanese[index]

+ @if (index < CurrentHaiku.English.Count) + { +

@CurrentHaiku.English[index]

+ } +
+ } +
+ + @if (!string.IsNullOrEmpty(CurrentHaiku.ImageName)) + { +
+ @CurrentHaiku.ImageName +
+ } +
+ } +
+ +@code { + /// + /// Gets the haiku to display from the cascaded InvocationContext. + /// + [CascadingParameter] + public InvocationContext? Invocation { get; set; } + + /// + /// Gets or sets the haiku to display directly (used when not rendering from a tool call). + /// + [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(); + } + + // Try to get partial data from arguments while streaming + if (Invocation is not null) + { + var japanese = Invocation.GetArgument("japanese"); + var english = Invocation.GetArgument("english"); + var imageName = Invocation.GetArgument("image_name"); + var gradient = Invocation.GetArgument("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; + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/HaikuCard.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/HaikuCard.razor.css new file mode 100644 index 0000000000..ed506a02e0 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/HaikuCard.razor.css @@ -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; + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/HaikuCarousel.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/HaikuCarousel.razor new file mode 100644 index 0000000000..5e353bc748 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/HaikuCarousel.razor @@ -0,0 +1,104 @@ +@* Copyright (c) Microsoft. All rights reserved. *@ + + + +@if (Haikus.Count > 1) +{ + +} + +@code { + private int currentIndex = 0; + + /// + /// Gets or sets the list of haikus to display in the carousel. + /// + [Parameter] + public IReadOnlyList Haikus { get; set; } = []; + + /// + /// Event callback when the current haiku index changes. + /// + [Parameter] + public EventCallback 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); + } + } + + /// + /// Resets the carousel to show the first (newest) haiku. + /// + public void ResetToFirst() + { + currentIndex = 0; + StateHasChanged(); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/HaikuCarousel.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/HaikuCarousel.razor.css new file mode 100644 index 0000000000..9ae18cfb11 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/HaikuCarousel.razor.css @@ -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; + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/ToolBasedGenerativeUIDemo.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/ToolBasedGenerativeUIDemo.razor new file mode 100644 index 0000000000..66df8b2db8 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/ToolBasedGenerativeUIDemo.razor @@ -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 + +Tool Based Generative UI + +
+
+ +
+ +
+
+
Haiku Generator
+ +
+ + +
+ + + + + + +
+ +
+ + +
+
+
+
+ +@code { + private AIAgent? agent; + private HaikuCarousel? carouselRef; + private List 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("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); + } + + /// + /// Frontend tool handler that creates a new haiku and adds it to the carousel. + /// + [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 { 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(); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/ToolBasedGenerativeUIDemo.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/ToolBasedGenerativeUIDemo.razor.css new file mode 100644 index 0000000000..138c2687df --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Demos/ToolBasedGenerativeUI/ToolBasedGenerativeUIDemo.razor.css @@ -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; + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/DemoSidebar.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/DemoSidebar.razor new file mode 100644 index 0000000000..26d8fd1469 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/DemoSidebar.razor @@ -0,0 +1,44 @@ +@* Copyright (c) Microsoft. All rights reserved. *@ +@using AGUIDojoClient.Components.Shared +@inject DemoService DemoService +@inject NavigationManager Nav + +
+
+

AG-UI Dojo

+

Microsoft Agent Framework (.NET)

+
+
+ @foreach (DemoScenario scenario in DemoService.AllScenarios) + { + + } +
+
+ +@code { + /// + /// Gets or sets the currently selected scenario ID. + /// + [Parameter] + public string? CurrentScenarioId { get; set; } + + private bool IsSelected(string scenarioId) + => CurrentScenarioId?.Equals(scenarioId, StringComparison.OrdinalIgnoreCase) ?? false; + + private void NavigateToScenario(string scenarioId) + => Nav.NavigateTo($"/microsoft-agent-framework/feature/{scenarioId}"); +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/DemoSidebar.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/DemoSidebar.razor.css new file mode 100644 index 0000000000..8491f434ae --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/DemoSidebar.razor.css @@ -0,0 +1,125 @@ +/* Copyright (c) Microsoft. All rights reserved. */ + +.demo-sidebar { + width: 320px; + height: 100vh; + background-color: #f5f5f5; + border-right: 1px solid #e0e0e0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.demo-sidebar-header { + padding: 24px 20px; + border-bottom: 1px solid #e0e0e0; + background-color: #ffffff; +} + +.demo-sidebar-header h1 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: #212121; +} + +.demo-sidebar-header p { + margin: 4px 0 0 0; + font-size: 13px; + color: #666666; +} + +.demo-sidebar-list { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.demo-item { + width: 100%; + display: flex; + gap: 12px; + padding: 12px; + margin-bottom: 8px; + background-color: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 8px; + cursor: pointer; + text-align: left; + transition: all 0.2s ease; +} + +.demo-item:hover { + background-color: #fafafa; + border-color: #0078d4; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.demo-item.selected { + background-color: #e6f2ff; + border-color: #0078d4; + box-shadow: 0 2px 4px rgba(0, 120, 212, 0.2); +} + +.demo-icon { + font-size: 24px; + line-height: 1; + flex-shrink: 0; +} + +.demo-content { + flex: 1; + min-width: 0; +} + +.demo-title { + font-size: 14px; + font-weight: 600; + color: #212121; + margin-bottom: 4px; +} + +.demo-description { + font-size: 12px; + color: #666666; + line-height: 1.4; + margin-bottom: 8px; +} + +.demo-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.demo-tag { + font-size: 10px; + padding: 2px 6px; + background-color: #e8e8e8; + border-radius: 3px; + color: #424242; +} + +.demo-item.selected .demo-tag { + background-color: #cce4ff; + color: #004578; +} + +/* Scrollbar styling */ +.demo-sidebar-list::-webkit-scrollbar { + width: 8px; +} + +.demo-sidebar-list::-webkit-scrollbar-track { + background: #f5f5f5; +} + +.demo-sidebar-list::-webkit-scrollbar-thumb { + background: #c0c0c0; + border-radius: 4px; +} + +.demo-sidebar-list::-webkit-scrollbar-thumb:hover { + background: #a0a0a0; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/DemoViewTabs.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/DemoViewTabs.razor new file mode 100644 index 0000000000..5fa0de2f9d --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/DemoViewTabs.razor @@ -0,0 +1,76 @@ +@* Copyright (c) Microsoft. All rights reserved. *@ + +
+
+ + + +
+
+ @if (CurrentTab == "preview") + { +
+ @PreviewContent +
+ } + else if (CurrentTab == "code") + { +
+ @CodeContent +
+ } + else if (CurrentTab == "docs") + { +
+ @DocsContent +
+ } +
+
+ +@code { + /// + /// Gets or sets the currently active tab. + /// + [Parameter] + public string CurrentTab { get; set; } = "preview"; + + /// + /// Gets or sets the callback for tab changes. + /// + [Parameter] + public EventCallback OnTabChange { get; set; } + + /// + /// Gets or sets the content for the Preview tab. + /// + [Parameter] + public RenderFragment? PreviewContent { get; set; } + + /// + /// Gets or sets the content for the Code tab. + /// + [Parameter] + public RenderFragment? CodeContent { get; set; } + + /// + /// Gets or sets the content for the Docs tab. + /// + [Parameter] + public RenderFragment? DocsContent { get; set; } + + private async Task OnTabChanged(string tab) + { + CurrentTab = tab; + await OnTabChange.InvokeAsync(tab); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/DemoViewTabs.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/DemoViewTabs.razor.css new file mode 100644 index 0000000000..14da425663 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/DemoViewTabs.razor.css @@ -0,0 +1,52 @@ +/* Copyright (c) Microsoft. All rights reserved. */ + +.demo-view-tabs { + display: flex; + flex-direction: column; + height: 100%; + background-color: #ffffff; +} + +.tabs-header { + display: flex; + gap: 0; + border-bottom: 1px solid #e0e0e0; + background-color: #fafafa; + padding: 0 20px; +} + +.tab-button { + padding: 12px 24px; + background: none; + border: none; + border-bottom: 2px solid transparent; + font-size: 14px; + font-weight: 500; + color: #666666; + cursor: pointer; + transition: all 0.2s ease; + position: relative; +} + +.tab-button:hover { + color: #0078d4; + background-color: #f0f0f0; +} + +.tab-button.active { + color: #0078d4; + border-bottom-color: #0078d4; + background-color: #ffffff; +} + +.tab-content { + flex: 1; + overflow: hidden; + position: relative; +} + +.tab-panel { + height: 100%; + width: 100%; + overflow: auto; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/LoadingSpinner.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/LoadingSpinner.razor new file mode 100644 index 0000000000..116455ce45 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/LoadingSpinner.razor @@ -0,0 +1 @@ +
diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/LoadingSpinner.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/LoadingSpinner.razor.css new file mode 100644 index 0000000000..e599d27e86 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/LoadingSpinner.razor.css @@ -0,0 +1,89 @@ +/* Used under CC0 license */ + +.lds-ellipsis { + color: #666; + animation: fade-in 1s; +} + +@keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + + .lds-ellipsis, + .lds-ellipsis div { + box-sizing: border-box; + } + +.lds-ellipsis { + margin: auto; + display: block; + position: relative; + width: 80px; + height: 80px; +} + + .lds-ellipsis div { + position: absolute; + top: 33.33333px; + width: 10px; + height: 10px; + border-radius: 50%; + background: currentColor; + animation-timing-function: cubic-bezier(0, 1, 1, 0); + } + + .lds-ellipsis div:nth-child(1) { + left: 8px; + animation: lds-ellipsis1 0.6s infinite; + } + + .lds-ellipsis div:nth-child(2) { + left: 8px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(3) { + left: 32px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(4) { + left: 56px; + animation: lds-ellipsis3 0.6s infinite; + } + +@keyframes lds-ellipsis1 { + 0% { + transform: scale(0); + } + + 100% { + transform: scale(1); + } +} + +@keyframes lds-ellipsis3 { + 0% { + transform: scale(1); + } + + 100% { + transform: scale(0); + } +} + +@keyframes lds-ellipsis2 { + 0% { + transform: translate(0, 0); + } + + 100% { + transform: translate(24px, 0); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/MainLayout.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/MainLayout.razor new file mode 100644 index 0000000000..18bc2f7aff --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/MainLayout.razor @@ -0,0 +1,10 @@ +@* Copyright (c) Microsoft. All rights reserved. *@ +@inherits LayoutComponentBase + +@Body + +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/MainLayout.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000000..60cec92d5e --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Layout/MainLayout.razor.css @@ -0,0 +1,20 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Pages/Demo.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Pages/Demo.razor new file mode 100644 index 0000000000..2a97e9aa6f --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Pages/Demo.razor @@ -0,0 +1,163 @@ +@* Copyright (c) Microsoft. All rights reserved. *@ +@page "/microsoft-agent-framework/feature/{scenarioId}" +@using AGUIDojoClient.Components.Shared +@using AGUIDojoClient.Components.Demos.AgenticChat +@using AGUIDojoClient.Components.Demos.AgenticGenerativeUI +@using AGUIDojoClient.Components.Demos.BackendToolRendering +@using AGUIDojoClient.Components.Demos.HumanInTheLoop +@using AGUIDojoClient.Components.Demos.SharedState +@using AGUIDojoClient.Components.Demos.PredictiveStateUpdates +@using AGUIDojoClient.Components.Demos.ToolBasedGenerativeUI +@rendermode InteractiveServer +@inject DemoService DemoService +@inject NavigationManager Nav + +@(scenario?.Title ?? "AG-UI Dojo") + +
+ +
+ @if (scenario is not null) + { + + +
+ @RenderScenarioDemo() +
+
+ +
+
+

Code

+

Code view for @scenario.Title will be available soon.

+

This will show the relevant source code for this scenario.

+
+
+
+ +
+
+

Documentation

+

Documentation for @scenario.Title will be available soon.

+

This will include:

+
    +
  • Overview of the scenario
  • +
  • Key concepts
  • +
  • How to use the demo
  • +
  • Links to related documentation
  • +
+
+
+
+
+ } + else + { +
+

Scenario Not Found

+

The scenario "@ScenarioId" could not be found.

+
+ } +
+
+ +@code { + private DemoScenario? scenario; + private string currentTab = "preview"; + + /// + /// Gets or sets the scenario ID from the route. + /// + [Parameter] + public string ScenarioId { get; set; } = string.Empty; + + protected override void OnParametersSet() + { + scenario = DemoService.GetScenario(ScenarioId); + if (scenario is null) + { + // Redirect to first scenario if not found + DemoScenario? firstScenario = DemoService.AllScenarios.FirstOrDefault(); + if (firstScenario is not null) + { + Nav.NavigateTo($"/microsoft-agent-framework/feature/{firstScenario.Id}", replace: true); + } + } + } + + private void OnTabChanged(string tab) + { + currentTab = tab; + } + + private RenderFragment RenderScenarioDemo() => builder => + { + if (scenario is null) + { + return; + } + + // For now, we only have AgenticChatDemo implemented + // Other scenarios will be added later + switch (scenario.Id.ToLowerInvariant()) + { + case "agentic_chat": + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(AgenticChatDemo.ScenarioId), scenario.Id); + builder.CloseComponent(); + break; + + case "backend_tool_rendering": + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(BackendToolRenderingDemo.ScenarioId), scenario.Id); + builder.CloseComponent(); + break; + + case "human_in_the_loop": + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(HumanInTheLoopDemo.ScenarioId), scenario.Id); + builder.CloseComponent(); + break; + + case "tool_based_generative_ui": + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(ToolBasedGenerativeUIDemo.ScenarioId), scenario.Id); + builder.CloseComponent(); + break; + + case "agentic_generative_ui": + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(AgenticGenerativeUIDemo.ScenarioId), scenario.Id); + builder.CloseComponent(); + break; + + case "shared_state": + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(SharedStateDemo.ScenarioId), scenario.Id); + builder.CloseComponent(); + break; + + case "predictive_state_updates": + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(PredictiveStateUpdatesDemo.ScenarioId), scenario.Id); + builder.CloseComponent(); + break; + + default: + // Placeholder for unimplemented scenarios + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "placeholder-content"); + builder.OpenElement(2, "h2"); + builder.AddContent(3, scenario.Title); + builder.CloseElement(); + builder.OpenElement(4, "p"); + builder.AddContent(5, scenario.Description); + builder.CloseElement(); + builder.OpenElement(6, "p"); + builder.AddContent(7, "This demo will be available soon."); + builder.CloseElement(); + builder.CloseElement(); + break; + } + }; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Pages/Demo.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Pages/Demo.razor.css new file mode 100644 index 0000000000..813ed0807f --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Pages/Demo.razor.css @@ -0,0 +1,51 @@ +/* Copyright (c) Microsoft. All rights reserved. */ + +.dojo-container { + display: flex; + height: 100vh; + width: 100%; + overflow: hidden; +} + +.dojo-main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.preview-panel, +.code-panel, +.docs-panel { + height: 100%; + overflow: auto; +} + +.placeholder-content { + padding: 40px; + max-width: 800px; +} + +.placeholder-content h2 { + margin-top: 0; + color: #212121; +} + +.placeholder-content p { + color: #666666; + line-height: 1.6; +} + +.placeholder-content ul { + color: #666666; + line-height: 1.8; +} + +.error-panel { + padding: 40px; + text-align: center; +} + +.error-panel h2 { + color: #d13438; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Pages/Index.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Pages/Index.razor new file mode 100644 index 0000000000..3e54072c44 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Pages/Index.razor @@ -0,0 +1,20 @@ +@* Copyright (c) Microsoft. All rights reserved. *@ +@page "/" +@using AGUIDojoClient.Components.Shared +@inject DemoService DemoService +@inject NavigationManager Nav + +@code { + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { + // Redirect to the first demo scenario + DemoScenario? firstScenario = DemoService.AllScenarios.FirstOrDefault(); + if (firstScenario is not null) + { + Nav.NavigateTo($"/microsoft-agent-framework/feature/{firstScenario.Id}", replace: true); + } + } + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Routes.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Routes.razor new file mode 100644 index 0000000000..faa2a8c2d5 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/Chat.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/Chat.razor new file mode 100644 index 0000000000..249ff9d579 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/Chat.razor @@ -0,0 +1,94 @@ +@* Copyright (c) Microsoft. All rights reserved. *@ +@using System.ComponentModel +@inject IChatClient ChatClient +@inject NavigationManager Nav +@implements IDisposable + +Chat + + + + + +
Ask the assistant a question to start a conversation.
+
+
+
+ + +
+ +@code { + private const string SystemPrompt = @" + You are a helpful assistant. + "; + + private int statefulMessageCount; + private readonly ChatOptions chatOptions = new(); + private readonly List messages = new(); + private CancellationTokenSource? currentResponseCancellation; + private ChatMessage? currentResponseMessage; + private ChatInput? chatInput; + private ChatSuggestions? chatSuggestions; + + protected override void OnInitialized() + { + statefulMessageCount = 0; + messages.Add(new(ChatRole.System, SystemPrompt)); + } + + private async Task AddUserMessageAsync(ChatMessage userMessage) + { + CancelAnyCurrentResponse(); + + // Add the user message to the conversation + messages.Add(userMessage); + chatSuggestions?.Clear(); + await chatInput!.FocusAsync(); + + // Stream and display a new response from the IChatClient + var responseText = new TextContent(""); + currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); + StateHasChanged(); + currentResponseCancellation = new(); + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) + { + messages.AddMessages(update, filter: c => c is not TextContent); + responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; + ChatMessageItem.NotifyChanged(currentResponseMessage); + } + + // Store the final response in the conversation, and begin getting suggestions + messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; + currentResponseMessage = null; + chatSuggestions?.Update(messages); + } + + private void CancelAnyCurrentResponse() + { + // If a response was cancelled while streaming, include it in the conversation so it's not lost + if (currentResponseMessage is not null) + { + messages.Add(currentResponseMessage); + } + + currentResponseCancellation?.Cancel(); + currentResponseMessage = null; + } + + private async Task ResetConversationAsync() + { + CancelAnyCurrentResponse(); + messages.Clear(); + messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; + chatSuggestions?.Clear(); + await chatInput!.FocusAsync(); + } + + public void Dispose() + => currentResponseCancellation?.Cancel(); +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/Chat.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/Chat.razor.css new file mode 100644 index 0000000000..08841605f6 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/Chat.razor.css @@ -0,0 +1,11 @@ +.chat-container { + position: sticky; + bottom: 0; + padding-left: 1.5rem; + padding-right: 1.5rem; + padding-top: 0.75rem; + padding-bottom: 1.5rem; + border-top-width: 1px; + background-color: #F3F4F6; + border-color: #E5E7EB; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatCitation.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatCitation.razor new file mode 100644 index 0000000000..ccb5853cec --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatCitation.razor @@ -0,0 +1,38 @@ +@using System.Web +@if (!string.IsNullOrWhiteSpace(viewerUrl)) +{ + + + + +
+
@File
+
@Quote
+
+
+} + +@code { + [Parameter] + public required string File { get; set; } + + [Parameter] + public int? PageNumber { get; set; } + + [Parameter] + public required string Quote { get; set; } + + private string? viewerUrl; + + protected override void OnParametersSet() + { + viewerUrl = null; + + // If you ingest other types of content besides PDF files, construct a URL to an appropriate viewer here + if (File.EndsWith(".pdf")) + { + var search = Quote?.Trim('.', ',', ' ', '\n', '\r', '\t', '"', '\''); + viewerUrl = $"lib/pdf_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#page={PageNumber}&search={HttpUtility.UrlEncode(search)}&phrase=true"; + } + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatCitation.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatCitation.razor.css new file mode 100644 index 0000000000..763c82aec4 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatCitation.razor.css @@ -0,0 +1,37 @@ +.citation { + display: inline-flex; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + margin-top: 1rem; + margin-right: 1rem; + border-bottom: 2px solid #a770de; + gap: 0.5rem; + border-radius: 0.25rem; + font-size: 0.875rem; + line-height: 1.25rem; + background-color: #ffffff; +} + + .citation[href]:hover { + outline: 1px solid #865cb1; + } + + .citation svg { + width: 1.5rem; + height: 1.5rem; + } + + .citation:active { + background-color: rgba(0,0,0,0.05); + } + +.citation-content { + display: flex; + flex-direction: column; +} + +.citation-file { + font-weight: 600; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatHeader.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatHeader.razor new file mode 100644 index 0000000000..a339038e2a --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatHeader.razor @@ -0,0 +1,17 @@ +
+
+ +
+ +

AGUI WebChat

+
+ +@code { + [Parameter] + public EventCallback OnNewChat { get; set; } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatHeader.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatHeader.razor.css new file mode 100644 index 0000000000..97f0a8d43a --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatHeader.razor.css @@ -0,0 +1,25 @@ +.chat-header-container { + top: 0; + padding: 1.5rem; +} + +.chat-header-controls { + margin-bottom: 1.5rem; +} + +h1 { + overflow: hidden; + text-overflow: ellipsis; +} + +.new-chat-icon { + width: 1.25rem; + height: 1.25rem; + color: rgb(55, 65, 81); +} + +@media (min-width: 768px) { + .chat-header-container { + position: sticky; + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatInput.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatInput.razor new file mode 100644 index 0000000000..b3948ae4c7 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatInput.razor @@ -0,0 +1,51 @@ +@inject IJSRuntime JS + + + + + +@code { + private ElementReference textArea; + private string? messageText; + + [Parameter] + public EventCallback OnSend { get; set; } + + public ValueTask FocusAsync() + => textArea.FocusAsync(); + + private async Task SendMessageAsync() + { + if (messageText is { Length: > 0 } text) + { + messageText = null; + await OnSend.InvokeAsync(new ChatMessage(ChatRole.User, text)); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try + { + var module = await JS.InvokeAsync("import", "./Components/Shared/Chat/ChatInput.razor.js"); + await module.InvokeVoidAsync("init", textArea); + await module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + } + } + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatInput.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatInput.razor.css new file mode 100644 index 0000000000..375dd711d9 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatInput.razor.css @@ -0,0 +1,57 @@ +.input-box { + display: flex; + flex-direction: column; + background: white; + border: 1px solid rgb(229, 231, 235); + border-radius: 8px; + padding: 0.5rem 0.75rem; + margin-top: 0.75rem; +} + + .input-box:focus-within { + outline: 2px solid #4152d5; + } + +textarea { + resize: none; + border: none; + outline: none; + flex-grow: 1; +} + + textarea:placeholder-shown + .tools { + --send-button-color: #aaa; + } + +.tools { + display: flex; + margin-top: 1rem; + align-items: center; +} + +.tool-icon { + width: 1.25rem; + height: 1.25rem; +} + +.send-button { + color: var(--send-button-color); + margin-left: auto; +} + + .send-button:hover { + color: black; + } + +.attach { + background-color: white; + border-style: dashed; + color: #888; + border-color: #888; + padding: 3px 8px; +} + + .attach:hover { + background-color: #f0f0f0; + color: black; + } diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatInput.razor.js b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatInput.razor.js new file mode 100644 index 0000000000..e4bd8af20a --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatInput.razor.js @@ -0,0 +1,43 @@ +export function init(elem) { + elem.focus(); + + // Auto-resize whenever the user types or if the value is set programmatically + elem.addEventListener('input', () => resizeToFit(elem)); + afterPropertyWritten(elem, 'value', () => resizeToFit(elem)); + + // Auto-submit the form on 'enter' keypress + elem.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + elem.dispatchEvent(new CustomEvent('change', { bubbles: true })); + elem.closest('form').dispatchEvent(new CustomEvent('submit', { bubbles: true, cancelable: true })); + } + }); +} + +function resizeToFit(elem) { + const lineHeight = parseFloat(getComputedStyle(elem).lineHeight); + + elem.rows = 1; + const numLines = Math.ceil(elem.scrollHeight / lineHeight); + elem.rows = Math.min(5, Math.max(1, numLines)); +} + +function afterPropertyWritten(target, propName, callback) { + const descriptor = getPropertyDescriptor(target, propName); + Object.defineProperty(target, propName, { + get: function () { + return descriptor.get.apply(this, arguments); + }, + set: function () { + const result = descriptor.set.apply(this, arguments); + callback(); + return result; + } + }); +} + +function getPropertyDescriptor(target, propertyName) { + return Object.getOwnPropertyDescriptor(target, propertyName) + || getPropertyDescriptor(Object.getPrototypeOf(target), propertyName); +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatMessageItem.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatMessageItem.razor new file mode 100644 index 0000000000..6f4e1357c9 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatMessageItem.razor @@ -0,0 +1,73 @@ +@using System.Runtime.CompilerServices +@using System.Text.RegularExpressions +@using System.Linq + +@if (Message.Role == ChatRole.User) +{ +
+ @Message.Text +
+} +else if (Message.Role == ChatRole.Assistant) +{ + foreach (var content in Message.Contents) + { + if (content is TextContent { Text: { Length: > 0 } text }) + { +
+
+
+ + + +
+
+
Assistant
+
+
@((MarkupString)text)
+
+
+ } + else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true) + { + + } + } +} + +@code { + private static readonly ConditionalWeakTable SubscribersLookup = new(); + + [Parameter, EditorRequired] + public required ChatMessage Message { get; set; } + + [Parameter] + public bool InProgress { get; set;} + + protected override void OnInitialized() + { + SubscribersLookup.AddOrUpdate(Message, this); + } + + public static void NotifyChanged(ChatMessage source) + { + if (SubscribersLookup.TryGetValue(source, out var subscriber)) + { + subscriber.StateHasChanged(); + } + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatMessageItem.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatMessageItem.razor.css new file mode 100644 index 0000000000..16443cf657 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatMessageItem.razor.css @@ -0,0 +1,67 @@ +.user-message { + background: rgb(182 215 232); + align-self: flex-end; + min-width: 25%; + max-width: calc(100% - 5rem); + padding: 0.5rem 1.25rem; + border-radius: 0.25rem; + color: #1F2937; + white-space: pre-wrap; +} + +.assistant-message, .assistant-search { + display: grid; + grid-template-rows: min-content; + grid-template-columns: 2rem minmax(0, 1fr); + gap: 0.25rem; +} + +.assistant-message-header { + font-weight: 600; +} + +.assistant-message-text { + grid-column-start: 2; +} + +.assistant-message-icon { + display: flex; + justify-content: center; + align-items: center; + border-radius: 9999px; + width: 1.5rem; + height: 1.5rem; + color: #ffffff; + background: #9b72ce; +} + + .assistant-message-icon svg { + width: 1rem; + height: 1rem; + } + +.assistant-search { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.assistant-search-icon { + display: flex; + justify-content: center; + align-items: center; + width: 1.5rem; + height: 1.5rem; +} + + .assistant-search-icon svg { + width: 1rem; + height: 1rem; + } + +.assistant-search-content { + align-content: center; +} + +.assistant-search-phrase { + font-weight: 600; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatMessageList.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatMessageList.razor new file mode 100644 index 0000000000..dacba51f38 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatMessageList.razor @@ -0,0 +1,41 @@ +@inject IJSRuntime JS + +
+ + @foreach (var message in Messages) + { + + } + + @if (InProgressMessage is not null) + { + + + } + else if (IsEmpty) + { +
@NoMessagesContent
+ } +
+
+ +@code { + [Parameter] + public required IEnumerable Messages { get; set; } + + [Parameter] + public ChatMessage? InProgressMessage { get; set; } + + [Parameter] + public RenderFragment? NoMessagesContent { get; set; } + + private bool IsEmpty => !Messages.Any(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrEmpty(m.Text)); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("import", "./Components/Shared/Chat/ChatMessageList.razor.js"); + } + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatMessageList.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatMessageList.razor.css new file mode 100644 index 0000000000..4be50ddfc3 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatMessageList.razor.css @@ -0,0 +1,22 @@ +.message-list-container { + margin: 2rem 1.5rem; + flex-grow: 1; +} + +.message-list { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.no-messages { + text-align: center; + font-size: 1.25rem; + color: #999; + margin-top: calc(40vh - 18rem); +} + +chat-messages > ::deep div:last-of-type { + /* Adds some vertical buffer to so that suggestions don't overlap the output when they appear */ + margin-bottom: 2rem; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatMessageList.razor.js b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatMessageList.razor.js new file mode 100644 index 0000000000..9755d47c29 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatMessageList.razor.js @@ -0,0 +1,34 @@ +// The following logic provides auto-scroll behavior for the chat messages list. +// If you don't want that behavior, you can simply not load this module. + +window.customElements.define('chat-messages', class ChatMessages extends HTMLElement { + static _isFirstAutoScroll = true; + + connectedCallback() { + this._observer = new MutationObserver(mutations => this._scheduleAutoScroll(mutations)); + this._observer.observe(this, { childList: true, attributes: true }); + } + + disconnectedCallback() { + this._observer.disconnect(); + } + + _scheduleAutoScroll(mutations) { + // Debounce the calls in case multiple DOM updates occur together + cancelAnimationFrame(this._nextAutoScroll); + this._nextAutoScroll = requestAnimationFrame(() => { + const addedUserMessage = mutations.some(m => Array.from(m.addedNodes).some(n => n.parentElement === this && n.classList?.contains('user-message'))); + const elem = this.lastElementChild; + if (ChatMessages._isFirstAutoScroll || addedUserMessage || this._elemIsNearScrollBoundary(elem, 300)) { + elem.scrollIntoView({ behavior: ChatMessages._isFirstAutoScroll ? 'instant' : 'smooth' }); + ChatMessages._isFirstAutoScroll = false; + } + }); + } + + _elemIsNearScrollBoundary(elem, threshold) { + const maxScrollPos = document.body.scrollHeight - window.innerHeight; + const remainingScrollDistance = maxScrollPos - window.scrollY; + return remainingScrollDistance < elem.offsetHeight + threshold; + } +}); diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatSuggestions.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatSuggestions.razor new file mode 100644 index 0000000000..69ca922a8c --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatSuggestions.razor @@ -0,0 +1,78 @@ +@inject IChatClient ChatClient + +@if (suggestions is not null) +{ +
+ @foreach (var suggestion in suggestions) + { + + } +
+} + +@code { + private static string Prompt = @" + Suggest up to 3 follow-up questions that I could ask you to help me complete my task. + Each suggestion must be a complete sentence, maximum 6 words. + Each suggestion must be phrased as something that I (the user) would ask you (the assistant) in response to your previous message, + for example 'How do I do that?' or 'Explain ...'. + If there are no suggestions, reply with an empty list. + "; + + private string[]? suggestions; + private CancellationTokenSource? cancellation; + + [Parameter] + public EventCallback OnSelected { get; set; } + + public void Clear() + { + suggestions = null; + cancellation?.Cancel(); + } + + public void Update(IReadOnlyList messages) + { + // Runs in the background and handles its own cancellation/errors + _ = UpdateSuggestionsAsync(messages); + } + + private async Task UpdateSuggestionsAsync(IReadOnlyList messages) + { + cancellation?.Cancel(); + cancellation = new CancellationTokenSource(); + + try + { + var response = await ChatClient.GetResponseAsync( + [.. ReduceMessages(messages), new(ChatRole.User, Prompt)], + cancellationToken: cancellation.Token); + if (!response.TryGetResult(out suggestions)) + { + suggestions = null; + } + + StateHasChanged(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + await DispatchExceptionAsync(ex); + } + } + + private async Task AddSuggestionAsync(string text) + { + await OnSelected.InvokeAsync(new(ChatRole.User, text)); + } + + private IEnumerable ReduceMessages(IReadOnlyList messages) + { + // Get any leading system messages, plus up to 5 user/assistant messages + // This should be enough context to generate suggestions without unnecessarily resending entire conversations when long + var systemMessages = messages.TakeWhile(m => m.Role == ChatRole.System); + var otherMessages = messages.Where((m, index) => m.Role == ChatRole.User || m.Role == ChatRole.Assistant).Where(m => !string.IsNullOrEmpty(m.Text)).TakeLast(5); + return systemMessages.Concat(otherMessages); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatSuggestions.razor.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatSuggestions.razor.css new file mode 100644 index 0000000000..dcc7ee8bd8 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/Chat/ChatSuggestions.razor.css @@ -0,0 +1,9 @@ +.suggestions { + text-align: right; + white-space: nowrap; + gap: 0.5rem; + justify-content: flex-end; + flex-wrap: wrap; + display: flex; + margin-bottom: 0.75rem; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/DemoScenario.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/DemoScenario.cs new file mode 100644 index 0000000000..7fcf24b51f --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/DemoScenario.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace AGUIDojoClient.Components.Shared; + +/// +/// Represents a demo scenario in the AG-UI dojo. +/// +/// Unique identifier for the scenario (e.g., "agentic_chat"). +/// Display title of the scenario. +/// Brief description of what the scenario demonstrates. +/// Collection of tags categorizing the scenario's features. +/// Server endpoint path for the AG-UI connection. +/// Optional emoji icon for the scenario. +public record DemoScenario( + string Id, + string Title, + string Description, + IReadOnlyList Tags, + string Endpoint, + string Icon = "💬" +); diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/DemoService.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/DemoService.cs new file mode 100644 index 0000000000..a5c51ec02b --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/DemoService.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.AGUI; +using Microsoft.Extensions.AI; + +namespace AGUIDojoClient.Components.Shared; + +/// +/// Service for managing demo scenarios and creating chat clients for each scenario. +/// +public sealed class DemoService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly List _scenarios; + + /// + /// Initializes a new instance of the class. + /// + /// Factory for creating HTTP clients. + public DemoService(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + _scenarios = InitializeScenarios(); + } + + /// + /// Gets all available demo scenarios. + /// + public IEnumerable AllScenarios => this._scenarios; + + /// + /// Gets a specific scenario by its ID. + /// + /// The scenario identifier. + /// The scenario if found; otherwise, null. + public DemoScenario? GetScenario(string id) + => this._scenarios.FirstOrDefault(s => s.Id.Equals(id, StringComparison.OrdinalIgnoreCase)); + + /// + /// Creates a chat client for the specified endpoint. + /// + /// The AG-UI endpoint path. + /// A configured chat client. + public IChatClient CreateChatClient(string endpoint) + { + HttpClient httpClient = this._httpClientFactory.CreateClient("aguiserver"); + return new AGUIChatClient(httpClient, endpoint); + } + + private static List InitializeScenarios() + { + return + [ + new DemoScenario( + Id: "agentic_chat", + Title: "Agentic Chat", + Description: "Chat with your Copilot and call frontend tools", + Tags: ["Chat", "Tools", "Streaming"], + Endpoint: "/agentic_chat", + Icon: "💬" + ), + new DemoScenario( + Id: "backend_tool_rendering", + Title: "Backend Tool Rendering", + Description: "Render and stream your backend tools to the frontend", + Tags: ["Agent State", "Collaborating"], + Endpoint: "/backend_tool_rendering", + Icon: "🛠️" + ), + new DemoScenario( + Id: "human_in_the_loop", + Title: "Human in the Loop", + Description: "Plan a task together and direct the Copilot to take the right steps", + Tags: ["HITL", "Interactivity"], + Endpoint: "/human_in_the_loop", + Icon: "👤" + ), + new DemoScenario( + Id: "agentic_generative_ui", + Title: "Agentic Generative UI", + Description: "Assign a long running task to your Copilot and see how it performs!", + Tags: ["Generative UI (agent)", "Long running task"], + Endpoint: "/agentic_generative_ui", + Icon: "🤖" + ), + new DemoScenario( + Id: "tool_based_generative_ui", + Title: "Tool Based Generative UI", + Description: "Haiku generator that uses tool based generative UI", + Tags: ["Generative UI (action)", "Tools"], + Endpoint: "/tool_based_generative_ui", + Icon: "🎨" + ), + new DemoScenario( + Id: "shared_state", + Title: "Shared State between Agent and UI", + Description: "A recipe Copilot which reads and updates collaboratively", + Tags: ["Agent State", "Collaborating"], + Endpoint: "/shared_state", + Icon: "🍳" + ), + new DemoScenario( + Id: "predictive_state_updates", + Title: "Predictive State Updates", + Description: "Use collaboration to edit a document in real time with your Copilot", + Tags: ["State", "Streaming", "Tools"], + Endpoint: "/predictive_state_updates", + Icon: "📝" + ) + ]; + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/WeatherInfo.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/WeatherInfo.cs new file mode 100644 index 0000000000..b540a5de7f --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/Shared/WeatherInfo.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace AGUIDojoClient.Components.Shared; + +/// +/// Weather information model for backend tool rendering. +/// +public sealed class WeatherInfo +{ + [JsonPropertyName("temperature")] + public int Temperature { get; init; } + + [JsonPropertyName("conditions")] + public string Conditions { get; init; } = string.Empty; + + [JsonPropertyName("humidity")] + public int Humidity { get; init; } + + [JsonPropertyName("wind_speed")] + public int WindSpeed { get; init; } + + [JsonPropertyName("feelsLike")] + public int FeelsLike { get; init; } + + /// + /// Gets the temperature in Fahrenheit. + /// + public double TemperatureFahrenheit => (Temperature * 9.0 / 5.0) + 32; + + /// + /// Gets an emoji icon based on weather conditions. + /// + public string ConditionIcon => GetConditionIcon(Conditions); + + private static string GetConditionIcon(string conditions) + { + if (string.Equals(conditions, "sunny", StringComparison.OrdinalIgnoreCase) || + string.Equals(conditions, "clear", StringComparison.OrdinalIgnoreCase)) + return "☀️"; + if (string.Equals(conditions, "cloudy", StringComparison.OrdinalIgnoreCase) || + string.Equals(conditions, "overcast", StringComparison.OrdinalIgnoreCase)) + return "☁️"; + if (string.Equals(conditions, "rainy", StringComparison.OrdinalIgnoreCase) || + string.Equals(conditions, "rain", StringComparison.OrdinalIgnoreCase)) + return "🌧️"; + if (string.Equals(conditions, "stormy", StringComparison.OrdinalIgnoreCase) || + string.Equals(conditions, "thunderstorm", StringComparison.OrdinalIgnoreCase)) + return "⛈️"; + if (string.Equals(conditions, "snowy", StringComparison.OrdinalIgnoreCase) || + string.Equals(conditions, "snow", StringComparison.OrdinalIgnoreCase)) + return "❄️"; + if (string.Equals(conditions, "foggy", StringComparison.OrdinalIgnoreCase) || + string.Equals(conditions, "fog", StringComparison.OrdinalIgnoreCase)) + return "🌫️"; + if (string.Equals(conditions, "windy", StringComparison.OrdinalIgnoreCase)) + return "💨"; + if (string.Equals(conditions, "partly cloudy", StringComparison.OrdinalIgnoreCase)) + return "⛅"; + return "🌡️"; + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/_Imports.razor b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/_Imports.razor new file mode 100644 index 0000000000..0fbeece8d2 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Components/_Imports.razor @@ -0,0 +1,14 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using AGUIDojoClient +@using AGUIDojoClient.Components +@using AGUIDojoClient.Components.Layout +@using AGUIDojoClient.Components.Shared +@using Microsoft.AspNetCore.Components.AI +@using Microsoft.Extensions.AI diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Program.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Program.cs new file mode 100644 index 0000000000..090a041b86 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Program.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using AGUIDojoClient.Components; +using AGUIDojoClient.Components.Shared; +using AGUIDojoClient.Services; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AGUI; +using Microsoft.Extensions.AI; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +string serverUrl = builder.Configuration["SERVER_URL"] ?? "http://localhost:5018"; + +builder.Services.AddHttpClient("aguiserver", httpClient => httpClient.BaseAddress = new Uri(serverUrl)); + +// Register the DemoService for managing demo scenarios +builder.Services.AddSingleton(); + +// Register the BackgroundColorService for frontend tool support +builder.Services.AddSingleton(); + +// Register IChatClient for components like ChatSuggestions +builder.Services.AddChatClient(sp => +{ + HttpClient httpClient = sp.GetRequiredService().CreateClient("aguiserver"); + return new AGUIChatClient(httpClient, "agentic_chat"); +}); + +// Register a keyed AIAgent using AGUIChatClient with frontend tools +builder.Services.AddKeyedSingleton("agentic-chat", (sp, key) => +{ + HttpClient httpClient = sp.GetRequiredService().CreateClient("aguiserver"); + AGUIChatClient aguiChatClient = new AGUIChatClient(httpClient, "agentic_chat"); + + // Get the background color service for the frontend tool + IBackgroundColorService backgroundService = sp.GetRequiredService(); + + // Define the frontend tool that changes the background color + [Description("Change the background color of the chat interface.")] + string ChangeBackground([Description("The color to change the background to. Can be a color name (e.g., 'blue'), hex value (e.g., '#FF5733'), or RGB value (e.g., 'rgb(255,87,51)').")] string color) + { + backgroundService.SetColor(color); + return $"Background color changed to {color}"; + } + + // Create frontend tools array + AITool[] frontendTools = [AIFunctionFactory.Create(ChangeBackground)]; + + return aguiChatClient.CreateAIAgent( + name: "AgenticChatAssistant", + description: "A helpful assistant for the agentic chat demo", + tools: frontendTools); +}); + +// Register a keyed AIAgent for backend tool rendering (weather demo) +builder.Services.AddKeyedSingleton("backend-tool-rendering", (sp, key) => +{ + HttpClient httpClient = sp.GetRequiredService().CreateClient("aguiserver"); + AGUIChatClient aguiChatClient = new AGUIChatClient(httpClient, "backend_tool_rendering"); + + return aguiChatClient.CreateAIAgent( + name: "BackendToolRenderingAssistant", + description: "A helpful assistant that can look up weather information"); +}); + +// Register a keyed AIAgent for human-in-the-loop demo +builder.Services.AddKeyedSingleton("human-in-the-loop", (sp, key) => +{ + HttpClient httpClient = sp.GetRequiredService().CreateClient("aguiserver"); + AGUIChatClient aguiChatClient = new AGUIChatClient(httpClient, "human_in_the_loop"); + + // Create the base agent and wrap it with a delegating agent that adds instructions + AIAgent baseAgent = aguiChatClient.CreateAIAgent( + name: "HumanInTheLoopAssistant", + description: "A helpful assistant that creates plans and asks for user confirmation"); + + return new AGUIDojoClient.Components.Demos.HumanInTheLoop.HumanInTheLoopAgent(baseAgent); +}); + +// Register a keyed AIAgent for tool-based generative UI demo (haiku generator) +builder.Services.AddKeyedSingleton("tool-based-generative-ui", (sp, key) => +{ + HttpClient httpClient = sp.GetRequiredService().CreateClient("aguiserver"); + AGUIChatClient aguiChatClient = new AGUIChatClient(httpClient, "tool_based_generative_ui"); + + return aguiChatClient.CreateAIAgent( + name: "ToolBasedGenerativeUIAssistant", + description: "A helpful assistant that generates haikus with Japanese text and images"); +}); + +// Register a keyed AIAgent for agentic generative UI demo (long-running task execution) +builder.Services.AddKeyedSingleton("agentic-generative-ui", (sp, key) => +{ + HttpClient httpClient = sp.GetRequiredService().CreateClient("aguiserver"); + AGUIChatClient aguiChatClient = new AGUIChatClient(httpClient, "agentic_generative_ui"); + + return aguiChatClient.CreateAIAgent( + name: "AgenticGenerativeUIAssistant", + description: "A helpful assistant that executes long-running tasks and shows progress"); +}); + +// Register a keyed AIAgent for shared state demo (recipe copilot) +builder.Services.AddKeyedSingleton("shared-state", (sp, key) => +{ + HttpClient httpClient = sp.GetRequiredService().CreateClient("aguiserver"); + AGUIChatClient aguiChatClient = new AGUIChatClient(httpClient, "shared_state"); + + return aguiChatClient.CreateAIAgent( + name: "SharedStateAssistant", + description: "A recipe copilot that reads and updates collaboratively"); +}); + +// Register a keyed AIAgent for predictive state updates demo (document editor) +builder.Services.AddKeyedSingleton("predictive-state-updates", (sp, key) => +{ + HttpClient httpClient = sp.GetRequiredService().CreateClient("aguiserver"); + AGUIChatClient aguiChatClient = new AGUIChatClient(httpClient, "predictive_state_updates"); + + return aguiChatClient.CreateAIAgent( + name: "PredictiveStateUpdatesAssistant", + description: "An AI document editor that streams content updates"); +}); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseAntiforgery(); +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Properties/launchSettings.json b/dotnet/samples/AGUIDojo/AGUIDojoClient/Properties/launchSettings.json new file mode 100644 index 0000000000..2f1ff831b6 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "SERVER_URL": "http://localhost:5018" + } + } + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/Services/BackgroundColorService.cs b/dotnet/samples/AGUIDojo/AGUIDojoClient/Services/BackgroundColorService.cs new file mode 100644 index 0000000000..558284cd3b --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/Services/BackgroundColorService.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace AGUIDojoClient.Services; + +/// +/// Event arguments for background color changes. +/// +public class BackgroundColorChangedEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The new background color. + public BackgroundColorChangedEventArgs(string color) + { + Color = color; + } + + /// + /// Gets the new background color. + /// + public string Color { get; } +} + +/// +/// Service for managing the background color of the chat interface. +/// Used by frontend tools to change the background color. +/// +public interface IBackgroundColorService +{ + /// + /// Gets the current background color. + /// + string? CurrentColor { get; } + + /// + /// Event raised when the background color changes. + /// + event EventHandler? ColorChanged; + + /// + /// Sets the background color and notifies subscribers. + /// + /// The color value (e.g., "blue", "#FF5733", "rgb(255,87,51)"). + void SetColor(string color); +} + +/// +/// Default implementation of . +/// +public class BackgroundColorService : IBackgroundColorService +{ + /// + public string? CurrentColor { get; private set; } + + /// + public event EventHandler? ColorChanged; + + /// + public void SetColor(string color) + { + CurrentColor = color; + ColorChanged?.Invoke(this, new BackgroundColorChangedEventArgs(color)); + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/appsettings.Development.json b/dotnet/samples/AGUIDojo/AGUIDojoClient/appsettings.Development.json new file mode 100644 index 0000000000..a0ab4d66c1 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "DetailedErrors": true, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.Components.AI": "Debug" + } + } +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/appsettings.json b/dotnet/samples/AGUIDojo/AGUIDojoClient/appsettings.json new file mode 100644 index 0000000000..10f68b8c8b --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/app.css b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/app.css new file mode 100644 index 0000000000..8cb3035a13 --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/app.css @@ -0,0 +1,150 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + width: 100%; + overflow: hidden; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + color: #212121; + background-color: #ffffff; +} + +h1 { + font-size: 2.25rem; + line-height: 2.5rem; + font-weight: 600; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.btn-default { + display: flex; + padding: 0.25rem 0.75rem; + gap: 0.25rem; + align-items: center; + border-radius: 0.25rem; + border: 1px solid #9CA3AF; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 600; + background-color: #D1D5DB; +} + + .btn-default:hover { + background-color: #E5E7EB; + } + +.btn-subtle { + display: flex; + padding: 0.25rem 0.75rem; + gap: 0.25rem; + align-items: center; + border-radius: 0.25rem; + border: 1px solid #D1D5DB; + font-size: 0.875rem; + line-height: 1.25rem; +} + + .btn-subtle:hover { + border-color: #93C5FD; + background-color: #DBEAFE; + } + +.page-width { + max-width: 1024px; + margin: auto; +} + +/* AgentInput component styles */ +.agent-input { + display: flex; + align-items: flex-end; + gap: 12px; + width: 100%; + padding: 16px; + background-color: #fff; +} + +.agent-input textarea { + flex: 1; + padding: 12px 16px; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; + resize: none; + min-height: 44px; + max-height: 200px; + line-height: 1.5; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.agent-input textarea:focus { + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.agent-input textarea::placeholder { + color: #9ca3af; +} + +.agent-input .send-button { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border: none; + border-radius: 8px; + background-color: #2563eb; + color: white; + cursor: pointer; + transition: background-color 0.2s, opacity 0.2s; + flex-shrink: 0; +} + +.agent-input .send-button:hover:not(:disabled) { + background-color: #1d4ed8; +} + +.agent-input .send-button:disabled { + background-color: #d1d5db; + color: #9ca3af; + cursor: not-allowed; +} + +.agent-input .send-button svg { + display: block; +} diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/favicon.png b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/favicon.png new file mode 100644 index 0000000000..8422b59695 Binary files /dev/null and b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/favicon.png differ diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Bonsai_Tree_Potted_Japanese_Art_Green_Foliage.jpeg b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Bonsai_Tree_Potted_Japanese_Art_Green_Foliage.jpeg new file mode 100644 index 0000000000..f55e74033a Binary files /dev/null and b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Bonsai_Tree_Potted_Japanese_Art_Green_Foliage.jpeg differ diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Cherry_Blossoms_Sakura_Night_View_City_Lights_Japan.jpg b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Cherry_Blossoms_Sakura_Night_View_City_Lights_Japan.jpg new file mode 100644 index 0000000000..6d6af4c776 Binary files /dev/null and b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Cherry_Blossoms_Sakura_Night_View_City_Lights_Japan.jpg differ diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Ginkaku-ji_Silver_Pavilion_Kyoto_Japanese_Garden_Pond_Reflection.jpg b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Ginkaku-ji_Silver_Pavilion_Kyoto_Japanese_Garden_Pond_Reflection.jpg new file mode 100644 index 0000000000..40adf209fb Binary files /dev/null and b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Ginkaku-ji_Silver_Pavilion_Kyoto_Japanese_Garden_Pond_Reflection.jpg differ diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Itsukushima_Shrine_Miyajima_Floating_Torii_Gate_Sunset_Long_Exposure.jpg b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Itsukushima_Shrine_Miyajima_Floating_Torii_Gate_Sunset_Long_Exposure.jpg new file mode 100644 index 0000000000..7258d812d1 Binary files /dev/null and b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Itsukushima_Shrine_Miyajima_Floating_Torii_Gate_Sunset_Long_Exposure.jpg differ diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Mount_Fuji_Lake_Reflection_Cherry_Blossoms_Sakura_Spring.jpg b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Mount_Fuji_Lake_Reflection_Cherry_Blossoms_Sakura_Spring.jpg new file mode 100644 index 0000000000..40716614fb Binary files /dev/null and b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Mount_Fuji_Lake_Reflection_Cherry_Blossoms_Sakura_Spring.jpg differ diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Osaka_Castle_Turret_Stone_Wall_Pine_Trees_Daytime.jpg b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Osaka_Castle_Turret_Stone_Wall_Pine_Trees_Daytime.jpg new file mode 100644 index 0000000000..6442112a27 Binary files /dev/null and b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Osaka_Castle_Turret_Stone_Wall_Pine_Trees_Daytime.jpg differ diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Senso-ji_Temple_Asakusa_Cherry_Blossoms_Kimono_Umbrella.jpg b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Senso-ji_Temple_Asakusa_Cherry_Blossoms_Kimono_Umbrella.jpg new file mode 100644 index 0000000000..3cb13e4048 Binary files /dev/null and b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Senso-ji_Temple_Asakusa_Cherry_Blossoms_Kimono_Umbrella.jpg differ diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Shirakawa-go_Gassho-zukuri_Thatched_Roof_Village_Aerial_View.jpg b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Shirakawa-go_Gassho-zukuri_Thatched_Roof_Village_Aerial_View.jpg new file mode 100644 index 0000000000..a1f03f8241 Binary files /dev/null and b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Shirakawa-go_Gassho-zukuri_Thatched_Roof_Village_Aerial_View.jpg differ diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Takachiho_Gorge_Waterfall_River_Lush_Greenery_Japan.jpg b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Takachiho_Gorge_Waterfall_River_Lush_Greenery_Japan.jpg new file mode 100644 index 0000000000..c8e85d3f20 Binary files /dev/null and b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Takachiho_Gorge_Waterfall_River_Lush_Greenery_Japan.jpg differ diff --git a/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Tokyo_Skyline_Night_Tokyo_Tower_Mount_Fuji_View.jpg b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Tokyo_Skyline_Night_Tokyo_Tower_Mount_Fuji_View.jpg new file mode 100644 index 0000000000..de88f84af6 Binary files /dev/null and b/dotnet/samples/AGUIDojo/AGUIDojoClient/wwwroot/images/Tokyo_Skyline_Night_Tokyo_Tower_Mount_Fuji_View.jpg differ diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj b/dotnet/samples/AGUIDojo/AGUIDojoServer/AGUIDojoServer.csproj similarity index 100% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj rename to dotnet/samples/AGUIDojo/AGUIDojoServer/AGUIDojoServer.csproj diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs b/dotnet/samples/AGUIDojo/AGUIDojoServer/AGUIDojoServerSerializerContext.cs similarity index 100% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs rename to dotnet/samples/AGUIDojo/AGUIDojoServer/AGUIDojoServerSerializerContext.cs diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/AgenticPlanningTools.cs b/dotnet/samples/AGUIDojo/AGUIDojoServer/AgenticUI/AgenticPlanningTools.cs similarity index 89% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/AgenticPlanningTools.cs rename to dotnet/samples/AGUIDojo/AGUIDojoServer/AgenticUI/AgenticPlanningTools.cs index 98fe96b442..3ce79cc53b 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/AgenticPlanningTools.cs +++ b/dotnet/samples/AGUIDojo/AGUIDojoServer/AgenticUI/AgenticPlanningTools.cs @@ -1,4 +1,12 @@ +<<<<<<< HEAD +<<<<<<< HEAD // Copyright (c) Microsoft. All rights reserved. +======= +// Copyright (c) Microsoft. All rights reserved. +>>>>>>> beabff3e (Update the dojo samples) +======= +// Copyright (c) Microsoft. All rights reserved. +>>>>>>> 660dee85 (cleanups) using System.ComponentModel; diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/AgenticUIAgent.cs b/dotnet/samples/AGUIDojo/AGUIDojoServer/AgenticUI/AgenticUIAgent.cs similarity index 100% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/AgenticUIAgent.cs rename to dotnet/samples/AGUIDojo/AGUIDojoServer/AgenticUI/AgenticUIAgent.cs diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/JsonPatchOperation.cs b/dotnet/samples/AGUIDojo/AGUIDojoServer/AgenticUI/JsonPatchOperation.cs similarity index 68% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/JsonPatchOperation.cs rename to dotnet/samples/AGUIDojo/AGUIDojoServer/AgenticUI/JsonPatchOperation.cs index 1cd8f5dcd2..0f2a36c6e1 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/JsonPatchOperation.cs +++ b/dotnet/samples/AGUIDojo/AGUIDojoServer/AgenticUI/JsonPatchOperation.cs @@ -1,4 +1,12 @@ +<<<<<<< HEAD +<<<<<<< HEAD // Copyright (c) Microsoft. All rights reserved. +======= +// Copyright (c) Microsoft. All rights reserved. +>>>>>>> beabff3e (Update the dojo samples) +======= +// Copyright (c) Microsoft. All rights reserved. +>>>>>>> 660dee85 (cleanups) using System.Text.Json.Serialization; diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/Plan.cs b/dotnet/samples/AGUIDojo/AGUIDojoServer/AgenticUI/Plan.cs similarity index 52% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/Plan.cs rename to dotnet/samples/AGUIDojo/AGUIDojoServer/AgenticUI/Plan.cs index a8ffcc6c37..9a5eed9ef6 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/Plan.cs +++ b/dotnet/samples/AGUIDojo/AGUIDojoServer/AgenticUI/Plan.cs @@ -1,4 +1,12 @@ +<<<<<<< HEAD +<<<<<<< HEAD // Copyright (c) Microsoft. All rights reserved. +======= +// Copyright (c) Microsoft. All rights reserved. +>>>>>>> beabff3e (Update the dojo samples) +======= +// Copyright (c) Microsoft. All rights reserved. +>>>>>>> 660dee85 (cleanups) using System.Text.Json.Serialization; diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/Step.cs b/dotnet/samples/AGUIDojo/AGUIDojoServer/AgenticUI/Step.cs similarity index 62% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/Step.cs rename to dotnet/samples/AGUIDojo/AGUIDojoServer/AgenticUI/Step.cs index 26bc9860a5..6a638a506b 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/Step.cs +++ b/dotnet/samples/AGUIDojo/AGUIDojoServer/AgenticUI/Step.cs @@ -1,4 +1,12 @@ +<<<<<<< HEAD +<<<<<<< HEAD // Copyright (c) Microsoft. All rights reserved. +======= +// Copyright (c) Microsoft. All rights reserved. +>>>>>>> beabff3e (Update the dojo samples) +======= +// Copyright (c) Microsoft. All rights reserved. +>>>>>>> 660dee85 (cleanups) using System.Text.Json.Serialization; diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/StepStatus.cs b/dotnet/samples/AGUIDojo/AGUIDojoServer/AgenticUI/StepStatus.cs similarity index 53% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/StepStatus.cs rename to dotnet/samples/AGUIDojo/AGUIDojoServer/AgenticUI/StepStatus.cs index f88d71bef0..a5e57e2632 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/StepStatus.cs +++ b/dotnet/samples/AGUIDojo/AGUIDojoServer/AgenticUI/StepStatus.cs @@ -1,4 +1,12 @@ +<<<<<<< HEAD +<<<<<<< HEAD // Copyright (c) Microsoft. All rights reserved. +======= +// Copyright (c) Microsoft. All rights reserved. +>>>>>>> beabff3e (Update the dojo samples) +======= +// Copyright (c) Microsoft. All rights reserved. +>>>>>>> 660dee85 (cleanups) using System.Text.Json.Serialization; diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/BackendToolRendering/WeatherInfo.cs b/dotnet/samples/AGUIDojo/AGUIDojoServer/BackendToolRendering/WeatherInfo.cs similarity index 100% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/BackendToolRendering/WeatherInfo.cs rename to dotnet/samples/AGUIDojo/AGUIDojoServer/BackendToolRendering/WeatherInfo.cs diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs b/dotnet/samples/AGUIDojo/AGUIDojoServer/ChatClientAgentFactory.cs similarity index 79% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs rename to dotnet/samples/AGUIDojo/AGUIDojoServer/ChatClientAgentFactory.cs index 58f5ad4ae9..376bc0109b 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs +++ b/dotnet/samples/AGUIDojo/AGUIDojoServer/ChatClientAgentFactory.cs @@ -10,6 +10,7 @@ using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using OpenAI; using ChatClient = OpenAI.Chat.ChatClient; namespace AGUIDojoServer; @@ -77,6 +78,7 @@ internal static class ChatClientAgentFactory { Name = "AgenticUIAgent", Description = "An agent that generates agentic user interfaces using Azure OpenAI", +<<<<<<< HEAD ChatOptions = new ChatOptions { Instructions = """ @@ -93,6 +95,24 @@ internal static class ChatClientAgentFactory Only one plan can be active at a time, so do not call the `create_plan` tool again until all the steps in current plan are completed. """, +======= + Instructions = """ + When planning use tools only, without any other messages. + IMPORTANT: + - Use the `create_plan` tool to set the initial state of the steps + - Use the `update_plan_step` tool to update the status of each step + - Do NOT repeat the plan or summarise it in a message + - Do NOT confirm the creation or updates in a message + - Do NOT ask the user for additional information or next steps + - Do NOT leave a plan hanging, always complete the plan via `update_plan_step` if one is ongoing. + - Continue calling update_plan_step until all steps are marked as completed. + + Only one plan can be active at a time, so do not call the `create_plan` tool + again until all the steps in current plan are completed. + """, + ChatOptions = new ChatOptions + { +>>>>>>> beabff3e (Update the dojo samples) Tools = [ AIFunctionFactory.Create( AgenticPlanningTools.CreatePlan, @@ -131,6 +151,7 @@ internal static class ChatClientAgentFactory { Name = "PredictiveStateUpdatesAgent", Description = "An agent that demonstrates predictive state updates using Azure OpenAI", +<<<<<<< HEAD ChatOptions = new ChatOptions { Instructions = """ @@ -148,6 +169,25 @@ internal static class ChatClientAgentFactory After the user confirms the changes, provide a brief summary of what you wrote. """, +======= + Instructions = """ + You are a document editor assistant. When asked to write or edit content: + + IMPORTANT: + - Use the `write_document` tool with the full document text in Markdown format + - Format the document extensively so it's easy to read + - You can use all kinds of markdown (headings, lists, bold, etc.) + - However, do NOT use italic or strike-through formatting + - You MUST write the full document, even when changing only a few words + - When making edits to the document, try to make them minimal - do not change every word + - Keep stories SHORT! + - After you are done writing the document you MUST call a confirm_changes tool after you call write_document + + After the user confirms the changes, provide a brief summary of what you wrote. + """, + ChatOptions = new ChatOptions + { +>>>>>>> beabff3e (Update the dojo samples) Tools = [ AIFunctionFactory.Create( WriteDocument, diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/PredictiveStateUpdates/DocumentState.cs b/dotnet/samples/AGUIDojo/AGUIDojoServer/PredictiveStateUpdates/DocumentState.cs similarity index 56% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/PredictiveStateUpdates/DocumentState.cs rename to dotnet/samples/AGUIDojo/AGUIDojoServer/PredictiveStateUpdates/DocumentState.cs index ad053fe4a2..32e63d239d 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/PredictiveStateUpdates/DocumentState.cs +++ b/dotnet/samples/AGUIDojo/AGUIDojoServer/PredictiveStateUpdates/DocumentState.cs @@ -1,4 +1,12 @@ +<<<<<<< HEAD +<<<<<<< HEAD // Copyright (c) Microsoft. All rights reserved. +======= +// Copyright (c) Microsoft. All rights reserved. +>>>>>>> beabff3e (Update the dojo samples) +======= +// Copyright (c) Microsoft. All rights reserved. +>>>>>>> 660dee85 (cleanups) using System.Text.Json.Serialization; diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/PredictiveStateUpdates/PredictiveStateUpdatesAgent.cs b/dotnet/samples/AGUIDojo/AGUIDojoServer/PredictiveStateUpdates/PredictiveStateUpdatesAgent.cs similarity index 100% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/PredictiveStateUpdates/PredictiveStateUpdatesAgent.cs rename to dotnet/samples/AGUIDojo/AGUIDojoServer/PredictiveStateUpdates/PredictiveStateUpdatesAgent.cs diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/Program.cs b/dotnet/samples/AGUIDojo/AGUIDojoServer/Program.cs similarity index 100% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/Program.cs rename to dotnet/samples/AGUIDojo/AGUIDojoServer/Program.cs diff --git a/dotnet/samples/AGUIDojo/AGUIDojoServer/Properties/launchSettings.json b/dotnet/samples/AGUIDojo/AGUIDojoServer/Properties/launchSettings.json new file mode 100644 index 0000000000..2d776d977a --- /dev/null +++ b/dotnet/samples/AGUIDojo/AGUIDojoServer/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "profiles": { + "AGUIDojoServer": { + "commandName": "Project", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "AZURE_OPENAI_ENDPOINT": "https://ag-ui-agent-framework.openai.azure.com/", + "AZURE_OPENAI_DEPLOYMENT_NAME": "gpt-4.1-mini" + }, + "applicationUrl": "http://localhost:5018" + } + } +} \ No newline at end of file diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/Ingredient.cs b/dotnet/samples/AGUIDojo/AGUIDojoServer/SharedState/Ingredient.cs similarity index 100% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/Ingredient.cs rename to dotnet/samples/AGUIDojo/AGUIDojoServer/SharedState/Ingredient.cs diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/Recipe.cs b/dotnet/samples/AGUIDojo/AGUIDojoServer/SharedState/Recipe.cs similarity index 100% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/Recipe.cs rename to dotnet/samples/AGUIDojo/AGUIDojoServer/SharedState/Recipe.cs diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/RecipeResponse.cs b/dotnet/samples/AGUIDojo/AGUIDojoServer/SharedState/RecipeResponse.cs similarity index 100% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/RecipeResponse.cs rename to dotnet/samples/AGUIDojo/AGUIDojoServer/SharedState/RecipeResponse.cs diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs b/dotnet/samples/AGUIDojo/AGUIDojoServer/SharedState/SharedStateAgent.cs similarity index 100% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs rename to dotnet/samples/AGUIDojo/AGUIDojoServer/SharedState/SharedStateAgent.cs diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/appsettings.Development.json b/dotnet/samples/AGUIDojo/AGUIDojoServer/appsettings.Development.json similarity index 100% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/appsettings.Development.json rename to dotnet/samples/AGUIDojo/AGUIDojoServer/appsettings.Development.json diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/appsettings.json b/dotnet/samples/AGUIDojo/AGUIDojoServer/appsettings.json similarity index 100% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/appsettings.json rename to dotnet/samples/AGUIDojo/AGUIDojoServer/appsettings.json diff --git a/dotnet/samples/AGUIDojo/Design.md b/dotnet/samples/AGUIDojo/Design.md new file mode 100644 index 0000000000..fdc79e016f --- /dev/null +++ b/dotnet/samples/AGUIDojo/Design.md @@ -0,0 +1,390 @@ +# Design for Blazor Agent UI +This document covers the design of the Microsoft.AspNetCore.Components.AI library for apps that interact with AI agents. Below we enumerate the general design patterns that we aim to provide in this area + +## Scenario 1 - Include an agent on the UI + +```html + + +``` + +AgentBoundary is responsible for "connecting the UI to the agent. + +It keeps track of the messages back and forth between the agent and the UI. + +It provides an AgentBoundaryContext cascading value that other children component can use to: +* Listen for new messages + * The context exposes an `IAsyncEnumerable` Messages stream of messages (including past messages and future messages, this is a "cold" enumerable) + * The context exposes an `IAsyncEnumerable>` stream of updates per turn. + * The outer IEnumerable represents all the turns between the user and the LLM. + * The inner IEnumerable represents the updates for the current run. + * This is a "hot" enumerable, meaning that you don't get past updates, only new ones. + * The context exposes a T State property that represents any state associated with the agent. + * The context exposes an IAsyncEnumerable stream that represents state updates if there is state attached to the Agent. + * The context exposes a `SendAsync` method to trigger an interaction with the agent, which might include new messages as well as state. + * The context exposes a CancellationToken tied to the lifetime of the AgentBoundary + +AgentBoundary doesn't have any UI, it's all about managing the interaction with the agent. This gives users the freedom to implement other parts of their agentic UI as they see fit. + +That said, we provide components to render a default input box as well as components to render the list of messages. + +Here is a more complete example: +```html + + + + +``` + +The snippet above will render the default Chat like UI that we are all used to seeing and using. + +## Messages Component + +The messages component is responsible for the bulk of functionality in rendering messages from the agent. It's responsible for subscribing to the AgentBoundaryContext update stream and making sure that messages are rendered as they arrive. It holds the list of messages in memory and re-renders as new messages arrive. + +Our default Messages component supports customizing the way messages are rendered via templates. Users can define custom content templates to control how messages are rendered. These templates reflect the different content types that are available on Microsoft.Extensions.AI. There is a default template class for each content type and a general fallback template that can be used to provide a fallback for any unknown content. + +Some templates can have additional parameters. For example, the DataContentTemplate can specify a mime type to filter on and only render data messages that match that mime type. Similarly, the ToolCallContentTemplate can specify a tool name to filter on. + +The content templates provide a context object that includes two properties: +* Message: That gives access to the completed message (if available) +* Updates: That gives access to the list of updates being rendered for the current incoming message. + +The Messages component provides a default rendering template for TextContent. Any other content type will just not be rendered unless the developer provides a custom template for it. + +Here is a list of all the available content templates: + +* CodeInterpreterToolCallTemplate +* CodeInterpreterToolResultTemplate +* DataTemplate +* ErrorTemplate +* FunctionCallTemplate +* FunctionResultTemplate +* FunctionApprovalRequestTemplate +* FunctionApprovalResponseTemplate +* HostedFileTemplate +* HostedVectorStoreTemplate +* ImageGenerationToolCallTemplate +* ImageGenerationToolResultTemplate +* McpServerToolCallTemplate +* McpServerToolResultTemplate +* TextTemplate +* TextReasoningTemplate +* UriTemplate +* UsageTemplate +* UserInputRequestTemplate +* UserInputResponseTemplate + +MessageModifiers can be used to modify the way messages are rendered. For example, a MessageModifier can be used to customize the "frame" around a message, so that ContentTemplates can focus on rendering the content itself. At the same time, MessageModifiers can be used to add additional UI elements around the message or provide overrides for specific AI contents. + +All ContentTemplates and MessageModifiers extend a base class ContentTemplateBase or MessageModifierBase respectively. They expose a "When" parameter that receives the message and returns a boolean indicating whether the template/modifier should be applied to that message/update. + +The MessageModifiers that exist by default are: +* UserMessageModifier +* SystemMessageModifier +* AssistantMessageModifier +* ToolMessageModifier + +Internally the Messages component follows this algorithm to render messages: +1. For any given message: It first tries to match against an existing WellKnown MessageModifier (User, System, Assistant, Tool). If it matches a well known MessageModifier, it uses that one to render the message. + - We do this to avoid having to iterate over all MessageModifiers for the most common cases. + - Well known MessageModifiers take more precedence than custom message modifiers. +2. If no well known MessageModifier matches, it iterates over all custom MessageModifiers in the order they were defined and uses the first one that matches. +3. If no MessageModifier matches, it uses the DefaultMessageModifier to render the message. + +4. We merge the list of default content renderers with the content renderers in the modifier (if applicable) and then we proceed to find the right content template for each content in the message: + - For any given content in the message, we first try to match against well known ContentTemplates in the merged set. + - We do this to avoid having to iterate over all ContentTemplates for the most common cases. + - Well known ContentTemplates take more precedence than custom content templates. + - If no well known ContentTemplate matches, we iterate over all custom ContentTemplates in the merged set in the order they were defined and use the first one that matches. + - If no matching ContentTemplate is found, we don't render any content. + - If no content is rendered for a message, we skip rendering the message entirely. + +By default we render each message inside a `div` with CSS classes that reflect the role of the message (user, system, assistant, tool) as well as whether or not the message is complete. This can be customized by providing custom MessageModifiers. The modifier context provides access to a RenderContents method that can be used to render the contents of the message using the content templates. + +With this in mind, let's walk through some very common scenarios: + +### Customizing messages based on role + +```html + + + + +
+ @context.RenderContents() +
+
+ +
+ @context.RenderContents() +
+
+
+
+ +
+``` + +### Rendering tool calls + +```html + + + + + + + + + + +``` + +### Rendering tool results + +```html + + + + +
+ Weather in @context.GetArgument("location"): @context.GetArgument("weather_description"), Temperature: @context.GetArgument("temperature") °C +
+
+
+
+ +
+``` + +### Rendering approvals + +```html + + + + +
+

Function: @context.FunctionName

+

Details: @context.Details

+ + +
+
+
+
+ +
+``` + +### Rendering images + +```html + + + + + Generated Image + + + + + +``` + +## Types + +```csharp +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.AI; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Components.AI; + +// Main boundary component that connects UI to agent +public class AgentBoundary : IComponent +{ + [Parameter] public IAgent Agent { get; set; } + [Parameter] public RenderFragment ChildContent { get; set; } + + public void Attach(RenderHandle renderHandle) => throw new NotImplementedException(); + public Task SetParametersAsync(ParameterView parameters) => throw new NotImplementedException(); +} + +// Non-generic context provided by AgentBoundary +public abstract class AgentBoundaryContext +{ + IAsyncEnumerable Messages { get; } + IAsyncEnumerable> UpdateStreams { get; } + object GetState(); + IAsyncEnumerable GetStateUpdates(); + Task SendAsync(object state, params Span newMessages); + CancellationToken CancellationToken { get; } + + // Methods for approval scenarios + Task Approve(FunctionApprovalRequest request); + Task Reject(FunctionApprovalRequest request); +} + +// Generic context with typed state +public abstract class AgentBoundaryContext : AgentBoundaryContext +{ + public abstract TState State { get; } + public abstract IAsyncEnumerable StateUpdates { get; } + public abstract Task SendAsync(TState state, params Span newMessages); +} + +// Main messages display component +public class Messages : IComponent +{ + [Parameter] public RenderFragment MessageModifiers { get; set; } + [Parameter] public RenderFragment ContentTemplates { get; set; } + [CascadingParameter] public IAgentBoundaryContext AgentContext { get; set; } + + public void Attach(RenderHandle renderHandle) => throw new NotImplementedException(); + public Task SetParametersAsync(ParameterView parameters) => throw new NotImplementedException(); +} + +// Input component for user interactions +public class AgentInput : IComponent +{ + [CascadingParameter] public IAgentBoundaryContext AgentContext { get; set; } + + public void Attach(RenderHandle renderHandle) => throw new NotImplementedException(); + public Task SetParametersAsync(ParameterView parameters) => throw new NotImplementedException(); +} + +// Base classes for templates and modifiers +public abstract class MessageModifierBase : IComponent +{ + [Parameter] public Func When { get; set; } + [Parameter] public RenderFragment ChildContent { get; set; } + + public void Attach(RenderHandle renderHandle) => throw new NotImplementedException(); + public Task SetParametersAsync(ParameterView parameters) => throw new NotImplementedException(); +} + +public abstract class ContentTemplateBase : IComponent +{ + [Parameter] public Func When { get; set; } + [Parameter] public RenderFragment ChildContent { get; set; } + + public void Attach(RenderHandle renderHandle) => throw new NotImplementedException(); + public Task SetParametersAsync(ParameterView parameters) => throw new NotImplementedException(); +} + +// Context types for templates +public class MessageModifierContext +{ + public ChatMessage Message { get; set; } + public IEnumerable Updates { get; set; } + public RenderFragment RenderContents() => default; +} + +public class ContentTemplateContext +{ + public ChatMessage Message { get; set; } + public IReadOnlyList Updates { get; set; } + public IAgentBoundaryContext AgentContext { get; set; } + public T GetArgument(string name) => default; +} + +// Specialized context for approval scenarios +public class FunctionApprovalContext : ContentTemplateContext +{ + public string FunctionName { get; set; } + public string Details { get; set; } + public FunctionApprovalRequest Request { get; set; } +} + +// Message modifiers for different roles +public class UserMessageModifier : MessageModifierBase { } +public class SystemMessageModifier : MessageModifierBase { } +public class AssistantMessageModifier : MessageModifierBase { } +public class ToolMessageModifier : MessageModifierBase { } +public class DefaultMessageModifier : MessageModifierBase { } + +// Content templates for different content types +public class TextTemplate : ContentTemplateBase { } + +public class TextReasoningTemplate : ContentTemplateBase { } + +public class DataTemplate : ContentTemplateBase +{ + [Parameter] public string MimeType { get; set; } +} + +public class ImageTemplate : ContentTemplateBase { } + +public class UriTemplate : ContentTemplateBase { } + +public class UsageTemplate : ContentTemplateBase { } + +public class ErrorTemplate : ContentTemplateBase { } + +public class HostedFileTemplate : ContentTemplateBase { } + +public class HostedVectorStoreTemplate : ContentTemplateBase { } + +// Function/Tool related templates +public class FunctionCallTemplate : ContentTemplateBase { } + +public class FunctionResultTemplate : ContentTemplateBase { } + +public class ToolCallContentTemplate : ContentTemplateBase +{ + [Parameter] public string ToolName { get; set; } +} + +public class ToolResultContentTemplate : ContentTemplateBase +{ + [Parameter] public string ToolName { get; set; } +} + +// Approval templates +public class FunctionApprovalRequestTemplate : ContentTemplateBase +{ + [Parameter] public new RenderFragment ChildContent { get; set; } +} + +public class FunctionApprovalResponseTemplate : ContentTemplateBase { } + +// User input templates +public class UserInputRequestTemplate : ContentTemplateBase { } + +public class UserInputResponseTemplate : ContentTemplateBase { } + +// Specialized tool templates +public class CodeInterpreterToolCallTemplate : ContentTemplateBase { } + +public class CodeInterpreterToolResultTemplate : ContentTemplateBase { } + +public class ImageGenerationToolCallTemplate : ContentTemplateBase { } + +public class ImageGenerationToolResultTemplate : ContentTemplateBase +{ + public string ImageUrl => default; +} + +public class McpServerToolCallTemplate : ContentTemplateBase { } + +public class McpServerToolResultTemplate : ContentTemplateBase { } + +// Supporting types +public class FunctionApprovalRequest +{ + public string FunctionName { get; set; } + public IDictionary Arguments { get; set; } +} + +// Agent interface (assumed from Microsoft.Extensions.AI) +public interface IAgent +{ + // Agent interface members would be defined in Microsoft.Extensions.AI +} +``` \ No newline at end of file diff --git a/dotnet/samples/AGUIDojo/Plan.md b/dotnet/samples/AGUIDojo/Plan.md new file mode 100644 index 0000000000..132481e7b2 --- /dev/null +++ b/dotnet/samples/AGUIDojo/Plan.md @@ -0,0 +1,497 @@ +# AG-UI Blazor Dojo - Implementation Plan + +## Executive Summary + +This document outlines the plan to transform the AGUIDojoClient from a simple chat application into a comprehensive AG-UI Dojo - a simplified Blazor version of [dojo.ag-ui.com/microsoft-agent-framework-dotnet](https://dojo.ag-ui.com/microsoft-agent-framework-dotnet). The dojo will showcase various AG-UI scenarios and features through an interactive demonstration platform. + +## Current State Analysis + +### Existing Components + +**AGUIDojoServer** provides seven AG-UI endpoints: +1. `/agentic_chat` - Basic chat with frontend tools +2. `/backend_tool_rendering` - Backend tool demonstrations +3. `/human_in_the_loop` - Human approval workflows +4. `/tool_based_generative_ui` - Tool-based generative UI +5. `/agentic_generative_ui` - Agentic generative UI +6. `/shared_state` - Shared state management +7. `/predictive_state_updates` - Predictive state updates + +**AGUIDojoClient** currently has: +- Basic chat interface components (Chat.razor, ChatInput.razor, ChatMessageList.razor, etc.) +- Layout components (MainLayout.razor) +- AG-UI client integration via `AGUIChatClient` + +## Target Architecture + +### Dojo Website Structure (from analysis) + +The dojo website has the following structure: +- **Left Sidebar**: Lists all available demo scenarios with descriptions and tags +- **Main Content Area**: Contains three tabs + - **Preview Tab**: Interactive demo of the selected scenario + - **Code Tab**: Shows relevant code files for the scenario + - **Docs Tab**: Documentation for the scenario +- **No Integration Selector**: We'll skip the integration dropdown since we're focused on Microsoft Agent Framework (.NET) + +### Demo Scenarios + +Based on the dojo website analysis, we need to support these scenarios: + +1. **Agentic Chat** + - Description: Chat with your Copilot and call frontend tools + - Tags: Chat, Tools, Streaming + - Endpoint: `/agentic_chat` + +2. **Backend Tool Rendering** + - Description: Render and stream your backend tools to the frontend + - Tags: Agent State, Collaborating + - Endpoint: `/backend_tool_rendering` + +3. **Human in the Loop** + - Description: Plan a task together and direct the Copilot to take the right steps + - Tags: HITL, Interactivity + - Endpoint: `/human_in_the_loop` + +4. **Agentic Generative UI** + - Description: Assign a long running task to your Copilot and see how it performs! + - Tags: Generative UI (agent), Long running task + - Endpoint: `/agentic_generative_ui` + +5. **Tool Based Generative UI** + - Description: Haiku generator that uses tool based generative UI + - Tags: Generative UI (action), Tools + - Endpoint: `/tool_based_generative_ui` + +6. **Shared State between Agent and UI** + - Description: A recipe Copilot which reads and updates collaboratively + - Tags: Agent State, Collaborating + - Endpoint: `/shared_state` + +7. **Predictive State Updates** + - Description: Use collaboration to edit a document in real time with your Copilot + - Tags: State, Streaming, Tools + - Endpoint: `/predictive_state_updates` + +## Implementation Plan + +### Phase 1: Project Structure & Routing + +#### 1.1 Create New Component Structure + +``` +Components/ + Layout/ + MainLayout.razor (Update existing) + DemoSidebar.razor (New - left sidebar with demo list) + DemoViewTabs.razor (New - Preview/Code/Docs tabs) + + Pages/ + Index.razor (New - redirects to first demo) + Demo.razor (New - main demo container with routing) + + Demos/ + AgenticChat/ + AgenticChatDemo.razor (Chat UI for this scenario) + BackendToolRendering/ + BackendToolRenderingDemo.razor + HumanInLoop/ + HumanInLoopDemo.razor + AgenticGenerativeUI/ + AgenticGenerativeUIDemo.razor + ToolBasedGenerativeUI/ + ToolBasedGenerativeUIDemo.razor + SharedState/ + SharedStateDemo.razor + PredictiveStateUpdates/ + PredictiveStateUpdatesDemo.razor + + Shared/ + DemoScenario.cs (Model for demo metadata) + DemoService.cs (Service to manage demo scenarios) + Chat/ (Move existing chat components here) + ChatHeader.razor + ChatInput.razor + ChatMessageList.razor + ChatMessageItem.razor + ChatSuggestions.razor + ChatCitation.razor +``` + +#### 1.2 Update Routing + +- Use Blazor's `@page` directive with route parameters: `@page "/microsoft-agent-framework/feature/{scenarioId}"` +- Default route `/` redirects to first scenario +- Each scenario accessible via `/microsoft-agent-framework/feature/agentic_chat`, `/microsoft-agent-framework/feature/backend_tool_rendering`, etc. +- Scenario IDs use underscores to match server endpoints and dojo convention + +### Phase 2: Core Components + +#### 2.1 DemoSidebar Component + +**Purpose**: Display list of demo scenarios in left sidebar + +**Features**: +- List all available scenarios +- Show title, description, and tags for each scenario +- Highlight currently selected scenario +- Navigate to scenario on click +- Responsive design (collapsible on mobile) + +**Data Structure**: +```csharp +public record DemoScenario( + string Id, // e.g., "agentic_chat", "backend_tool_rendering" + string Title, + string Description, + string[] Tags, + string Endpoint, // e.g., "/agentic_chat" + string Icon = "💬" +); +``` + +#### 2.2 DemoViewTabs Component + +**Purpose**: Tab control for Preview/Code/Docs views + +**Features**: +- Three tabs: Preview, Code, Docs +- Smooth tab transitions +- State persistence (current tab stays active when switching demos) +- Blazor Interactive Server rendering for tab switching + +**Implementation Approach**: +- Use Blazor's `@rendermode InteractiveServer` for tab interactivity +- Tab state managed via component parameter +- CSS for tab styling similar to dojo website + +#### 2.3 Demo.razor (Main Container) + +**Purpose**: Container page that hosts the demo experience + +**Features**: +- Route parameter for scenario selection: `@page "/microsoft-agent-framework/feature/{scenarioId}"` +- Loads appropriate demo component based on scenarioId +- Manages tab state +- Dynamically loads code/docs content + +**Structure**: +```razor +@page "/microsoft-agent-framework/feature/{scenarioId}" +@rendermode InteractiveServer + +
+ +
+ + + @RenderScenarioDemo() + + + + + + + + +
+
+``` + +### Phase 3: Demo Scenario Components + +#### 3.1 Base Pattern for Demo Components + +Each demo component should: +- Accept configuration (endpoint URL, server URL) +- Use existing chat components where applicable +- Implement scenario-specific features +- Handle AG-UI client connection +- Display scenario-specific UI elements + +#### 3.2 Reusable Chat Components + +Move existing chat components to `Components/Shared/Chat/`: +- `ChatHeader.razor` - Header with new chat button +- `ChatInput.razor` - Message input with send button +- `ChatMessageList.razor` - Message display container +- `ChatMessageItem.razor` - Individual message rendering +- `ChatSuggestions.razor` - Suggested prompts +- `ChatCitation.razor` - Citation display + +#### 3.3 Scenario-Specific Components + +**AgenticChat**: Can largely reuse existing Chat.razor +- Configure to use `/agentic_chat` endpoint +- Add example prompts specific to frontend tools + +**BackendToolRendering**: Similar to AgenticChat +- Configure to use `/backend_tool_rendering` endpoint +- Display backend tool execution results +- Show tool rendering in UI + +**HumanInLoop**: Add approval UI +- Configure to use `/human_in_the_loop` endpoint +- Display approval requests +- Add approve/reject buttons +- Show approval workflow state + +**AgenticGenerativeUI**: +- Configure to use `/agentic_generative_ui` endpoint +- Display agent's plan/steps +- Show generative UI components +- Render dynamic UI elements + +**ToolBasedGenerativeUI**: +- Configure to use `/tool_based_generative_ui` endpoint +- Haiku-specific UI +- Display generated UI components + +**SharedState**: +- Configure to use `/shared_state` endpoint +- Display recipe state +- Show collaborative state updates +- Render ingredient lists, recipe steps + +**PredictiveStateUpdates**: +- Configure to use `/predictive_state_updates` endpoint +- Document editing UI +- Real-time state synchronization display +- Show predictive updates as they occur + +### Phase 4: Code & Documentation Views + +#### 4.1 Code Viewer Component + +**Purpose**: Display code files for each scenario + +**Features**: +- File selector (tabs or dropdown for multiple files) +- Syntax highlighting (use a Blazor-compatible syntax highlighter) +- Copy to clipboard button +- Line numbers +- Responsive design + +**Code Files per Scenario**: +- Server-side C# files from AGUIDojoServer +- Client-side Razor files +- Shared models/types + +**Implementation Options**: +- Embed code as string resources +- Load from embedded resources +- Use `@@preservewhitespace` directive +- Consider using BlazorMonaco or similar for code display + +#### 4.2 Docs Viewer Component + +**Purpose**: Display documentation for each scenario + +**Features**: +- Markdown rendering (use Markdig or similar) +- Links to external documentation +- Responsive design +- Code examples within docs + +**Documentation Content**: +- Overview of the scenario +- Key concepts +- How to use the demo +- Links to related documentation +- Common patterns and best practices + +### Phase 5: Styling & UX + +#### 5.1 Layout & Design + +**Sidebar**: +- Fixed width (e.g., 300px) on desktop +- Collapsible on mobile +- Scrollable list of demos +- Visual hierarchy with tags + +**Main Content Area**: +- Fluid width +- Tabs at the top +- Full-height content area +- Proper spacing and padding + +**Colors & Theme**: +- Professional color scheme +- Clear visual hierarchy +- Accessible contrast ratios +- Consistent with Microsoft design language + +#### 5.2 Responsive Design + +- Desktop (>1024px): Full sidebar + content +- Tablet (768px-1024px): Collapsible sidebar +- Mobile (<768px): Hamburger menu for sidebar, full-width content + +#### 5.3 Loading States + +- Skeleton loaders for chat messages +- Loading indicators for responses +- Smooth transitions +- Error states with retry options + +### Phase 6: Configuration & State Management + +#### 6.1 Demo Configuration Service + +```csharp +public class DemoService +{ + private readonly IConfiguration _configuration; + private readonly string _serverUrl; + + public DemoService(IConfiguration configuration) + { + _configuration = configuration; + _serverUrl = configuration["SERVER_URL"] ?? "http://localhost:5100"; + } + + public IEnumerable GetAllScenarios() { ... } + + public DemoScenario? GetScenario(string id) { ... } + + public IChatClient CreateChatClient(string endpoint) + { + var httpClient = new HttpClient { BaseAddress = new Uri(_serverUrl) }; + return new AGUIChatClient(httpClient, endpoint); + } +} +``` + +#### 6.2 State Management + +- Use Blazor's built-in state management +- Component-level state for each demo +- Service-level state for app-wide settings +- No global state persistence needed (demos are ephemeral) + +### Phase 7: Testing & Refinement + +#### 7.1 Manual Testing Checklist + +- [ ] All scenarios load and display correctly +- [ ] Routing works for all scenarios +- [ ] Tab switching works smoothly +- [ ] Chat functionality works in each scenario +- [ ] Code viewer displays code correctly +- [ ] Docs viewer renders markdown correctly +- [ ] Responsive design works on different screen sizes +- [ ] Error handling works properly +- [ ] Loading states display correctly + +#### 7.2 Performance Considerations + +- Lazy load demo components +- Minimize initial bundle size +- Use Blazor InteractiveServer for UI interactions +- Optimize chat message rendering +- Consider virtualization for long message lists + +## Technical Considerations + +### Blazor Render Modes + +We'll use **Interactive Server** render mode (`@rendermode InteractiveServer`) for: +- Tab switching +- Demo sidebar interactions +- Chat interactions +- Real-time updates + +This provides: +- Real-time updates over SignalR +- Server-side state management +- No need for WebAssembly +- Simplified deployment + +### AG-UI Client Integration + +Each demo component will: +1. Inject or create an `IChatClient` instance +2. Configure with the appropriate endpoint +3. Use `ChatClient.GetStreamingResponseAsync()` for streaming +4. Handle different content types (text, state, approvals, etc.) +5. Dispose properly on component disposal + +### Code Organization Best Practices + +Following existing conventions: +- Copyright headers on all `.cs` files +- XML documentation for public classes/methods +- Use `@inject` for dependency injection +- Use `@implements IDisposable` for cleanup +- Use `@code` blocks for component logic +- Follow Blazor naming conventions (PascalCase for components) + +## Success Criteria + +1. **Functional**: All seven scenarios work correctly +2. **Usable**: Intuitive navigation between scenarios and tabs +3. **Educational**: Code and docs help users understand AG-UI +4. **Performant**: Smooth interactions, fast loading +5. **Maintainable**: Clean code, good structure, easy to extend +6. **Responsive**: Works on desktop, tablet, and mobile + +## Future Enhancements (Out of Scope) + +- Authentication/authorization +- Saving/sharing demo sessions +- Custom scenario creation +- Integration selector (supporting multiple frameworks) +- Deployment to Azure +- Analytics/telemetry +- Dark mode toggle +- Internationalization + +## Dependencies & Prerequisites + +### Required NuGet Packages +- Microsoft.Agents.AI.AGUI (already referenced) +- Markdig (for markdown rendering in docs) +- BlazorMonaco or similar (optional, for code syntax highlighting) + +### Environment Configuration +- Server must be running on configured port (default: 5100) +- All seven endpoints must be available on AGUIDojoServer +- Azure OpenAI credentials configured in AGUIDojoServer + +## Implementation Timeline + +### Phase 1 (Structure): 2-3 hours +- Create component structure +- Set up routing +- Create demo service + +### Phase 2 (Core Components): 3-4 hours +- Build DemoSidebar +- Build DemoViewTabs +- Build Demo.razor container + +### Phase 3 (Scenarios): 4-6 hours +- Implement all seven scenario components +- Reuse/adapt existing chat components + +### Phase 4 (Code/Docs): 2-3 hours +- Build code viewer +- Build docs viewer +- Create documentation content + +### Phase 5 (Styling): 2-3 hours +- Implement responsive layout +- Style components +- Add loading states + +### Phase 6 (Testing): 2-3 hours +- Manual testing +- Bug fixes +- Refinements + +**Total Estimated Time**: 15-22 hours + +## Conclusion + +This plan provides a comprehensive roadmap for building a simplified Blazor version of the AG-UI dojo. By following this plan, we'll create an educational and interactive platform that showcases the capabilities of the Microsoft Agent Framework's AG-UI integration. The modular structure ensures maintainability and extensibility for future enhancements. diff --git a/dotnet/samples/AGUIDojo/start-dojo.ps1 b/dotnet/samples/AGUIDojo/start-dojo.ps1 new file mode 100644 index 0000000000..49a75e7c2b --- /dev/null +++ b/dotnet/samples/AGUIDojo/start-dojo.ps1 @@ -0,0 +1,32 @@ +# Stop any existing dotnet processes on ports 5000 and 5018 +$existingProcesses = Get-NetTCPConnection -LocalPort 5000,5018 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique +foreach ($processId in $existingProcesses) { + Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue +} + +# Wait a moment for ports to be released +Start-Sleep -Seconds 1 + +# Build the solution first +Write-Host "Building solution..." +$solutionPath = Join-Path $PSScriptRoot "AGUIDojo.slnx" +dotnet build $solutionPath +if ($LASTEXITCODE -ne 0) { + Write-Host "Build failed. Exiting." -ForegroundColor Red + exit 1 +} +Write-Host "Build completed successfully." -ForegroundColor Green + +# Start server in background (no-build since we already built) +$serverPath = Join-Path $PSScriptRoot "AGUIDojoServer" +Start-Process -FilePath "dotnet" -ArgumentList "run", "--no-build" -WorkingDirectory $serverPath -NoNewWindow + +# Wait for server to start +Start-Sleep -Seconds 3 + +# Start client in background (no-build since we already built) +$clientPath = Join-Path $PSScriptRoot "AGUIDojoClient" +Start-Process -FilePath "dotnet" -ArgumentList "run", "--no-build" -WorkingDirectory $clientPath -NoNewWindow + +Write-Host "Started AGUIDojoServer on http://localhost:5018" +Write-Host "Started AGUIDojoClient on http://localhost:5000" diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs index eca2131f23..66a67dd96b 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs @@ -47,6 +47,7 @@ internal sealed class BaseEventJsonConverter : JsonConverter AGUIEventTypes.ToolCallEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallEndEvent))) as ToolCallEndEvent, AGUIEventTypes.ToolCallResult => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallResultEvent))) as ToolCallResultEvent, AGUIEventTypes.StateSnapshot => jsonElement.Deserialize(options.GetTypeInfo(typeof(StateSnapshotEvent))) as StateSnapshotEvent, + AGUIEventTypes.StateDelta => jsonElement.Deserialize(options.GetTypeInfo(typeof(StateDeltaEvent))) as StateDeltaEvent, _ => throw new JsonException($"Unknown BaseEvent type discriminator: '{discriminator}'") };