mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9c60c29ec | ||
|
|
fcb6ec9fd3 | ||
|
|
c0ce673354 | ||
|
|
0c93762cb6 | ||
|
|
c34a72c3bf | ||
|
|
64790c4c34 | ||
|
|
38f3e6a5ec | ||
|
|
495c2e9a92 | ||
|
|
99ac6fdaa5 | ||
|
|
0eef9949bf | ||
|
|
0340531f3a |
@@ -20,9 +20,14 @@
|
||||
</Folder>
|
||||
<Folder Name="/Samples/AGUIClientServer/">
|
||||
<Project Path="samples/AGUIClientServer/AGUIClient/AGUIClient.csproj" />
|
||||
<Project Path="samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj" />
|
||||
<Project Path="samples/AGUIClientServer/AGUIServer/AGUIServer.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/AGUIDojo/">
|
||||
<File Path="samples/AGUIDojo/Design.md" />
|
||||
<File Path="samples/AGUIDojo/Plan.md" />
|
||||
<Project Path="samples/AGUIDojo/AGUIDojoClient/AGUIDojoClient.csproj" />
|
||||
<Project Path="samples/AGUIDojo/AGUIDojoServer/AGUIDojoServer.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/AzureFunctions/">
|
||||
<File Path="samples/AzureFunctions/.editorconfig" />
|
||||
<File Path="samples/AzureFunctions/README.md" />
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.playwright-mcp/
|
||||
@@ -0,0 +1,11 @@
|
||||
<Solution>
|
||||
<Project Path="../../src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<Project Path="../../src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<Project Path="../../src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<Project Path="../../src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<Project Path="../../src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<Project Path="../../src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<Project Path="../../src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj" />
|
||||
<Project Path="AGUIDojoClient/AGUIDojoClient.csproj" />
|
||||
<Project Path="AGUIDojoServer/AGUIDojoServer.csproj" />
|
||||
</Solution>
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks></TargetFrameworks>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,141 @@
|
||||
// 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<TState> : IComponent, IDisposable
|
||||
{
|
||||
private RenderHandle _renderHandle;
|
||||
private RenderFragment? _renderWithState;
|
||||
private protected AgentBoundaryContext<TState>? _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; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback invoked when the boundary context is created or recreated.
|
||||
/// Use this to register tools or perform other initialization.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<IAgentBoundaryContext> OnContextCreated { get; set; }
|
||||
|
||||
public void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
this._renderHandle = renderHandle;
|
||||
this._renderWithState = this.RenderWithState;
|
||||
}
|
||||
|
||||
// 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<TState?>.Default.Equals(this.State, currentState);
|
||||
|
||||
bool refresh = agentChanged || threadChanged || stateChanged;
|
||||
|
||||
if (refresh)
|
||||
{
|
||||
this._context?.Dispose();
|
||||
this._context = null;
|
||||
this._currentThread = null;
|
||||
}
|
||||
|
||||
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<TState>(this.Agent!, thread!);
|
||||
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()
|
||||
{
|
||||
this._renderHandle.Render(builder =>
|
||||
{
|
||||
builder.OpenComponent<CascadingValue<AgentBoundaryContext<TState>>>(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<CascadingValue<TState?>>(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)
|
||||
{
|
||||
this._context?.Dispose();
|
||||
}
|
||||
this._disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class AgentBoundary : AgentStateBoundary<object?>
|
||||
{
|
||||
protected override void RenderWithState(RenderTreeBuilder builder)
|
||||
{
|
||||
builder.OpenComponent<CascadingValue<IAgentBoundaryContext>>(0);
|
||||
builder.AddComponentParameter(1, "Value", this._context);
|
||||
builder.AddComponentParameter(2, "IsFixed", true);
|
||||
builder.AddComponentParameter(3, "ChildContent", this.ChildContent);
|
||||
builder.CloseComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
public sealed partial class AgentBoundaryContext<TState> : IAgentBoundaryContext, IDisposable
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
private readonly AgentThread _thread;
|
||||
private readonly CancellationTokenSource _cancellationTokenSource;
|
||||
private readonly List<ChatMessage> _messages = [];
|
||||
private readonly List<ChatMessage> _pendingMessages = [];
|
||||
private readonly List<Action> _messageChangeSubscribers = [];
|
||||
private readonly List<Action> _responseUpdateSubscribers = [];
|
||||
private readonly List<Action> _runStatusSubscribers = [];
|
||||
private readonly List<AITool> _tools = [];
|
||||
private readonly Dictionary<string, TaskCompletionSource<object>> _pendingResponses = [];
|
||||
|
||||
public CancellationToken CancellationToken => this._cancellationTokenSource.Token;
|
||||
|
||||
public IReadOnlyList<ChatMessage> CompletedMessages => this._messages.AsReadOnly();
|
||||
|
||||
public TState? CurrentState { get; set; }
|
||||
|
||||
public IReadOnlyList<ChatMessage> PendingMessages => this._pendingMessages.AsReadOnly();
|
||||
|
||||
public ChatMessage? CurrentMessage { get; private set; }
|
||||
|
||||
public ChatResponseUpdate? CurrentUpdate { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the agent is currently processing a turn.
|
||||
/// </summary>
|
||||
public bool IsProcessing { get; private set; }
|
||||
|
||||
public AgentBoundaryContext(AIAgent agent, AgentThread thread)
|
||||
{
|
||||
this._agent = agent;
|
||||
this._thread = thread;
|
||||
this._cancellationTokenSource = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void RegisterTool(AITool tool)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tool);
|
||||
this._tools.Add(tool);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers multiple client-side tools that will be passed to the agent during invocation.
|
||||
/// </summary>
|
||||
public void RegisterTools(params AITool[] tools)
|
||||
{
|
||||
foreach (var tool in tools)
|
||||
{
|
||||
this.RegisterTool(tool);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="key">A unique key to identify this pending response (e.g., function call ID).</param>
|
||||
/// <returns>A task that completes with the response object when ProvideResponse is called.</returns>
|
||||
public Task<object> WaitForResponse(string key)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
this._pendingResponses[key] = tcs;
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides a response for a pending request, completing the task returned by WaitForResponse.
|
||||
/// Called by UI components when user interaction is complete.
|
||||
/// </summary>
|
||||
/// <param name="key">The key used when calling WaitForResponse.</param>
|
||||
/// <param name="response">The response object to return.</param>
|
||||
/// <returns>True if the response was provided successfully, false if no pending request was found.</returns>
|
||||
public bool ProvideResponse(string key, object response)
|
||||
{
|
||||
if (this._pendingResponses.TryGetValue(key, out var tcs))
|
||||
{
|
||||
this._pendingResponses.Remove(key);
|
||||
tcs.TrySetResult(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._cancellationTokenSource.Cancel();
|
||||
this._cancellationTokenSource.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
public async Task SendAsync(params ChatMessage[] userMessages)
|
||||
{
|
||||
// This starts a new turn. Once completed, we will add the new messages to the conversation.
|
||||
this._pendingMessages.Clear();
|
||||
this._pendingMessages.AddRange(userMessages);
|
||||
|
||||
// Mark as processing
|
||||
this.IsProcessing = true;
|
||||
|
||||
// Notify subscribers that run status changed (processing started)
|
||||
this.TriggerRunStatusChanged();
|
||||
|
||||
// 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]
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// 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);
|
||||
if (isNewMessage)
|
||||
{
|
||||
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;
|
||||
this.IsProcessing = false;
|
||||
|
||||
// Notify subscribers that run status changed (processing ended)
|
||||
this.TriggerRunStatusChanged();
|
||||
|
||||
// Notify subscribers
|
||||
this.TriggerChatResponseUpdate();
|
||||
this.TriggerMessageChanges();
|
||||
}
|
||||
|
||||
private void TriggerChatResponseUpdate()
|
||||
{
|
||||
// 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()
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
|
||||
public RunStatusSubscription SubscribeToRunStatusChanges(Action onRunStatusChanged)
|
||||
{
|
||||
return new RunStatusSubscription(this._runStatusSubscribers, onRunStatusChanged);
|
||||
}
|
||||
|
||||
private void TriggerRunStatusChanged()
|
||||
{
|
||||
// Iterate backwards to avoid issues if subscribers are removed during iteration
|
||||
for (var i = this._runStatusSubscribers.Count - 1; i >= 0; i--)
|
||||
{
|
||||
this._runStatusSubscribers[i]();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface IAgentBoundaryContext
|
||||
{
|
||||
// Push new messages to the agent
|
||||
Task SendAsync(params ChatMessage[] userMessages);
|
||||
|
||||
// All message interactions from previous turns
|
||||
IReadOnlyList<ChatMessage> CompletedMessages { get; }
|
||||
|
||||
// All message interactions from the current turn. These represent completed messages only.
|
||||
IReadOnlyList<ChatMessage> PendingMessages { get; }
|
||||
|
||||
// The current message being processed by the agent.
|
||||
ChatMessage? CurrentMessage { get; }
|
||||
|
||||
ChatResponseUpdate? CurrentUpdate { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the agent is currently processing a turn.
|
||||
/// </summary>
|
||||
bool IsProcessing { get; }
|
||||
|
||||
// Triggered any time there is a change on a message.
|
||||
MessageSubscription SubscribeToMessageChanges(Action onNewMessage);
|
||||
|
||||
ResponseUpdateSubscription SubscribeToResponseUpdates(Action onChatResponse);
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to run status changes (when processing starts or ends).
|
||||
/// </summary>
|
||||
/// <param name="onRunStatusChanged">The callback to invoke when run status changes.</param>
|
||||
/// <returns>A subscription that can be disposed to unsubscribe.</returns>
|
||||
RunStatusSubscription SubscribeToRunStatusChanges(Action onRunStatusChanged);
|
||||
|
||||
CancellationToken CancellationToken { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Registers a client-side tool that will be passed to the agent during invocation.
|
||||
/// </summary>
|
||||
void RegisterTool(AITool tool);
|
||||
|
||||
/// <summary>
|
||||
/// Registers multiple client-side tools that will be passed to the agent during invocation.
|
||||
/// </summary>
|
||||
void RegisterTools(params AITool[] tools);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="key">A unique key to identify this pending response (e.g., function call ID).</param>
|
||||
/// <returns>A task that completes with the response object when ProvideResponse is called.</returns>
|
||||
Task<object> WaitForResponse(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Provides a response for a pending request, completing the task returned by WaitForResponse.
|
||||
/// Called by UI components when user interaction is complete.
|
||||
/// </summary>
|
||||
/// <param name="key">The key used when calling WaitForResponse.</param>
|
||||
/// <param name="response">The response object to return.</param>
|
||||
/// <returns>True if the response was provided successfully, false if no pending request was found.</returns>
|
||||
bool ProvideResponse(string key, object response);
|
||||
}
|
||||
|
||||
public readonly struct MessageSubscription : IDisposable, IEquatable<MessageSubscription>
|
||||
{
|
||||
internal readonly List<Action> _subscribers;
|
||||
internal readonly Action _subscription;
|
||||
|
||||
internal MessageSubscription(List<Action> 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<List<Action>>.Default.Equals(this._subscribers, other._subscribers) &&
|
||||
EqualityComparer<Action>.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<ResponseUpdateSubscription>
|
||||
{
|
||||
private readonly List<Action> _subscribers;
|
||||
private readonly Action _subscription;
|
||||
|
||||
public ResponseUpdateSubscription(List<Action> 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<List<Action>>.Default.Equals(this._subscribers, other._subscribers) &&
|
||||
EqualityComparer<Action>.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);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct RunStatusSubscription : IDisposable, IEquatable<RunStatusSubscription>
|
||||
{
|
||||
private readonly List<Action> _subscribers;
|
||||
private readonly Action _subscription;
|
||||
|
||||
public RunStatusSubscription(List<Action> 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 RunStatusSubscription subscription && this.Equals(subscription);
|
||||
}
|
||||
|
||||
public bool Equals(RunStatusSubscription other)
|
||||
{
|
||||
return EqualityComparer<List<Action>>.Default.Equals(this._subscribers, other._subscribers) &&
|
||||
EqualityComparer<Action>.Default.Equals(this._subscription, other._subscription);
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return HashCode.Combine(this._subscribers, this._subscription);
|
||||
}
|
||||
|
||||
public static bool operator ==(RunStatusSubscription left, RunStatusSubscription right)
|
||||
{
|
||||
return left.Equals(right);
|
||||
}
|
||||
|
||||
public static bool operator !=(RunStatusSubscription left, RunStatusSubscription right)
|
||||
{
|
||||
return !(left == right);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
public sealed partial class AgentInput : IComponent, IDisposable
|
||||
{
|
||||
private RenderHandle _renderHandle;
|
||||
private AgentBoundaryContext<object?>? _context;
|
||||
private string? _inputText;
|
||||
private RunStatusSubscription? _subscription;
|
||||
|
||||
[CascadingParameter] public AgentBoundaryContext<object?>? AgentContext { get; set; }
|
||||
|
||||
[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);
|
||||
|
||||
// Unsubscribe from previous context if it changed
|
||||
if (this._context != this.AgentContext)
|
||||
{
|
||||
this._subscription?.Dispose();
|
||||
this._context = this.AgentContext;
|
||||
|
||||
// Subscribe to run status changes
|
||||
if (this._context != null)
|
||||
{
|
||||
this._subscription = this._context.SubscribeToRunStatusChanges(this.OnRunStatusChanged);
|
||||
}
|
||||
}
|
||||
|
||||
this.Render();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnRunStatusChanged()
|
||||
{
|
||||
this.Render();
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
var isProcessing = this._context?.IsProcessing ?? false;
|
||||
var isDisabled = string.IsNullOrWhiteSpace(this._inputText) || isProcessing;
|
||||
|
||||
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<ChangeEventArgs>(this, e => { this._inputText = e.Value?.ToString(); this.Render(); }));
|
||||
builder.AddAttribute(5, "placeholder", this.Placeholder);
|
||||
builder.AddAttribute(6, "rows", "1");
|
||||
builder.AddAttribute(7, "disabled", isProcessing);
|
||||
builder.CloseElement();
|
||||
|
||||
builder.OpenElement(8, "button");
|
||||
builder.AddAttribute(9, "class", "send-button");
|
||||
builder.AddAttribute(10, "onclick", EventCallback.Factory.Create(this, this.SendAsync));
|
||||
builder.AddAttribute(11, "disabled", isDisabled);
|
||||
builder.AddMarkupContent(12, """<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>""");
|
||||
builder.CloseElement(); // close button
|
||||
|
||||
builder.CloseElement(); // close div
|
||||
});
|
||||
}
|
||||
|
||||
private async Task SendAsync()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(this._inputText) && this._context is { IsProcessing: false })
|
||||
{
|
||||
var text = this._inputText;
|
||||
this._inputText = ""; // Clear input immediately
|
||||
this.Render(); // Re-render to clear input
|
||||
|
||||
await this._context.SendAsync(new ChatMessage(ChatRole.User, text));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._subscription?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A component that displays a loading indicator (three animated dots) when the agent is processing.
|
||||
/// </summary>
|
||||
public sealed partial class AgentLoadingIndicator : IComponent, IDisposable
|
||||
{
|
||||
private RenderHandle _renderHandle;
|
||||
private AgentBoundaryContext<object?>? _context;
|
||||
private RunStatusSubscription? _subscription;
|
||||
|
||||
[CascadingParameter] public AgentBoundaryContext<object?>? AgentContext { get; set; }
|
||||
|
||||
public void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
this._renderHandle = renderHandle;
|
||||
}
|
||||
|
||||
public Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
|
||||
// Unsubscribe from previous context if it changed
|
||||
if (this._context != this.AgentContext)
|
||||
{
|
||||
this._subscription?.Dispose();
|
||||
this._context = this.AgentContext;
|
||||
|
||||
// Subscribe to run status changes
|
||||
if (this._context != null)
|
||||
{
|
||||
this._subscription = this._context.SubscribeToRunStatusChanges(this.OnRunStatusChanged);
|
||||
}
|
||||
}
|
||||
|
||||
this.Render();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnRunStatusChanged()
|
||||
{
|
||||
this.Render();
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
var isProcessing = this._context?.IsProcessing ?? false;
|
||||
|
||||
this._renderHandle.Render(builder =>
|
||||
{
|
||||
if (!isProcessing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
builder.OpenElement(0, "div");
|
||||
builder.AddAttribute(1, "class", "agent-loading-indicator");
|
||||
builder.OpenElement(2, "div");
|
||||
builder.AddAttribute(3, "class", "agent-loading-dots");
|
||||
builder.AddMarkupContent(4, "<span></span><span></span><span></span>");
|
||||
builder.CloseElement();
|
||||
builder.CloseElement();
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._subscription?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@namespace Microsoft.AspNetCore.Components.AI
|
||||
@using Microsoft.Extensions.AI
|
||||
@typeparam TState
|
||||
@implements IDisposable
|
||||
|
||||
<CascadingValue Value="@CurrentState" IsFixed="false">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
|
||||
@code {
|
||||
private ResponseUpdateSubscription? _subscription;
|
||||
|
||||
/// <summary>
|
||||
/// The agent boundary context to subscribe to for state updates.
|
||||
/// </summary>
|
||||
[CascadingParameter]
|
||||
public IAgentBoundaryContext? BoundaryContext { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The current state value. Can be set initially and will be updated by state events.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public TState? CurrentState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback to deserialize a STATE_SNAPSHOT (application/json) into TState.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Func<ReadOnlyMemory<byte>, TState?>? OnSnapshot { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback to apply a STATE_DELTA (application/json-patch+json) to the current state.
|
||||
/// Returns the updated state.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Func<TState?, ReadOnlyMemory<byte>, TState?>? OnDelta { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback invoked whenever state changes.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<TState?> CurrentStateChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Child content that will receive the cascaded state.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a suggestion that can be displayed and sent to the agent.
|
||||
/// </summary>
|
||||
public readonly struct Suggestion : IEquatable<Suggestion>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Suggestion"/> struct.
|
||||
/// </summary>
|
||||
/// <param name="text">The display text for the suggestion.</param>
|
||||
/// <param name="message">The message to send when the suggestion is selected.</param>
|
||||
public Suggestion(string text, ChatMessage message)
|
||||
{
|
||||
this.Text = text;
|
||||
this.Message = message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Suggestion"/> struct with a simple text message.
|
||||
/// </summary>
|
||||
/// <param name="text">The display text for the suggestion, also used as the message content.</param>
|
||||
public Suggestion(string text)
|
||||
{
|
||||
this.Text = text;
|
||||
this.Message = new ChatMessage(ChatRole.User, text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display text for the suggestion.
|
||||
/// </summary>
|
||||
public string Text { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the message to send when the suggestion is selected.
|
||||
/// </summary>
|
||||
public ChatMessage Message { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool Equals(object? obj) => obj is Suggestion other && this.Equals(other);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(Suggestion other) => this.Text == other.Text;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode() => this.Text?.GetHashCode() ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether two <see cref="Suggestion"/> instances are equal.
|
||||
/// </summary>
|
||||
public static bool operator ==(Suggestion left, Suggestion right) => left.Equals(right);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether two <see cref="Suggestion"/> instances are not equal.
|
||||
/// </summary>
|
||||
public static bool operator !=(Suggestion left, Suggestion right) => !left.Equals(right);
|
||||
}
|
||||
|
||||
public sealed partial class AgentSuggestions : IComponent, IDisposable
|
||||
{
|
||||
private RenderHandle _renderHandle;
|
||||
private AgentBoundaryContext<object?>? _context;
|
||||
private IReadOnlyList<Suggestion>? _suggestions;
|
||||
private RunStatusSubscription? _subscription;
|
||||
|
||||
[CascadingParameter] public AgentBoundaryContext<object?>? AgentContext { get; set; }
|
||||
|
||||
[Parameter] public IReadOnlyList<Suggestion>? Suggestions { get; set; }
|
||||
|
||||
public void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
this._renderHandle = renderHandle;
|
||||
}
|
||||
|
||||
public Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
|
||||
// Unsubscribe from previous context if it changed
|
||||
if (this._context != this.AgentContext)
|
||||
{
|
||||
this._subscription?.Dispose();
|
||||
this._context = this.AgentContext;
|
||||
|
||||
// Subscribe to run status changes
|
||||
if (this._context != null)
|
||||
{
|
||||
this._subscription = this._context.SubscribeToRunStatusChanges(this.OnRunStatusChanged);
|
||||
}
|
||||
}
|
||||
|
||||
this._suggestions = this.Suggestions;
|
||||
this.Render();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnRunStatusChanged()
|
||||
{
|
||||
this.Render();
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
var isProcessing = this._context?.IsProcessing ?? false;
|
||||
|
||||
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.AddAttribute(5, "disabled", isProcessing);
|
||||
builder.AddContent(6, suggestion.Text);
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
builder.CloseElement();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task SelectSuggestionAsync(Suggestion suggestion)
|
||||
{
|
||||
if (this._context is { IsProcessing: false })
|
||||
{
|
||||
await this._context.SendAsync(suggestion.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._subscription?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+10
@@ -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;
|
||||
}
|
||||
+14
@@ -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<ContentContext> ChildContent { get; set; } = (content) => builder => { };
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Default template for rendering DataContent. Renders nothing by default
|
||||
/// as DataContent typically contains binary/JSON data not meant for display.
|
||||
/// </summary>
|
||||
public class DataContentTemplate : ContentTemplateBase
|
||||
{
|
||||
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
|
||||
|
||||
public override void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
this.ChildContent = this.RenderData;
|
||||
}
|
||||
|
||||
public override Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
this.Context.RegisterContentTemplate(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override bool When(ContentContext context)
|
||||
{
|
||||
return context.Content is DataContent;
|
||||
}
|
||||
|
||||
private RenderFragment RenderData(ContentContext content) => builder =>
|
||||
{
|
||||
// By default, render nothing.
|
||||
};
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Default template for rendering ErrorContent. Renders nothing by default
|
||||
/// to prevent the error from crashing the UI. More specific templates can
|
||||
/// override this to display error messages.
|
||||
/// </summary>
|
||||
public class ErrorTemplate : ContentTemplateBase
|
||||
{
|
||||
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
|
||||
|
||||
public override void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
this.ChildContent = this.RenderError;
|
||||
}
|
||||
|
||||
public override Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
this.Context.RegisterContentTemplate(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if this template should handle the given content.
|
||||
/// Matches ErrorContent.
|
||||
/// </summary>
|
||||
public override bool When(ContentContext context)
|
||||
{
|
||||
return context.Content is ErrorContent;
|
||||
}
|
||||
|
||||
private RenderFragment RenderError(ContentContext content) => builder =>
|
||||
{
|
||||
// By default, render nothing.
|
||||
// Specific templates can override to display error messages.
|
||||
};
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Template for rendering function/tool call content in messages.
|
||||
/// Provides access to both the call and its result (when available) via InvocationContext.
|
||||
/// </summary>
|
||||
public class FunctionCallTemplate : ContentTemplateBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the tool name to filter on. If null, matches all function calls.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if this template should handle the given content.
|
||||
/// </summary>
|
||||
/// <param name="context">The content context.</param>
|
||||
/// <returns>True if this template should render the content.</returns>
|
||||
public override 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 =>
|
||||
{
|
||||
// By default, function calls are not rendered visually.
|
||||
// Custom templates (like WeatherCallTemplate) can override this
|
||||
// behavior for specific functions by registering before this template.
|
||||
};
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Default template for FunctionResultContent that renders nothing.
|
||||
/// Function results are internal tool responses and typically don't need
|
||||
/// visual representation in the chat UI.
|
||||
/// </summary>
|
||||
public class FunctionResultTemplate : ContentTemplateBase
|
||||
{
|
||||
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
|
||||
|
||||
public override void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
// This component never renders anything by itself.
|
||||
this.ChildContent = this.RenderFunctionResult;
|
||||
}
|
||||
|
||||
public override Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
this.Context.RegisterContentTemplate(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only match FunctionResultContent.
|
||||
/// </summary>
|
||||
public override bool When(ContentContext context) => context.Content is FunctionResultContent;
|
||||
|
||||
private RenderFragment RenderFunctionResult(ContentContext content) => builder =>
|
||||
{
|
||||
// By default, function results are not rendered visually.
|
||||
// The result data is typically processed by the agent to generate text responses.
|
||||
};
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Context for a function invocation, tracking the call and its result.
|
||||
/// </summary>
|
||||
public class InvocationContext
|
||||
{
|
||||
private Action? _resultArrived;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InvocationContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="call">The function call content.</param>
|
||||
public InvocationContext(FunctionCallContent call)
|
||||
{
|
||||
this.Call = call;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the function call content.
|
||||
/// </summary>
|
||||
public FunctionCallContent Call { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the function result content, if available.
|
||||
/// </summary>
|
||||
public FunctionResultContent? ResultContent { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the result has arrived.
|
||||
/// </summary>
|
||||
public bool HasResult => this.ResultContent != null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the function name from the call.
|
||||
/// </summary>
|
||||
public string FunctionName => this.Call.Name;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the call ID.
|
||||
/// </summary>
|
||||
public string CallId => this.Call.CallId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the arguments from the call.
|
||||
/// </summary>
|
||||
public IDictionary<string, object?>? Arguments => this.Call.Arguments;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when the result arrives.
|
||||
/// </summary>
|
||||
#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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the result and raises the ResultArrived event.
|
||||
/// </summary>
|
||||
/// <param name="result">The function result content.</param>
|
||||
internal void SetResult(FunctionResultContent result)
|
||||
{
|
||||
this.ResultContent = result;
|
||||
this._resultArrived?.Invoke();
|
||||
this._resultArrived = null; // Clear invocation list after firing
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an argument value by name, deserializing from JSON if necessary.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to deserialize to.</typeparam>
|
||||
/// <param name="name">The argument name.</param>
|
||||
/// <returns>The argument value, or default if not found.</returns>
|
||||
public T? GetArgument<T>(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<T>();
|
||||
}
|
||||
|
||||
// Try to convert via JSON serialization
|
||||
var json = JsonSerializer.Serialize(value);
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result as a specific type, deserializing from JSON if necessary.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to deserialize to.</typeparam>
|
||||
/// <returns>The result value, or default if not available.</returns>
|
||||
public T? GetResult<T>()
|
||||
{
|
||||
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<T>();
|
||||
}
|
||||
|
||||
// Try to convert via JSON serialization
|
||||
var json = JsonSerializer.Serialize(this.ResultContent.Result);
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only match TextContent, allowing other content templates to handle
|
||||
/// FunctionCallContent, FunctionResultContent, etc.
|
||||
/// </summary>
|
||||
public override bool When(ContentContext context) => context.Content is TextContent;
|
||||
|
||||
private RenderFragment RenderText(ContentContext content) => builder =>
|
||||
{
|
||||
if (content.Content is TextContent textContent)
|
||||
{
|
||||
builder.AddContent(0, textContent.Text);
|
||||
}
|
||||
};
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
// 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<ChatMessage> _buffer = [];
|
||||
|
||||
public DefaultMessageTemplate()
|
||||
{
|
||||
this.ChildContent = this.SelectTemplate;
|
||||
}
|
||||
|
||||
private RenderFragment SelectTemplate(MessageContext messageContext)
|
||||
{
|
||||
if (messageContext.ChatMessage is not null)
|
||||
{
|
||||
var getRenderContents = messageContext.RenderContents();
|
||||
// Return a render fragment that checks visibility at render time
|
||||
// This is important because message contents may change during streaming
|
||||
return CreateRenderMessage(
|
||||
messageContext.ChatMessage,
|
||||
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],
|
||||
getRenderContents);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a message has any visible content that should be displayed.
|
||||
/// Messages that only contain FunctionResultContent are internal tool messages
|
||||
/// and should not be rendered as chat bubbles.
|
||||
/// FunctionCallContent is considered visible because content templates can render them
|
||||
/// (e.g., WeatherCallTemplate renders weather tool calls as cards).
|
||||
/// </summary>
|
||||
private static bool HasVisibleContent(ChatMessage message)
|
||||
{
|
||||
if (message.Contents.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var content in message.Contents)
|
||||
{
|
||||
// TextContent is visible
|
||||
if (content is TextContent textContent && !string.IsNullOrWhiteSpace(textContent.Text))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// FunctionCallContent is visible - content templates can render them
|
||||
// (e.g., WeatherCallTemplate renders weather tool calls as cards)
|
||||
if (content is FunctionCallContent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Other content types that are not function results are visible
|
||||
if (content is not FunctionResultContent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static RenderFragment CreateRenderMessage(
|
||||
ChatMessage message,
|
||||
RenderFragment getRenderContents)
|
||||
{
|
||||
var roleClass = $"{message.Role}-message";
|
||||
return builder =>
|
||||
{
|
||||
// Check visibility at render time, not at template creation time
|
||||
// This is important because message contents may change during streaming
|
||||
if (!HasVisibleContent(message))
|
||||
{
|
||||
return; // Don't render messages without visible content
|
||||
}
|
||||
|
||||
builder.OpenElement(0, "div");
|
||||
if (!string.IsNullOrEmpty(message.MessageId))
|
||||
{
|
||||
builder.AddAttribute(1, "id", message.MessageId);
|
||||
}
|
||||
builder.AddAttribute(2, "class", $"chat-message {roleClass}");
|
||||
builder.AddContent(3, getRenderContents);
|
||||
builder.CloseElement();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<AIContent, RenderFragment> _contentRenderers = [];
|
||||
|
||||
internal MessageContext(ChatMessage message, MessageListContext messageListContext)
|
||||
{
|
||||
this.ChatMessage = message;
|
||||
this._messageListContext = messageListContext;
|
||||
}
|
||||
|
||||
public ChatMessage? ChatMessage { get; init; }
|
||||
|
||||
public IList<ChatResponseUpdate>? 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
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<AIContent> contents)
|
||||
{
|
||||
Coalesce<TextContent>(
|
||||
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<byte>(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<AIContent>? 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<AIContent>? 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<AIContent> 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<TContent>(
|
||||
IList<AIContent> contents,
|
||||
bool mergeSingle,
|
||||
Func<TContent, TContent, bool>? canMerge,
|
||||
Func<IList<AIContent>, 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<T>(IList<T> contents)
|
||||
where T : class
|
||||
{
|
||||
if (contents is List<AIContent> 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<ChatMessage> messages)
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Get the message to target, either a new one or the last ones.
|
||||
ChatMessage message;
|
||||
if (isNewMessage)
|
||||
{
|
||||
message = new(ChatRole.Assistant, []);
|
||||
messages.Add(message);
|
||||
}
|
||||
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.
|
||||
message.MessageId = update.MessageId;
|
||||
}
|
||||
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
message.Contents.Add(content);
|
||||
}
|
||||
|
||||
return isNewMessage;
|
||||
}
|
||||
|
||||
/// <summary>Gets whether both strings are not null/empty and not the same as each other.</summary>
|
||||
private static bool NotEmptyOrEqual(string? s1, string? s2) =>
|
||||
s1 is { Length: > 0 } str1 && s2 is { Length: > 0 } str2 && str1 != str2;
|
||||
|
||||
/// <summary>Gets whether two roles are not null and not the same as each other.</summary>
|
||||
private static bool NotNullOrEqual(ChatRole? r1, ChatRole? r2) =>
|
||||
r1.HasValue && r2.HasValue && r1.Value != r2.Value;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
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);
|
||||
|
||||
// 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)
|
||||
{
|
||||
// Track all render keys to detect duplicates
|
||||
var allRenderKeys = new HashSet<string?>();
|
||||
|
||||
// Open container div for message list with flex layout
|
||||
builder.OpenElement(0, "div");
|
||||
builder.AddAttribute(1, "class", "messages-container");
|
||||
|
||||
foreach (var message in this.MessageListContext.AgentBoundaryContext.CompletedMessages)
|
||||
{
|
||||
var renderKey = GetUniqueRenderKey(message);
|
||||
|
||||
// Calling GetTemplate will stop template collection on the first message if it
|
||||
// was still ongoing.
|
||||
builder.OpenComponent<ContentBlock>(2);
|
||||
builder.SetKey(renderKey);
|
||||
builder.AddComponentParameter(3, "ChildContent", this.MessageListContext.GetTemplate(message));
|
||||
builder.CloseComponent();
|
||||
}
|
||||
|
||||
foreach (var message in this.MessageListContext.AgentBoundaryContext.PendingMessages)
|
||||
{
|
||||
var renderKey = GetUniqueRenderKey(message);
|
||||
|
||||
builder.OpenComponent<ContentBlock>(4);
|
||||
builder.SetKey(renderKey);
|
||||
builder.AddComponentParameter(5, "ChildContent", this.MessageListContext.GetTemplate(message));
|
||||
builder.CloseComponent();
|
||||
}
|
||||
|
||||
// Close container div
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<FunctionResultContent>().FirstOrDefault();
|
||||
if (resultContent != null && !string.IsNullOrEmpty(resultContent.CallId))
|
||||
{
|
||||
return $"{message.MessageId}_{resultContent.CallId}";
|
||||
}
|
||||
}
|
||||
|
||||
return message.MessageId;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
((IDisposable)this._messageSubscription).Dispose();
|
||||
((IDisposable)this._responseSubscription).Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// 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<MessageTemplateBase> _templates = [];
|
||||
private readonly List<ContentTemplateBase> _contentTemplates = [];
|
||||
|
||||
// We compute a render fragment to render each message only once and cache it here.
|
||||
private readonly Dictionary<ChatMessage, RenderFragment> _templateCache = [];
|
||||
|
||||
// Track function invocations by CallId to associate calls with results.
|
||||
private readonly Dictionary<string, InvocationContext> _invocationMap = [];
|
||||
|
||||
public MessageListContext(IAgentBoundaryContext context)
|
||||
{
|
||||
this.AgentBoundaryContext = context;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public void RegisterTemplate(MessageTemplateBase template)
|
||||
{
|
||||
if (this._collectingTemplates)
|
||||
{
|
||||
this._templates.Add(template);
|
||||
}
|
||||
}
|
||||
|
||||
public void RegisterContentTemplate(ContentTemplateBase template)
|
||||
{
|
||||
if (this._collectingTemplates)
|
||||
{
|
||||
this._contentTemplates.Add(template);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates an invocation context for the given function call.
|
||||
/// </summary>
|
||||
/// <param name="call">The function call content.</param>
|
||||
/// <returns>The invocation context for this call.</returns>
|
||||
public InvocationContext GetOrCreateInvocation(FunctionCallContent call)
|
||||
{
|
||||
if (!this._invocationMap.TryGetValue(call.CallId, out var context))
|
||||
{
|
||||
context = new InvocationContext(call);
|
||||
this._invocationMap[call.CallId] = context;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Associates a function result with its corresponding call.
|
||||
/// </summary>
|
||||
/// <param name="result">The function result content.</param>
|
||||
public void AssociateResult(FunctionResultContent result)
|
||||
{
|
||||
if (this._invocationMap.TryGetValue(result.CallId, out var context))
|
||||
{
|
||||
context.SetResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an invocation context by call ID.
|
||||
/// </summary>
|
||||
/// <param name="callId">The call ID.</param>
|
||||
/// <returns>The invocation context, or null if not found.</returns>
|
||||
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;
|
||||
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}.");
|
||||
}
|
||||
}
|
||||
@@ -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<MessageContext> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// 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<DefaultMessageTemplate>(0);
|
||||
builder.CloseComponent();
|
||||
};
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment ContentTemplates { get; set; } = builder =>
|
||||
{
|
||||
builder.OpenComponent<TextTemplate>(0);
|
||||
builder.CloseComponent();
|
||||
builder.OpenComponent<FunctionCallTemplate>(1);
|
||||
builder.CloseComponent();
|
||||
builder.OpenComponent<FunctionResultTemplate>(2);
|
||||
builder.CloseComponent();
|
||||
builder.OpenComponent<ErrorTemplate>(3);
|
||||
builder.CloseComponent();
|
||||
builder.OpenComponent<DataContentTemplate>(4);
|
||||
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)
|
||||
{
|
||||
this._messageListContext = new MessageListContext(this.AgentContext);
|
||||
this.Render();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
this._renderHandle.Render(this.RenderCore);
|
||||
}
|
||||
|
||||
private void RenderCore(RenderTreeBuilder builder)
|
||||
{
|
||||
builder.OpenComponent<CascadingValue<MessageListContext>>(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<MessageList>(3);
|
||||
builder.CloseComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/* Copyright (c) Microsoft. All rights reserved. */
|
||||
/* ==========================================================================
|
||||
AG-UI Components - Shared Styles
|
||||
These styles provide consistent appearance for AI components across demos.
|
||||
========================================================================== */
|
||||
|
||||
/* ==========================================================================
|
||||
Agent Input Component
|
||||
========================================================================== */
|
||||
|
||||
.agent-input {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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 textarea:disabled {
|
||||
background-color: #f9fafb;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Agent Suggestions Component
|
||||
========================================================================== */
|
||||
|
||||
.agent-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.suggestion-button:hover:not(:disabled) {
|
||||
background: #f0f0f0;
|
||||
border-color: #0078d4;
|
||||
color: #0078d4;
|
||||
}
|
||||
|
||||
.suggestion-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Loading Indicator - Triple Dot Animation
|
||||
========================================================================== */
|
||||
|
||||
.agent-loading-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.agent-loading-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-loading-dots span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: agent-dot-bounce 1.4s ease-in-out infinite both;
|
||||
}
|
||||
|
||||
.agent-loading-dots span:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.agent-loading-dots span:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
.agent-loading-dots span:nth-child(3) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
@keyframes agent-dot-bounce {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline loading indicator for use within messages */
|
||||
.agent-loading-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.agent-loading-inline span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background-color: #6b7280;
|
||||
border-radius: 50%;
|
||||
animation: agent-dot-bounce 1.4s ease-in-out infinite both;
|
||||
}
|
||||
|
||||
.agent-loading-inline span:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.agent-loading-inline span:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
.agent-loading-inline span:nth-child(3) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Processing State Overlay
|
||||
========================================================================== */
|
||||
|
||||
.agent-input-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.agent-input-container.processing .agent-input textarea {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Message Styles
|
||||
========================================================================== */
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.message-user {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-assistant {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 80%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-user .message-content {
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message-assistant .message-content {
|
||||
background-color: #f3f4f6;
|
||||
color: #1f2937;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Common Chat Layout Styles
|
||||
========================================================================== */
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
<link rel="stylesheet" href="AGUIDojoClient.styles.css" />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet @rendermode="@renderMode" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes @rendermode="@renderMode" />
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@code {
|
||||
private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false);
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using Microsoft.AspNetCore.Components.AI
|
||||
@using Microsoft.Agents.AI
|
||||
@using Microsoft.Extensions.AI
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using AGUIDojoClient.Services
|
||||
@inject IServiceProvider ServiceProvider
|
||||
@inject IBackgroundColorService BackgroundColorService
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>Agentic Chat</PageTitle>
|
||||
|
||||
<div class="chat-layout" style="@GetBackgroundStyle()">
|
||||
<div class="chat-header">
|
||||
<div class="chat-title">AGUI WebChat</div>
|
||||
<button class="new-chat-button" @onclick="ResetConversationAsync">
|
||||
<span class="button-icon">+</span> New chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AgentBoundary Agent="@agent">
|
||||
<div class="chat-content">
|
||||
<Messages />
|
||||
<AgentLoadingIndicator />
|
||||
</div>
|
||||
|
||||
<div class="chat-input-container">
|
||||
<AgentSuggestions Suggestions="@suggestions" />
|
||||
<AgentInput Placeholder="Type your message..." />
|
||||
</div>
|
||||
</AgentBoundary>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private AIAgent? agent;
|
||||
private string? _backgroundColor;
|
||||
private Suggestion[] suggestions = [
|
||||
new Suggestion("Change background", new ChatMessage(ChatRole.User, "Change background to light blue")),
|
||||
new Suggestion("Generate sonnet")
|
||||
];
|
||||
|
||||
[Parameter]
|
||||
public string ScenarioId { get; set; } = "agentic_chat";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("agentic-chat");
|
||||
BackgroundColorService.ColorChanged += OnColorChanged;
|
||||
}
|
||||
|
||||
private string GetBackgroundStyle()
|
||||
{
|
||||
return _backgroundColor != null ? $"background-color: {_backgroundColor}" : "";
|
||||
}
|
||||
|
||||
private async void OnColorChanged(object? sender, BackgroundColorChangedEventArgs e)
|
||||
{
|
||||
_backgroundColor = e.Color;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void ResetConversationAsync()
|
||||
{
|
||||
// Reset would need to be implemented in AgentBoundary
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
BackgroundColorService.ColorChanged -= OnColorChanged;
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/* 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: 1rem 2rem 1.5rem;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
background: white;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.agentic-chat-demo {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
padding: 0 2rem 1.5rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using Microsoft.AspNetCore.Components.AI
|
||||
@using Microsoft.Agents.AI
|
||||
@using Microsoft.Extensions.AI
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using AGUIDojoClient.Components.Shared
|
||||
@using System.Text.Json
|
||||
@inject IServiceProvider ServiceProvider
|
||||
|
||||
<PageTitle>Agentic Generative UI</PageTitle>
|
||||
|
||||
<div class="chat-layout">
|
||||
<div class="chat-header">
|
||||
<div class="chat-title">Agentic Generative UI</div>
|
||||
<button class="new-chat-button" @onclick="ResetConversation">
|
||||
<span class="button-icon">+</span> New chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AgentBoundary Agent="@agent">
|
||||
<AgentState TState="Plan"
|
||||
CurrentState="@currentPlan"
|
||||
OnSnapshot="@DeserializePlan"
|
||||
OnDelta="@ApplyPlanDelta"
|
||||
CurrentStateChanged="@OnPlanChanged">
|
||||
<div class="chat-content">
|
||||
<Messages>
|
||||
<ContentTemplates>
|
||||
<TaskProgressTemplate />
|
||||
<TextTemplate />
|
||||
<FunctionCallTemplate />
|
||||
<FunctionResultTemplate />
|
||||
<ErrorTemplate />
|
||||
<DataContentTemplate />
|
||||
</ContentTemplates>
|
||||
</Messages>
|
||||
<AgentLoadingIndicator />
|
||||
</div>
|
||||
|
||||
<div class="chat-input-container">
|
||||
<AgentSuggestions Suggestions="@suggestions" />
|
||||
<AgentInput Placeholder="Ask me to plan something..." />
|
||||
</div>
|
||||
</AgentState>
|
||||
</AgentBoundary>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private AIAgent? agent;
|
||||
private Plan? currentPlan;
|
||||
|
||||
private Suggestion[] suggestions = [
|
||||
new Suggestion("Simple plan", new ChatMessage(ChatRole.User, "Please build a plan to go to mars in 5 steps.")),
|
||||
new Suggestion("Complex plan", new ChatMessage(ChatRole.User, "Please build a plan to make pizza in 10 steps."))
|
||||
];
|
||||
|
||||
[Parameter]
|
||||
public string ScenarioId { get; set; } = "agentic_generative_ui";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("agentic-generative-ui");
|
||||
}
|
||||
|
||||
private Plan? DeserializePlan(ReadOnlyMemory<byte> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Plan>(data.Span);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Plan? ApplyPlanDelta(Plan? current, ReadOnlyMemory<byte> deltaData)
|
||||
{
|
||||
if (current is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var operations = JsonSerializer.Deserialize<List<JsonPatchOperation>>(deltaData.Span);
|
||||
if (operations is not null)
|
||||
{
|
||||
PlanPatcher.Apply(current, operations);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore deserialization errors
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private void OnPlanChanged(Plan? plan)
|
||||
{
|
||||
currentPlan = plan;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ResetConversation()
|
||||
{
|
||||
currentPlan = null;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/* Copyright (c) Microsoft. All rights reserved. */
|
||||
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.new-chat-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #d1d1d1;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #424242;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.new-chat-button:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #b3b3b3;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
background: white;
|
||||
}
|
||||
|
||||
::deep .agent-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
::deep .suggestion-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #d1d1d1;
|
||||
border-radius: 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #424242;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
::deep .suggestion-button:hover {
|
||||
background: #f0f0f0;
|
||||
border-color: #0078d4;
|
||||
color: #0078d4;
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.AgenticGenerativeUI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a JSON Patch operation (RFC 6902).
|
||||
/// </summary>
|
||||
public sealed class JsonPatchOperation
|
||||
{
|
||||
/// <summary>
|
||||
/// The operation to perform (e.g., "replace", "add", "remove").
|
||||
/// </summary>
|
||||
[JsonPropertyName("op")]
|
||||
public string Op { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The JSON Pointer path to the target location.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The value for the operation (used with "replace", "add", "test").
|
||||
/// </summary>
|
||||
[JsonPropertyName("value")]
|
||||
public object? Value { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.AgenticGenerativeUI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a plan with multiple steps.
|
||||
/// </summary>
|
||||
public sealed class Plan
|
||||
{
|
||||
/// <summary>
|
||||
/// The list of steps in the plan.
|
||||
/// </summary>
|
||||
[JsonPropertyName("steps")]
|
||||
public List<Step> Steps { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of completed steps.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public int CompletedCount => this.Steps.Count(s => s.IsCompleted);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of steps.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public int TotalCount => this.Steps.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether all steps are completed.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsComplete => this.Steps.Count > 0 && this.Steps.All(s => s.IsCompleted);
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.AgenticGenerativeUI;
|
||||
|
||||
/// <summary>
|
||||
/// Applies JSON Patch operations to a Plan object.
|
||||
/// </summary>
|
||||
public static partial class PlanPatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies a list of JSON Patch operations to the given plan.
|
||||
/// </summary>
|
||||
/// <param name="plan">The plan to modify.</param>
|
||||
/// <param name="operations">The patch operations to apply.</param>
|
||||
public static void Apply(Plan plan, IEnumerable<JsonPatchOperation> operations)
|
||||
{
|
||||
foreach (var operation in operations)
|
||||
{
|
||||
ApplyOperation(plan, operation);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyOperation(Plan plan, JsonPatchOperation operation)
|
||||
{
|
||||
// Parse paths like "/steps/0/status" or "/steps/0/description"
|
||||
var match = StepPathRegex().Match(operation.Path);
|
||||
if (!match.Success)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(match.Groups["index"].Value, out var index))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (index < 0 || index >= plan.Steps.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var property = match.Groups["property"].Value;
|
||||
var step = plan.Steps[index];
|
||||
|
||||
if (string.Equals(operation.Op, "replace", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.Equals(property, "status", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
step.Status = GetStringValue(operation.Value) ?? step.Status;
|
||||
}
|
||||
else if (string.Equals(property, "description", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
step.Description = GetStringValue(operation.Value) ?? step.Description;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetStringValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
string s => s,
|
||||
JsonElement { ValueKind: JsonValueKind.String } je => je.GetString(),
|
||||
_ => value?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^/steps/(?<index>\d+)/(?<property>\w+)$")]
|
||||
private static partial Regex StepPathRegex();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.AgenticGenerativeUI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single step in a plan.
|
||||
/// </summary>
|
||||
public sealed class Step
|
||||
{
|
||||
/// <summary>
|
||||
/// The description of the step.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The status of the step (pending or completed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this step is completed.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsCompleted => string.Equals(this.Status, "completed", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
|
||||
@if (Plan is not null && Plan.Steps.Count > 0)
|
||||
{
|
||||
<div class="task-progress-card @(Plan.IsComplete ? "completed" : "in-progress")">
|
||||
<div class="task-header">
|
||||
<h3>Task Progress</h3>
|
||||
<span class="task-counter">@Plan.CompletedCount/@Plan.TotalCount Complete</span>
|
||||
</div>
|
||||
<div class="task-steps">
|
||||
@for (int i = 0; i < Plan.Steps.Count; i++)
|
||||
{
|
||||
var step = Plan.Steps[i];
|
||||
var isCurrentStep = !step.IsCompleted &&
|
||||
(i == 0 || Plan.Steps[i - 1].IsCompleted);
|
||||
|
||||
<div class="task-step @(step.IsCompleted ? "step-completed" : isCurrentStep ? "step-current" : "step-pending")">
|
||||
<span class="step-icon">
|
||||
@if (step.IsCompleted)
|
||||
{
|
||||
<span class="check-icon">✓</span>
|
||||
}
|
||||
else if (isCurrentStep)
|
||||
{
|
||||
<span class="current-icon">●</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="pending-icon">○</span>
|
||||
}
|
||||
</span>
|
||||
<span class="step-description">@step.Description</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
public Plan? Plan { get; set; }
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/* Copyright (c) Microsoft. All rights reserved. */
|
||||
|
||||
.task-progress-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin: 8px 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.task-progress-card.completed {
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
.task-progress-card.in-progress {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.task-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.task-counter {
|
||||
font-size: 0.875rem;
|
||||
color: #666666;
|
||||
background: #f5f5f5;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.task-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.task-step.step-completed {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
|
||||
.task-step.step-current {
|
||||
background: rgba(0, 120, 212, 0.1);
|
||||
border-left: 3px solid #0078d4;
|
||||
animation: pulse-border 2s infinite;
|
||||
}
|
||||
|
||||
.task-step.step-pending {
|
||||
background: #fafafa;
|
||||
border-left: 3px solid #d1d1d1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@keyframes pulse-border {
|
||||
0%, 100% {
|
||||
border-left-color: #0078d4;
|
||||
box-shadow: 0 0 0 0 rgba(0, 120, 212, 0.4);
|
||||
}
|
||||
50% {
|
||||
border-left-color: #50a0e8;
|
||||
box-shadow: 0 0 8px 0 rgba(0, 120, 212, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: #22c55e;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.current-icon {
|
||||
color: #0078d4;
|
||||
font-size: 12px;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.pending-icon {
|
||||
color: #999999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.step-description {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: #424242;
|
||||
}
|
||||
|
||||
.step-completed .step-description {
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.step-pending .step-description {
|
||||
color: #666666;
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using Microsoft.AspNetCore.Components.AI
|
||||
@using Microsoft.Agents.AI
|
||||
@using Microsoft.Extensions.AI
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using AGUIDojoClient.Components.Shared
|
||||
@inject IServiceProvider ServiceProvider
|
||||
|
||||
<PageTitle>Backend Tool Rendering</PageTitle>
|
||||
|
||||
<div class="chat-layout">
|
||||
<div class="chat-header">
|
||||
<div class="chat-title">Backend Tool Rendering</div>
|
||||
<button class="new-chat-button" @onclick="ResetConversation">
|
||||
<span class="button-icon">+</span> New chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AgentBoundary Agent="@agent">
|
||||
<div class="chat-content">
|
||||
<Messages>
|
||||
<ContentTemplates>
|
||||
<TextTemplate />
|
||||
<WeatherCallTemplate />
|
||||
<FunctionCallTemplate />
|
||||
<FunctionResultTemplate />
|
||||
<ErrorTemplate />
|
||||
<DataContentTemplate />
|
||||
</ContentTemplates>
|
||||
</Messages>
|
||||
<AgentLoadingIndicator />
|
||||
</div>
|
||||
|
||||
<div class="chat-input-container">
|
||||
<AgentSuggestions Suggestions="@suggestions" />
|
||||
<AgentInput Placeholder="Ask about the weather..." />
|
||||
</div>
|
||||
</AgentBoundary>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private AIAgent? agent;
|
||||
private Suggestion[] suggestions = [
|
||||
new Suggestion("Weather in San Francisco", new ChatMessage(ChatRole.User, "What's the weather like in San Francisco?")),
|
||||
new Suggestion("Weather in New York", new ChatMessage(ChatRole.User, "What's the weather like in New York?")),
|
||||
new Suggestion("Weather in Tokyo", new ChatMessage(ChatRole.User, "What's the weather like in Tokyo?"))
|
||||
];
|
||||
|
||||
[Parameter]
|
||||
public string ScenarioId { get; set; } = "backend_tool_rendering";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("backend-tool-rendering");
|
||||
}
|
||||
|
||||
private void ResetConversation()
|
||||
{
|
||||
// Reset would need to be implemented - for now just trigger re-render
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/* Copyright (c) Microsoft. All rights reserved. */
|
||||
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.new-chat-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #d1d1d1;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #424242;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.new-chat-button:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #b3b3b3;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
background: white;
|
||||
}
|
||||
|
||||
::deep .agent-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
::deep .suggestion-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #d1d1d1;
|
||||
border-radius: 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #424242;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
::deep .suggestion-button:hover {
|
||||
background: #f0f0f0;
|
||||
border-color: #0078d4;
|
||||
color: #0078d4;
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Template for rendering weather function call content with its result.
|
||||
/// Uses InvocationContext to access both the call arguments and result.
|
||||
/// </summary>
|
||||
public class WeatherCallTemplate : ContentTemplateBase
|
||||
{
|
||||
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
|
||||
|
||||
public override void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
// This component never renders anything by itself.
|
||||
this.ChildContent = this.RenderWeatherCall;
|
||||
}
|
||||
|
||||
public override Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
this.Context.RegisterContentTemplate(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if this template should handle the given content.
|
||||
/// Matches FunctionCallContent for the get_weather function.
|
||||
/// </summary>
|
||||
public override bool When(ContentContext context)
|
||||
{
|
||||
// Only match FunctionCallContent for the get_weather function
|
||||
return context.Content is FunctionCallContent call &&
|
||||
string.Equals(call.Name, "get_weather", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private RenderFragment RenderWeatherCall(ContentContext content) => builder =>
|
||||
{
|
||||
if (content.Content is FunctionCallContent call)
|
||||
{
|
||||
// Get the invocation context which tracks both call and result
|
||||
var invocation = this.Context.GetOrCreateInvocation(call);
|
||||
|
||||
// Provide the invocation context to child components
|
||||
builder.OpenComponent<CascadingValue<InvocationContext>>(0);
|
||||
builder.AddComponentParameter(1, "Value", invocation);
|
||||
builder.AddComponentParameter(2, "IsFixed", true);
|
||||
builder.AddComponentParameter(3, "ChildContent", (RenderFragment)(innerBuilder =>
|
||||
{
|
||||
// Render the WeatherCard component which uses InvocationContext
|
||||
innerBuilder.OpenComponent<AGUIDojoClient.Components.Demos.BackendToolRendering.WeatherCard>(0);
|
||||
innerBuilder.CloseComponent();
|
||||
}));
|
||||
builder.CloseComponent();
|
||||
}
|
||||
};
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using AGUIDojoClient.Components.Shared
|
||||
@using Microsoft.AspNetCore.Components.AI
|
||||
|
||||
@if (Weather is null)
|
||||
{
|
||||
<div class="weather-card weather-loading">
|
||||
<div class="weather-header">
|
||||
<div class="weather-location">
|
||||
<h3>@Location</h3>
|
||||
<p>Loading weather...</p>
|
||||
</div>
|
||||
<span class="weather-icon skeleton-icon"></span>
|
||||
</div>
|
||||
|
||||
<div class="weather-main">
|
||||
<div class="temperature">
|
||||
<span class="temp-value skeleton-text skeleton-temp"></span>
|
||||
<span class="temp-fahrenheit skeleton-text skeleton-temp-f"></span>
|
||||
</div>
|
||||
<div class="conditions skeleton-text skeleton-conditions"></div>
|
||||
</div>
|
||||
|
||||
<div class="weather-details">
|
||||
<div class="detail-item">
|
||||
<p class="detail-label">Humidity</p>
|
||||
<p class="detail-value skeleton-text skeleton-detail"></p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<p class="detail-label">Wind</p>
|
||||
<p class="detail-value skeleton-text skeleton-detail"></p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<p class="detail-label">Feels Like</p>
|
||||
<p class="detail-value skeleton-text skeleton-detail"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="weather-card @GetConditionClass()">
|
||||
<div class="weather-header">
|
||||
<div class="weather-location">
|
||||
<h3>@Location</h3>
|
||||
<p>Current Weather</p>
|
||||
</div>
|
||||
<span class="weather-icon">@Weather.ConditionIcon</span>
|
||||
</div>
|
||||
|
||||
<div class="weather-main">
|
||||
<div class="temperature">
|
||||
<span class="temp-value">@Weather.Temperature° C</span>
|
||||
<span class="temp-fahrenheit">/ @Weather.TemperatureFahrenheit.ToString("F1")° F</span>
|
||||
</div>
|
||||
<div class="conditions">@Weather.Conditions</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-details">
|
||||
<div class="detail-item">
|
||||
<p class="detail-label">Humidity</p>
|
||||
<p class="detail-value">@(Weather.Humidity)%</p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<p class="detail-label">Wind</p>
|
||||
<p class="detail-value">@Weather.WindSpeed mph</p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<p class="detail-label">Feels Like</p>
|
||||
<p class="detail-value">@(Weather.FeelsLike)°</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
public InvocationContext Invocation { get; set; } = default!;
|
||||
|
||||
private string Location => Invocation?.GetArgument<string>("location") ?? "Unknown Location";
|
||||
|
||||
private WeatherInfo? Weather => Invocation?.HasResult == true
|
||||
? Invocation.GetResult<WeatherInfo>()
|
||||
: null;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (Invocation is not null && !Invocation.HasResult)
|
||||
{
|
||||
Invocation.ResultArrived += OnResultArrived;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnResultArrived()
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private string GetConditionClass()
|
||||
{
|
||||
if (Weather is null)
|
||||
{
|
||||
return "condition-default";
|
||||
}
|
||||
|
||||
return Weather.Conditions.ToLowerInvariant() switch
|
||||
{
|
||||
"sunny" or "clear" => "condition-sunny",
|
||||
"cloudy" or "overcast" => "condition-cloudy",
|
||||
"rainy" or "rain" => "condition-rainy",
|
||||
"stormy" or "thunderstorm" => "condition-stormy",
|
||||
"snowy" or "snow" => "condition-snowy",
|
||||
"foggy" or "fog" => "condition-foggy",
|
||||
_ => "condition-default"
|
||||
};
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
.weather-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
color: white;
|
||||
max-width: 320px;
|
||||
box-shadow: 0 10px 40px rgba(102, 126, 234, 0.4);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.weather-card.condition-sunny {
|
||||
background: linear-gradient(135deg, #f6d365 0%, #fda085 100%);
|
||||
}
|
||||
|
||||
.weather-card.condition-cloudy {
|
||||
background: linear-gradient(135deg, #bdc3c7 0%, #2c3e50 100%);
|
||||
}
|
||||
|
||||
.weather-card.condition-rainy {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.weather-card.condition-stormy {
|
||||
background: linear-gradient(135deg, #373b44 0%, #4286f4 100%);
|
||||
}
|
||||
|
||||
.weather-card.condition-snowy {
|
||||
background: linear-gradient(135deg, #e6dada 0%, #274046 100%);
|
||||
}
|
||||
|
||||
.weather-card.condition-foggy {
|
||||
background: linear-gradient(135deg, #606c88 0%, #3f4c6b 100%);
|
||||
}
|
||||
|
||||
.weather-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.weather-location h3 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.weather-location p {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.weather-icon {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.weather-main {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.temperature {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.temp-value {
|
||||
font-size: 3rem;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.temp-fahrenheit {
|
||||
font-size: 1rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.conditions {
|
||||
font-size: 1.1rem;
|
||||
text-transform: capitalize;
|
||||
margin-top: 8px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.weather-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Loading/Skeleton state */
|
||||
.weather-loading {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.2) 25%, rgba(255, 255, 255, 0.4) 50%, rgba(255, 255, 255, 0.2) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.skeleton-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.2) 25%, rgba(255, 255, 255, 0.4) 50%, rgba(255, 255, 255, 0.2) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.skeleton-temp {
|
||||
width: 80px;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.skeleton-temp-f {
|
||||
width: 60px;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.skeleton-conditions {
|
||||
width: 100px;
|
||||
height: 1.1rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.skeleton-detail {
|
||||
width: 40px;
|
||||
height: 1.1rem;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
|
||||
|
||||
/// <summary>
|
||||
/// Template for rendering create_plan function call content.
|
||||
/// The PlanCard will subscribe to events to track confirm_plan and update_plan_step calls.
|
||||
/// </summary>
|
||||
public class CreatePlanCallTemplate : ContentTemplateBase
|
||||
{
|
||||
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
|
||||
|
||||
public override void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
// This component never renders anything by itself.
|
||||
this.ChildContent = this.RenderCreatePlanCall;
|
||||
}
|
||||
|
||||
public override Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
this.Context.RegisterContentTemplate(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if this template should handle the given content.
|
||||
/// Matches FunctionCallContent for the create_plan function.
|
||||
/// </summary>
|
||||
public override bool When(ContentContext context)
|
||||
{
|
||||
// Only match FunctionCallContent for the create_plan function
|
||||
return context.Content is FunctionCallContent call &&
|
||||
string.Equals(call.Name, "create_plan", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private RenderFragment RenderCreatePlanCall(ContentContext content) => builder =>
|
||||
{
|
||||
if (content.Content is FunctionCallContent call)
|
||||
{
|
||||
// Get the invocation context which tracks both call and result
|
||||
var invocation = this.Context.GetOrCreateInvocation(call);
|
||||
|
||||
// Provide both the invocation context and message list context to PlanCard
|
||||
builder.OpenComponent<CascadingValue<InvocationContext>>(0);
|
||||
builder.AddComponentParameter(1, "Value", invocation);
|
||||
builder.AddComponentParameter(2, "IsFixed", true);
|
||||
builder.AddComponentParameter(3, "ChildContent", (RenderFragment)(innerBuilder =>
|
||||
{
|
||||
// Also cascade the MessageListContext so PlanCard can track other tool calls
|
||||
innerBuilder.OpenComponent<CascadingValue<MessageListContext>>(0);
|
||||
innerBuilder.AddComponentParameter(1, "Value", this.Context);
|
||||
innerBuilder.AddComponentParameter(2, "IsFixed", true);
|
||||
innerBuilder.AddComponentParameter(3, "ChildContent", (RenderFragment)(cardBuilder =>
|
||||
{
|
||||
// Render the PlanCard component
|
||||
cardBuilder.OpenComponent<PlanCard>(0);
|
||||
cardBuilder.CloseComponent();
|
||||
}));
|
||||
innerBuilder.CloseComponent();
|
||||
}));
|
||||
builder.CloseComponent();
|
||||
}
|
||||
};
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating agent that prepends instructions for the human-in-the-loop workflow.
|
||||
/// </summary>
|
||||
internal sealed class HumanInTheLoopAgent : DelegatingAIAgent
|
||||
{
|
||||
private static readonly ChatMessage InstructionsMessage = new(
|
||||
ChatRole.System,
|
||||
"""
|
||||
You help users create and execute plans. Follow this workflow:
|
||||
|
||||
1. When asked to create a plan, use the `create_plan` tool with a list of step descriptions.
|
||||
2. IMMEDIATELY after creating a plan, call `confirm_plan` with the plan object to ask for user approval.
|
||||
3. Wait for the user to confirm which steps they want to proceed with.
|
||||
4. Once confirmed, use `update_plan_step` to mark steps as 'completed' as you execute them.
|
||||
|
||||
IMPORTANT:
|
||||
- Always call `confirm_plan` right after `create_plan` - don't skip this step!
|
||||
- The plan parameter for `confirm_plan` should be the exact plan object returned from `create_plan`.
|
||||
- Do NOT start executing steps until the user confirms.
|
||||
- After receiving confirmation, update each selected step to 'completed' status.
|
||||
""");
|
||||
|
||||
public HumanInTheLoopAgent(AIAgent innerAgent)
|
||||
: base(innerAgent)
|
||||
{
|
||||
}
|
||||
|
||||
public override Task<AgentRunResponse> RunAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Prepend instructions message
|
||||
var messagesWithInstructions = messages.Prepend(InstructionsMessage);
|
||||
return base.RunAsync(messagesWithInstructions, thread, options, cancellationToken);
|
||||
}
|
||||
|
||||
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Prepend instructions message
|
||||
var messagesWithInstructions = messages.Prepend(InstructionsMessage);
|
||||
return base.RunStreamingAsync(messagesWithInstructions, thread, options, cancellationToken);
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using Microsoft.AspNetCore.Components.AI
|
||||
@using Microsoft.Agents.AI
|
||||
@using Microsoft.Extensions.AI
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using AGUIDojoClient.Components.Shared
|
||||
@using System.Text.Json
|
||||
@using System.ComponentModel
|
||||
@inject IServiceProvider ServiceProvider
|
||||
|
||||
<PageTitle>Human in the Loop</PageTitle>
|
||||
|
||||
<div class="chat-layout">
|
||||
<div class="chat-header">
|
||||
<div class="chat-title">Human in the Loop</div>
|
||||
<button class="new-chat-button" @onclick="ResetConversation">
|
||||
<span class="button-icon">+</span> New chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AgentBoundary Agent="@agent" OnContextCreated="OnContextCreated">
|
||||
<div class="chat-content">
|
||||
<Messages>
|
||||
<ContentTemplates>
|
||||
<CreatePlanCallTemplate />
|
||||
<TextTemplate />
|
||||
<FunctionCallTemplate />
|
||||
<FunctionResultTemplate />
|
||||
<ErrorTemplate />
|
||||
<DataContentTemplate />
|
||||
</ContentTemplates>
|
||||
</Messages>
|
||||
<AgentLoadingIndicator />
|
||||
</div>
|
||||
|
||||
<div class="chat-input-container">
|
||||
<AgentSuggestions Suggestions="@suggestions" />
|
||||
<AgentInput Placeholder="Ask for a plan..." />
|
||||
</div>
|
||||
</AgentBoundary>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private AIAgent? agent;
|
||||
private IAgentBoundaryContext? boundaryContext;
|
||||
private Plan? currentPlan;
|
||||
|
||||
private Suggestion[] suggestions = [
|
||||
new Suggestion("Simple plan", new ChatMessage(ChatRole.User, "Create a simple 5-step plan for organizing a birthday party")),
|
||||
new Suggestion("Complex plan", new ChatMessage(ChatRole.User, "Create a detailed 10-step plan for launching a new product"))
|
||||
];
|
||||
|
||||
[Parameter]
|
||||
public string ScenarioId { get; set; } = "human_in_the_loop";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("human-in-the-loop");
|
||||
}
|
||||
|
||||
private void OnContextCreated(IAgentBoundaryContext context)
|
||||
{
|
||||
boundaryContext = context;
|
||||
|
||||
// Register all three frontend tools for the human-in-the-loop scenario
|
||||
|
||||
// 1. create_plan - creates a plan and stores it
|
||||
var createPlanTool = AIFunctionFactory.Create(
|
||||
(List<string> steps) => CreatePlan(steps),
|
||||
"create_plan",
|
||||
"Create a plan with multiple steps. Call this first before confirm_plan.");
|
||||
|
||||
// 2. confirm_plan - waits for user confirmation via UI, receives the plan to display
|
||||
var confirmPlanTool = AIFunctionFactory.Create(
|
||||
(Plan plan) => ConfirmPlanAsync(context, plan),
|
||||
"confirm_plan",
|
||||
"Present the plan to the user for confirmation. The user can select which steps to proceed with. Pass the plan returned from create_plan.");
|
||||
|
||||
// 3. update_plan_step - updates a step's status
|
||||
var updatePlanStepTool = AIFunctionFactory.Create(
|
||||
(int index, string? description, string? status) => UpdatePlanStep(index, description, status),
|
||||
"update_plan_step",
|
||||
"Update a step in the plan with new description or status. Use status 'completed' to mark a step as done.");
|
||||
|
||||
context.RegisterTools(createPlanTool, confirmPlanTool, updatePlanStepTool);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frontend tool that creates a plan with the given steps.
|
||||
/// </summary>
|
||||
[Description("Create a plan with multiple steps.")]
|
||||
private Plan CreatePlan([Description("List of step descriptions to create the plan.")] List<string> steps)
|
||||
{
|
||||
currentPlan = new Plan
|
||||
{
|
||||
Steps = [.. steps.Select(s => new Step { Description = s, Status = "pending" })]
|
||||
};
|
||||
return currentPlan;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frontend tool that waits for user confirmation via the UI.
|
||||
/// The PlanCard component will call ProvideResponse when the user confirms/rejects.
|
||||
/// </summary>
|
||||
[Description("Present the plan to the user for confirmation.")]
|
||||
private static async Task<PlanConfirmationResult> ConfirmPlanAsync(
|
||||
IAgentBoundaryContext context,
|
||||
[Description("The plan to present to the user for confirmation.")] Plan plan)
|
||||
{
|
||||
// The plan parameter is received and will be accessible via InvocationContext in the PlanCard
|
||||
// Wait for the PlanCard component to provide the response
|
||||
var response = await context.WaitForResponse("confirm_plan");
|
||||
return (PlanConfirmationResult)response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frontend tool that updates a step in the plan.
|
||||
/// </summary>
|
||||
[Description("Update a step in the plan with new description or status.")]
|
||||
private List<JsonPatchOperation> UpdatePlanStep(
|
||||
[Description("The index of the step to update.")] int index,
|
||||
[Description("The new description for the step (optional).")] string? description = null,
|
||||
[Description("The new status for the step: 'pending' or 'completed'.")] string? status = null)
|
||||
{
|
||||
var changes = new List<JsonPatchOperation>();
|
||||
|
||||
if (currentPlan is null || index < 0 || index >= currentPlan.Steps.Count)
|
||||
{
|
||||
return changes;
|
||||
}
|
||||
|
||||
if (description is not null)
|
||||
{
|
||||
currentPlan.Steps[index].Description = description;
|
||||
changes.Add(new JsonPatchOperation
|
||||
{
|
||||
Op = "replace",
|
||||
Path = $"/steps/{index}/description",
|
||||
Value = description
|
||||
});
|
||||
}
|
||||
|
||||
if (status is not null)
|
||||
{
|
||||
currentPlan.Steps[index].Status = status.ToLowerInvariant();
|
||||
changes.Add(new JsonPatchOperation
|
||||
{
|
||||
Op = "replace",
|
||||
Path = $"/steps/{index}/status",
|
||||
Value = status.ToLowerInvariant()
|
||||
});
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
private void ResetConversation()
|
||||
{
|
||||
currentPlan = null;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/* Copyright (c) Microsoft. All rights reserved. */
|
||||
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.new-chat-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #d1d1d1;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #424242;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.new-chat-button:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #b3b3b3;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
background: white;
|
||||
}
|
||||
|
||||
::deep .agent-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
::deep .suggestion-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #d1d1d1;
|
||||
border-radius: 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #424242;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
::deep .suggestion-button:hover {
|
||||
background: #f0f0f0;
|
||||
border-color: #0078d4;
|
||||
color: #0078d4;
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a JSON Patch operation.
|
||||
/// </summary>
|
||||
public sealed class JsonPatchOperation
|
||||
{
|
||||
/// <summary>
|
||||
/// The operation type (e.g., "replace", "add", "remove").
|
||||
/// </summary>
|
||||
[JsonPropertyName("op")]
|
||||
public string Op { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The JSON Pointer path to the target location.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The value for the operation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("value")]
|
||||
public object? Value { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a plan with multiple steps.
|
||||
/// </summary>
|
||||
public sealed class Plan
|
||||
{
|
||||
/// <summary>
|
||||
/// The list of steps in the plan.
|
||||
/// </summary>
|
||||
[JsonPropertyName("steps")]
|
||||
public List<Step> Steps { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of completed steps.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public int CompletedCount => this.Steps.Count(s => s.IsCompleted);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of steps.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public int TotalCount => this.Steps.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether all steps are completed.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsComplete => this.Steps.Count > 0 && this.Steps.All(s => s.IsCompleted);
|
||||
}
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using Microsoft.AspNetCore.Components.AI
|
||||
@using Microsoft.Extensions.AI
|
||||
@using System.Text.Json
|
||||
@implements IDisposable
|
||||
|
||||
@if (IsWaitingForPlan)
|
||||
{
|
||||
<div class="plan-card plan-loading">
|
||||
<div class="plan-header">
|
||||
<h3>Loading Plan...</h3>
|
||||
<div class="plan-progress">
|
||||
<span class="skeleton-text skeleton-progress"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plan-steps">
|
||||
@for (int i = 0; i < 3; i++)
|
||||
{
|
||||
<div class="plan-step skeleton-step">
|
||||
<span class="step-checkbox skeleton-checkbox"></span>
|
||||
<span class="step-description skeleton-text skeleton-description"></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="plan-actions">
|
||||
<button class="plan-button plan-button-skeleton" disabled>Loading...</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (CurrentPlan is not null && AwaitingConfirmation)
|
||||
{
|
||||
<div class="plan-card plan-active">
|
||||
<div class="plan-header">
|
||||
<h3>Plan Confirmation</h3>
|
||||
<div class="plan-progress">
|
||||
<span class="progress-count">@CurrentPlan.CompletedCount / @CurrentPlan.TotalCount completed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plan-steps">
|
||||
@for (int i = 0; i < CurrentPlan.Steps.Count; i++)
|
||||
{
|
||||
var step = CurrentPlan.Steps[i];
|
||||
var index = i;
|
||||
<div class="plan-step @(step.IsCompleted ? "step-completed" : "step-pending") @(selectedSteps.Contains(index) ? "step-selected" : "")">
|
||||
<label class="step-checkbox-label">
|
||||
<input type="checkbox"
|
||||
class="step-checkbox"
|
||||
checked="@selectedSteps.Contains(index)"
|
||||
disabled="@step.IsCompleted"
|
||||
@onchange="() => ToggleStepSelection(index)" />
|
||||
<span class="checkmark @(step.IsCompleted || selectedSteps.Contains(index) ? "checkmark-completed" : "")">
|
||||
@if (step.IsCompleted || selectedSteps.Contains(index))
|
||||
{
|
||||
<span class="check-icon">✓</span>
|
||||
}
|
||||
</span>
|
||||
</label>
|
||||
<span class="step-description @(step.IsCompleted ? "description-completed" : "")">@step.Description</span>
|
||||
<span class="step-status @(step.IsCompleted ? "status-completed" : "status-pending")">
|
||||
@(step.IsCompleted ? "Done" : "Pending")
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="plan-actions">
|
||||
<button class="plan-button plan-button-confirm" @onclick="ConfirmPlan" disabled="@(selectedSteps.Count == 0)">
|
||||
Confirm Selected (@selectedSteps.Count)
|
||||
</button>
|
||||
<button class="plan-button plan-button-reject" @onclick="RejectPlan">
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (CurrentPlan is not null && !AwaitingConfirmation && !WasRejected)
|
||||
{
|
||||
<div class="plan-card @(CurrentPlan.IsComplete ? "plan-completed" : "plan-executing")">
|
||||
<div class="plan-header">
|
||||
<h3>@(CurrentPlan.IsComplete ? "Plan Completed" : "Executing Plan")</h3>
|
||||
@if (CurrentPlan.IsComplete)
|
||||
{
|
||||
<span class="completion-badge">✓ All Done</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="plan-progress">
|
||||
<span class="progress-count">@CurrentPlan.CompletedCount / @CurrentPlan.TotalCount completed</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="plan-steps">
|
||||
@foreach (var step in CurrentPlan.Steps)
|
||||
{
|
||||
<div class="plan-step @(step.IsCompleted ? "step-completed" : "step-pending")">
|
||||
<span class="checkmark @(step.IsCompleted ? "checkmark-completed" : "")">
|
||||
@if (step.IsCompleted)
|
||||
{
|
||||
<span class="check-icon">✓</span>
|
||||
}
|
||||
</span>
|
||||
<span class="step-description @(step.IsCompleted ? "description-completed" : "")">@step.Description</span>
|
||||
<span class="step-status @(step.IsCompleted ? "status-completed" : "status-pending")">
|
||||
@(step.IsCompleted ? "Done" : "Pending")
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (WasRejected)
|
||||
{
|
||||
<div class="plan-card plan-rejected">
|
||||
<div class="plan-header">
|
||||
<h3>Plan Rejected</h3>
|
||||
<span class="rejection-badge">✗ Cancelled</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
public InvocationContext Invocation { get; set; } = default!;
|
||||
|
||||
[CascadingParameter]
|
||||
public MessageListContext? MessageListContext { get; set; }
|
||||
|
||||
[CascadingParameter]
|
||||
public IAgentBoundaryContext? BoundaryContext { get; set; }
|
||||
|
||||
private Plan? CurrentPlan;
|
||||
private HashSet<int> selectedSteps = new();
|
||||
private bool AwaitingConfirmation;
|
||||
private bool WasRejected;
|
||||
private bool IsWaitingForPlan => CurrentPlan is null && !WasRejected;
|
||||
private ResponseUpdateSubscription? _responseSubscription;
|
||||
private string? _confirmPlanCallId;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// The plan comes from the create_plan function result
|
||||
if (Invocation?.HasResult == true)
|
||||
{
|
||||
TryParsePlanFromResult();
|
||||
}
|
||||
else if (Invocation is not null)
|
||||
{
|
||||
// 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)
|
||||
{
|
||||
_responseSubscription = BoundaryContext.SubscribeToResponseUpdates(OnResponseUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCreatePlanResultArrived()
|
||||
{
|
||||
TryParsePlanFromResult();
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void TryParsePlanFromResult()
|
||||
{
|
||||
var plan = Invocation?.GetResult<Plan>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnResponseUpdate()
|
||||
{
|
||||
// Check the current update for tool calls
|
||||
var update = BoundaryContext?.CurrentUpdate;
|
||||
if (update is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.Contents is not null)
|
||||
{
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent call)
|
||||
{
|
||||
if (string.Equals(call.Name, "confirm_plan", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
HandleConfirmPlanCall(call);
|
||||
}
|
||||
else if (string.Equals(call.Name, "update_plan_step", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
HandleUpdatePlanStepCall(call);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void HandleConfirmPlanCall(FunctionCallContent call)
|
||||
{
|
||||
// When confirm_plan is called, show the confirmation UI
|
||||
if (!AwaitingConfirmation && !WasRejected && _confirmPlanCallId is null)
|
||||
{
|
||||
_confirmPlanCallId = call.CallId;
|
||||
AwaitingConfirmation = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleUpdatePlanStepCall(FunctionCallContent call)
|
||||
{
|
||||
if (CurrentPlan is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the index and status from arguments
|
||||
if (call.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();
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleStepSelection(int index)
|
||||
{
|
||||
if (selectedSteps.Contains(index))
|
||||
{
|
||||
selectedSteps.Remove(index);
|
||||
}
|
||||
else
|
||||
{
|
||||
selectedSteps.Add(index);
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ConfirmPlan()
|
||||
{
|
||||
AwaitingConfirmation = false;
|
||||
var result = new PlanConfirmationResult
|
||||
{
|
||||
Confirmed = true,
|
||||
SelectedStepIndices = selectedSteps.ToList()
|
||||
};
|
||||
BoundaryContext?.ProvideResponse("confirm_plan", result);
|
||||
}
|
||||
|
||||
private void RejectPlan()
|
||||
{
|
||||
WasRejected = true;
|
||||
AwaitingConfirmation = false;
|
||||
var result = new PlanConfirmationResult
|
||||
{
|
||||
Confirmed = false,
|
||||
SelectedStepIndices = []
|
||||
};
|
||||
BoundaryContext?.ProvideResponse("confirm_plan", result);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Invocation is not null)
|
||||
{
|
||||
Invocation.ResultArrived -= OnCreatePlanResultArrived;
|
||||
}
|
||||
_responseSubscription?.Dispose();
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
.plan-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
color: white;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 10px 40px rgba(102, 126, 234, 0.4);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.plan-card.plan-active {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.plan-card.plan-completed {
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
}
|
||||
|
||||
.plan-card.plan-rejected {
|
||||
background: linear-gradient(135deg, #f5576c 0%, #f093fb 100%);
|
||||
}
|
||||
|
||||
.plan-card.plan-loading {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.plan-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.plan-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plan-progress .progress-count {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.completion-badge,
|
||||
.rejection-badge {
|
||||
font-size: 0.9rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.completion-badge {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.rejection-badge {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.plan-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.plan-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.plan-step.step-selected {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.plan-step.step-completed {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.step-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.step-checkbox {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-checkbox:checked + .checkmark {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
.checkmark-completed {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.step-description {
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.description-completed {
|
||||
opacity: 0.7;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.step-status {
|
||||
font-size: 0.75rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.plan-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.plan-button {
|
||||
flex: 1;
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.plan-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.plan-button-confirm {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.plan-button-confirm:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.plan-button-reject {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.plan-button-reject:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.plan-button-skeleton {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Loading/Skeleton state */
|
||||
.skeleton-text {
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.2) 25%, rgba(255, 255, 255, 0.4) 50%, rgba(255, 255, 255, 0.2) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.skeleton-progress {
|
||||
width: 100px;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.skeleton-step {
|
||||
padding: 14px 12px;
|
||||
}
|
||||
|
||||
.skeleton-checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.2) 25%, rgba(255, 255, 255, 0.4) 50%, rgba(255, 255, 255, 0.2) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-description {
|
||||
width: 200px;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
|
||||
|
||||
/// <summary>
|
||||
/// Result of user's plan confirmation decision.
|
||||
/// </summary>
|
||||
public class PlanConfirmationResult
|
||||
{
|
||||
public bool Confirmed { get; set; }
|
||||
public List<int> SelectedStepIndices { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
|
||||
|
||||
/// <summary>
|
||||
/// Applies JSON Patch operations to a Plan.
|
||||
/// Uses hardcoded path parsing for the expected paths:
|
||||
/// - /steps/{index}/status
|
||||
/// - /steps/{index}/description
|
||||
/// </summary>
|
||||
public static partial class PlanPatcher
|
||||
{
|
||||
// Regex to match paths like /steps/0/status or /steps/1/description
|
||||
[GeneratedRegex(@"^/steps/(\d+)/(status|description)$")]
|
||||
private static partial Regex StepPropertyPathRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Applies a JSON Patch operation to the plan.
|
||||
/// Only supports "replace" operations on /steps/{index}/status and /steps/{index}/description paths.
|
||||
/// </summary>
|
||||
/// <param name="plan">The plan to modify.</param>
|
||||
/// <param name="operation">The patch operation to apply.</param>
|
||||
/// <returns>True if the operation was applied successfully, false otherwise.</returns>
|
||||
public static bool ApplyPatch(Plan plan, JsonPatchOperation operation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
ArgumentNullException.ThrowIfNull(operation);
|
||||
|
||||
// Only support "replace" operations
|
||||
if (!string.Equals(operation.Op, "replace", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var match = StepPropertyPathRegex().Match(operation.Path);
|
||||
if (!match.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!int.TryParse(match.Groups[1].Value, out int stepIndex))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (stepIndex < 0 || stepIndex >= plan.Steps.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var propertyName = match.Groups[2].Value;
|
||||
var step = plan.Steps[stepIndex];
|
||||
|
||||
switch (propertyName.ToUpperInvariant())
|
||||
{
|
||||
case "STATUS":
|
||||
step.Status = operation.Value?.ToString() ?? "pending";
|
||||
return true;
|
||||
case "DESCRIPTION":
|
||||
step.Description = operation.Value?.ToString() ?? string.Empty;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies multiple JSON Patch operations to the plan.
|
||||
/// </summary>
|
||||
/// <param name="plan">The plan to modify.</param>
|
||||
/// <param name="operations">The patch operations to apply.</param>
|
||||
/// <returns>The number of operations successfully applied.</returns>
|
||||
public static int ApplyPatches(Plan plan, IEnumerable<JsonPatchOperation> operations)
|
||||
{
|
||||
int appliedCount = 0;
|
||||
foreach (var operation in operations)
|
||||
{
|
||||
if (ApplyPatch(plan, operation))
|
||||
{
|
||||
appliedCount++;
|
||||
}
|
||||
}
|
||||
return appliedCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single step in a plan.
|
||||
/// </summary>
|
||||
public sealed class Step
|
||||
{
|
||||
/// <summary>
|
||||
/// The description of the step.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The status of the step (pending or completed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this step is completed.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsCompleted => string.Equals(this.Status, "completed", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.PredictiveStateUpdates;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of the confirm_changes frontend tool.
|
||||
/// </summary>
|
||||
public sealed class ConfirmChangesResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the user confirmed the changes.
|
||||
/// </summary>
|
||||
public bool Confirmed { get; set; }
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.PredictiveStateUpdates;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the document state for the Predictive State Updates demo.
|
||||
/// This model mirrors the server-side DocumentState and is updated via streaming state updates.
|
||||
/// </summary>
|
||||
public sealed class DocumentState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the document content in Markdown format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("document")]
|
||||
public string Document { get; set; } = string.Empty;
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
@* 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
|
||||
@implements IDisposable
|
||||
@inject IServiceProvider ServiceProvider
|
||||
|
||||
<PageTitle>AI Document Editor</PageTitle>
|
||||
|
||||
<div class="predictive-state-layout">
|
||||
@* Left panel: Document editor *@
|
||||
<div class="document-panel">
|
||||
<div class="document-header">
|
||||
<h2>Document Editor</h2>
|
||||
@if (isStreaming)
|
||||
{
|
||||
<span class="streaming-indicator">
|
||||
<span class="streaming-dot"></span>
|
||||
Writing...
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="document-content">
|
||||
@if (string.IsNullOrWhiteSpace(currentDocument))
|
||||
{
|
||||
<div class="document-placeholder">
|
||||
Write whatever you want here in Markdown format...
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<pre class="document-text">@currentDocument</pre>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Right panel: Chat sidebar *@
|
||||
<div class="chat-panel">
|
||||
<div class="chat-header">
|
||||
<div class="chat-title">AI Document Editor</div>
|
||||
<button class="new-chat-button" @onclick="ResetConversation">
|
||||
<span class="button-icon">+</span> New chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AgentBoundary Agent="@agent" OnContextCreated="OnContextCreated">
|
||||
<div class="chat-content">
|
||||
<Messages>
|
||||
<ContentTemplates>
|
||||
<TextTemplate />
|
||||
<FunctionCallTemplate />
|
||||
<FunctionResultTemplate />
|
||||
<ErrorTemplate />
|
||||
<DataContentTemplate />
|
||||
</ContentTemplates>
|
||||
</Messages>
|
||||
<AgentLoadingIndicator />
|
||||
</div>
|
||||
|
||||
<AgentState TState="DocumentState"
|
||||
CurrentState="@currentDocumentState"
|
||||
OnSnapshot="@DeserializeDocumentState"
|
||||
CurrentStateChanged="@OnDocumentStateChanged" />
|
||||
|
||||
<div class="chat-input-container">
|
||||
<AgentSuggestions Suggestions="@suggestions" />
|
||||
<AgentInput Placeholder="Ask the AI to write or edit..." />
|
||||
</div>
|
||||
</AgentBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Confirmation modal *@
|
||||
@if (awaitingConfirmation)
|
||||
{
|
||||
<div class="confirmation-overlay">
|
||||
<div class="confirmation-modal">
|
||||
<h3>Confirm Changes</h3>
|
||||
<p>Do you want to accept the changes?</p>
|
||||
<div class="confirmation-actions">
|
||||
<button class="confirm-button reject" @onclick="RejectChanges">
|
||||
Reject
|
||||
</button>
|
||||
<button class="confirm-button accept" @onclick="ConfirmChanges">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private AIAgent? agent;
|
||||
private IAgentBoundaryContext? boundaryContext;
|
||||
private ResponseUpdateSubscription? responseSubscription;
|
||||
|
||||
private DocumentState? currentDocumentState;
|
||||
private string currentDocument = string.Empty;
|
||||
private string previousDocument = string.Empty;
|
||||
private bool isStreaming;
|
||||
private bool awaitingConfirmation;
|
||||
|
||||
private Suggestion[] suggestions = [
|
||||
new Suggestion("Write a pirate story", new ChatMessage(ChatRole.User, "Please write a story about a pirate named Candy Beard")),
|
||||
new Suggestion("Write a mermaid story", new ChatMessage(ChatRole.User, "Please write a story about a mermaid named Pearl")),
|
||||
new Suggestion("Add character", new ChatMessage(ChatRole.User, "Add a new character to the story"))
|
||||
];
|
||||
|
||||
[Parameter]
|
||||
public string ScenarioId { get; set; } = "predictive_state_updates";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("predictive-state-updates");
|
||||
}
|
||||
|
||||
private void OnContextCreated(IAgentBoundaryContext context)
|
||||
{
|
||||
boundaryContext = context;
|
||||
|
||||
// Register the confirm_changes frontend tool
|
||||
var confirmChangesTool = AIFunctionFactory.Create(
|
||||
() => ConfirmChangesAsync(context),
|
||||
"confirm_changes",
|
||||
"Ask the user to confirm or reject the document changes.");
|
||||
|
||||
context.RegisterTools(confirmChangesTool);
|
||||
|
||||
// Subscribe to response updates to detect when streaming starts/stops
|
||||
responseSubscription = context.SubscribeToResponseUpdates(OnResponseUpdate);
|
||||
}
|
||||
|
||||
private void OnResponseUpdate()
|
||||
{
|
||||
var update = boundaryContext?.CurrentUpdate;
|
||||
if (update is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for function calls to detect confirm_changes
|
||||
if (update.Contents is not null)
|
||||
{
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent call &&
|
||||
string.Equals(call.Name, "confirm_changes", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Show confirmation dialog when confirm_changes is called
|
||||
awaitingConfirmation = true;
|
||||
isStreaming = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private DocumentState? DeserializeDocumentState(ReadOnlyMemory<byte> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<DocumentState>(data.Span);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDocumentStateChanged(DocumentState? state)
|
||||
{
|
||||
if (state is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
currentDocumentState = state;
|
||||
|
||||
// Store the previous document before updating (for reject functionality)
|
||||
if (string.IsNullOrEmpty(previousDocument) && !string.IsNullOrEmpty(currentDocument))
|
||||
{
|
||||
previousDocument = currentDocument;
|
||||
}
|
||||
|
||||
// Check if we're starting to stream (document changed)
|
||||
if (currentDocument != state.Document)
|
||||
{
|
||||
isStreaming = true;
|
||||
}
|
||||
|
||||
currentDocument = state.Document;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frontend tool that waits for user confirmation via the UI.
|
||||
/// </summary>
|
||||
[Description("Ask the user to confirm or reject the document changes.")]
|
||||
private static async Task<ConfirmChangesResult> ConfirmChangesAsync(IAgentBoundaryContext context)
|
||||
{
|
||||
// Wait for the user to click Confirm or Reject
|
||||
var response = await context.WaitForResponse("confirm_changes");
|
||||
return (ConfirmChangesResult)response;
|
||||
}
|
||||
|
||||
private void ConfirmChanges()
|
||||
{
|
||||
awaitingConfirmation = false;
|
||||
isStreaming = false;
|
||||
|
||||
// Update the previous document to the current one (changes accepted)
|
||||
previousDocument = currentDocument;
|
||||
|
||||
boundaryContext?.ProvideResponse("confirm_changes", new ConfirmChangesResult { Confirmed = true });
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void RejectChanges()
|
||||
{
|
||||
awaitingConfirmation = false;
|
||||
isStreaming = false;
|
||||
|
||||
// Revert to the previous document
|
||||
currentDocument = previousDocument;
|
||||
|
||||
boundaryContext?.ProvideResponse("confirm_changes", new ConfirmChangesResult { Confirmed = false });
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ResetConversation()
|
||||
{
|
||||
currentDocument = string.Empty;
|
||||
previousDocument = string.Empty;
|
||||
currentDocumentState = null;
|
||||
isStreaming = false;
|
||||
awaitingConfirmation = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
responseSubscription?.Dispose();
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
/* 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 .agent-suggestions {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
::deep .agent-suggestions .suggestion-button {
|
||||
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 .agent-suggestions .suggestion-button:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
::deep .agent-suggestions .suggestion-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.SharedState;
|
||||
|
||||
public sealed class Ingredient
|
||||
{
|
||||
[JsonPropertyName("icon")]
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("amount")]
|
||||
public string Amount { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.SharedState;
|
||||
|
||||
public sealed class Recipe
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("skill_level")]
|
||||
public string SkillLevel { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cooking_time")]
|
||||
public string CookingTime { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("special_preferences")]
|
||||
public List<string> SpecialPreferences { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("ingredients")]
|
||||
public List<Ingredient> Ingredients { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("instructions")]
|
||||
public List<string> Instructions { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.SharedState;
|
||||
|
||||
public sealed class RecipeResponse
|
||||
{
|
||||
[JsonPropertyName("recipe")]
|
||||
public Recipe Recipe { get; set; } = new();
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using Microsoft.AspNetCore.Components.AI
|
||||
@using Microsoft.Agents.AI
|
||||
@using Microsoft.Extensions.AI
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using AGUIDojoClient.Components.Shared
|
||||
@using System.Text.Json
|
||||
@inject IServiceProvider ServiceProvider
|
||||
|
||||
<PageTitle>Shared State</PageTitle>
|
||||
|
||||
<div class="shared-state-layout">
|
||||
<AgentBoundary Agent="@agent" OnContextCreated="@OnContextCreated">
|
||||
<AgentState TState="Recipe"
|
||||
CurrentState="@currentRecipe"
|
||||
OnSnapshot="@DeserializeRecipe"
|
||||
CurrentStateChanged="@OnRecipeChanged">
|
||||
<div class="recipe-panel-container">
|
||||
<div class="recipe-panel">
|
||||
<div class="recipe-header">
|
||||
<input type="text" class="recipe-title" @bind="currentRecipe.Title" @bind:event="oninput" placeholder="Recipe name" />
|
||||
<div class="recipe-meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-icon">🕒</span>
|
||||
<select @bind="currentRecipe.CookingTime">
|
||||
<option value="5 min">5 min</option>
|
||||
<option value="15 min">15 min</option>
|
||||
<option value="30 min">30 min</option>
|
||||
<option value="45 min">45 min</option>
|
||||
<option value="60+ min">60+ min</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-icon">🏆</span>
|
||||
<select @bind="currentRecipe.SkillLevel">
|
||||
<option value="Beginner">Beginner</option>
|
||||
<option value="Intermediate">Intermediate</option>
|
||||
<option value="Advanced">Advanced</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preferences-section">
|
||||
<h2>Dietary Preferences</h2>
|
||||
<div class="preferences-grid">
|
||||
@foreach (var pref in dietaryPreferences)
|
||||
{
|
||||
<label class="preference-item">
|
||||
<input type="checkbox"
|
||||
checked="@currentRecipe.SpecialPreferences.Contains(pref)"
|
||||
@onchange="@(e => TogglePreference(pref, (bool?)e.Value ?? false))" />
|
||||
<span>@pref</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ingredients-section">
|
||||
<div class="section-header">
|
||||
<h2>Ingredients</h2>
|
||||
<button class="add-button" @onclick="AddIngredient">+ Add Ingredient</button>
|
||||
</div>
|
||||
<div class="ingredients-list">
|
||||
@for (int i = 0; i < currentRecipe.Ingredients.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
var ingredient = currentRecipe.Ingredients[index];
|
||||
<div class="ingredient-row">
|
||||
<span class="ingredient-icon">@ingredient.Icon</span>
|
||||
<div class="ingredient-inputs">
|
||||
<input type="text" placeholder="Ingredient name"
|
||||
value="@ingredient.Name"
|
||||
@onchange="@(e => UpdateIngredientName(index, e.Value?.ToString() ?? ""))" />
|
||||
<input type="text" placeholder="Amount"
|
||||
value="@ingredient.Amount"
|
||||
@onchange="@(e => UpdateIngredientAmount(index, e.Value?.ToString() ?? ""))" />
|
||||
</div>
|
||||
<button class="remove-button" @onclick="@(() => RemoveIngredient(index))">×</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="instructions-section">
|
||||
<div class="section-header">
|
||||
<h2>Instructions</h2>
|
||||
<button class="add-button" @onclick="AddInstruction">+ Add Step</button>
|
||||
</div>
|
||||
<div class="instructions-list">
|
||||
@for (int i = 0; i < currentRecipe.Instructions.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
<div class="instruction-row">
|
||||
<span class="step-number">@(index + 1)</span>
|
||||
<div class="instruction-input-wrapper">
|
||||
<input type="text"
|
||||
value="@currentRecipe.Instructions[index]"
|
||||
@onchange="@(e => UpdateInstruction(index, e.Value?.ToString() ?? ""))" />
|
||||
<button class="remove-button" @onclick="@(() => RemoveInstruction(index))">×</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="improve-button" @onclick="ImproveWithAI" disabled="@isProcessing">
|
||||
@(isProcessing ? "Please Wait..." : "Improve with AI")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-panel">
|
||||
<div class="chat-header">
|
||||
<span class="chat-title">AI Recipe Assistant</span>
|
||||
</div>
|
||||
<div class="chat-messages">
|
||||
<Messages>
|
||||
<ContentTemplates>
|
||||
<TextTemplate />
|
||||
<FunctionCallTemplate />
|
||||
<FunctionResultTemplate />
|
||||
<ErrorTemplate />
|
||||
<DataContentTemplate />
|
||||
</ContentTemplates>
|
||||
</Messages>
|
||||
<AgentLoadingIndicator />
|
||||
</div>
|
||||
<div class="chat-input-container">
|
||||
<AgentInput Placeholder="Ask about your recipe..." />
|
||||
</div>
|
||||
</div>
|
||||
</AgentState>
|
||||
</AgentBoundary>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private AIAgent? agent;
|
||||
private IAgentBoundaryContext? boundaryContext;
|
||||
private Recipe currentRecipe = CreateDefaultRecipe();
|
||||
private bool isProcessing;
|
||||
|
||||
private static readonly string[] dietaryPreferences = [
|
||||
"High Protein", "Low Carb", "Spicy", "Budget-Friendly",
|
||||
"One-Pot Meal", "Vegetarian", "Vegan"
|
||||
];
|
||||
|
||||
[Parameter]
|
||||
public string ScenarioId { get; set; } = "shared_state";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("shared-state");
|
||||
}
|
||||
|
||||
private void OnContextCreated(IAgentBoundaryContext context)
|
||||
{
|
||||
boundaryContext = context;
|
||||
}
|
||||
|
||||
private static Recipe CreateDefaultRecipe() => new()
|
||||
{
|
||||
Title = "Make Your Recipe",
|
||||
SkillLevel = "Intermediate",
|
||||
CookingTime = "45 min",
|
||||
SpecialPreferences = [],
|
||||
Ingredients =
|
||||
[
|
||||
new Ingredient { Icon = "🥕", Name = "Carrots", Amount = "3 large, grated" },
|
||||
new Ingredient { Icon = "🌾", Name = "All-Purpose Flour", Amount = "2 cups" }
|
||||
],
|
||||
Instructions =
|
||||
[
|
||||
"Preheat oven to 350°F (175°C)"
|
||||
]
|
||||
};
|
||||
|
||||
private Recipe? DeserializeRecipe(ReadOnlyMemory<byte> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = JsonSerializer.Deserialize<RecipeResponse>(data.Span);
|
||||
return response?.Recipe;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRecipeChanged(Recipe? recipe)
|
||||
{
|
||||
if (recipe is not null)
|
||||
{
|
||||
currentRecipe = recipe;
|
||||
isProcessing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ImproveWithAI()
|
||||
{
|
||||
if (boundaryContext is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing = true;
|
||||
StateHasChanged();
|
||||
|
||||
// Serialize current recipe state with wrapper
|
||||
var stateWrapper = new { recipe = currentRecipe };
|
||||
byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes(stateWrapper, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
});
|
||||
|
||||
// Create message with state attached as DataContent
|
||||
var message = new ChatMessage(ChatRole.User,
|
||||
[
|
||||
new TextContent("Improve the recipe"),
|
||||
new DataContent(stateBytes, "application/json")
|
||||
]);
|
||||
|
||||
await boundaryContext.SendAsync(message);
|
||||
}
|
||||
|
||||
private void TogglePreference(string preference, bool isChecked)
|
||||
{
|
||||
if (isChecked && !currentRecipe.SpecialPreferences.Contains(preference))
|
||||
{
|
||||
currentRecipe.SpecialPreferences.Add(preference);
|
||||
}
|
||||
else if (!isChecked)
|
||||
{
|
||||
currentRecipe.SpecialPreferences.Remove(preference);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddIngredient()
|
||||
{
|
||||
currentRecipe.Ingredients.Add(new Ingredient { Icon = "🥄", Name = "", Amount = "" });
|
||||
}
|
||||
|
||||
private void RemoveIngredient(int index)
|
||||
{
|
||||
if (index >= 0 && index < currentRecipe.Ingredients.Count)
|
||||
{
|
||||
currentRecipe.Ingredients.RemoveAt(index);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateIngredientName(int index, string name)
|
||||
{
|
||||
if (index >= 0 && index < currentRecipe.Ingredients.Count)
|
||||
{
|
||||
currentRecipe.Ingredients[index].Name = name;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateIngredientAmount(int index, string amount)
|
||||
{
|
||||
if (index >= 0 && index < currentRecipe.Ingredients.Count)
|
||||
{
|
||||
currentRecipe.Ingredients[index].Amount = amount;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddInstruction()
|
||||
{
|
||||
currentRecipe.Instructions.Add("");
|
||||
}
|
||||
|
||||
private void RemoveInstruction(int index)
|
||||
{
|
||||
if (index >= 0 && index < currentRecipe.Instructions.Count)
|
||||
{
|
||||
currentRecipe.Instructions.RemoveAt(index);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateInstruction(int index, string instruction)
|
||||
{
|
||||
if (index >= 0 && index < currentRecipe.Instructions.Count)
|
||||
{
|
||||
currentRecipe.Instructions[index] = instruction;
|
||||
}
|
||||
}
|
||||
}
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
/* Shared State Demo Layout */
|
||||
.shared-state-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
gap: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* Recipe Panel Container - centers the card */
|
||||
.recipe-panel-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Recipe Panel - the actual card */
|
||||
.recipe-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
/* Recipe Header */
|
||||
.recipe-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.recipe-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: #111827;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recipe-title:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.recipe-title::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.recipe-meta {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.meta-item select {
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
appearance: auto;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.meta-item select:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #f97316;
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Dietary Preferences */
|
||||
.preferences-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, auto);
|
||||
gap: 8px 32px;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.preference-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preference-item input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: #3b82f6;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Ingredients */
|
||||
.ingredients-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ingredient-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background-color: white;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 10px;
|
||||
min-width: 0;
|
||||
flex: 1 1 calc(33.333% - 8px);
|
||||
max-width: calc(33.333% - 8px);
|
||||
position: relative;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.ingredient-row:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Hide ingredient remove button by default, show on hover */
|
||||
.ingredient-row .remove-button {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.ingredient-row:hover .remove-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ingredient-icon {
|
||||
font-size: 20px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background-color: #fef3e2;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.ingredient-inputs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ingredient-inputs input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ingredient-inputs input:first-child {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.ingredient-inputs input:last-child {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.ingredient-inputs input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ingredient-inputs input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Instructions */
|
||||
.instructions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* Vertical connecting line */
|
||||
.instructions-list::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 23px;
|
||||
top: 20px;
|
||||
bottom: 20px;
|
||||
width: 2px;
|
||||
border-left: 2px dashed #fdba74;
|
||||
}
|
||||
|
||||
.instruction-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f97316;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 8px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Instruction input wrapper for positioning delete button inside */
|
||||
.instruction-input-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.instruction-input-wrapper input,
|
||||
.instruction-input-wrapper textarea {
|
||||
width: 100%;
|
||||
padding: 8px 32px 8px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
resize: vertical;
|
||||
min-height: 40px;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.instruction-input-wrapper input:focus,
|
||||
.instruction-input-wrapper textarea:focus {
|
||||
outline: none;
|
||||
border-color: #f97316;
|
||||
}
|
||||
|
||||
/* Instruction remove button - hidden by default, shown on hover, positioned inside textarea */
|
||||
.instruction-input-wrapper .remove-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.instruction-input-wrapper:hover .remove-button,
|
||||
.instruction-input-wrapper:focus-within .remove-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.add-button {
|
||||
padding: 8px 16px;
|
||||
background-color: transparent;
|
||||
border: 1px solid #f97316;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #f97316;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
background-color: #fff7ed;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.improve-button {
|
||||
padding: 14px 32px;
|
||||
background-color: #f97316;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
align-self: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.improve-button:hover:not(:disabled) {
|
||||
background-color: #ea580c;
|
||||
}
|
||||
|
||||
.improve-button:disabled {
|
||||
background-color: #fdba74;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Chat Panel */
|
||||
.chat-panel {
|
||||
width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: white;
|
||||
border-left: 1px solid #e5e7eb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.ToolBasedGenerativeUI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a haiku with Japanese and English translations, along with display properties.
|
||||
/// </summary>
|
||||
public class Haiku
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the three lines of the haiku in Japanese.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Japanese { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the three lines of the haiku translated to English.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> English { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the image associated with the haiku.
|
||||
/// </summary>
|
||||
public string? ImageName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the CSS gradient for the haiku card background.
|
||||
/// </summary>
|
||||
public string Gradient { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// List of valid image names that can be used with haikus.
|
||||
/// </summary>
|
||||
public static readonly string[] ValidImageNames =
|
||||
[
|
||||
"Osaka_Castle_Turret_Stone_Wall_Pine_Trees_Daytime.jpg",
|
||||
"Tokyo_Skyline_Night_Tokyo_Tower_Mount_Fuji_View.jpg",
|
||||
"Itsukushima_Shrine_Miyajima_Floating_Torii_Gate_Sunset_Long_Exposure.jpg",
|
||||
"Takachiho_Gorge_Waterfall_River_Lush_Greenery_Japan.jpg",
|
||||
"Bonsai_Tree_Potted_Japanese_Art_Green_Foliage.jpeg",
|
||||
"Shirakawa-go_Gassho-zukuri_Thatched_Roof_Village_Aerial_View.jpg",
|
||||
"Ginkaku-ji_Silver_Pavilion_Kyoto_Japanese_Garden_Pond_Reflection.jpg",
|
||||
"Senso-ji_Temple_Asakusa_Cherry_Blossoms_Kimono_Umbrella.jpg",
|
||||
"Cherry_Blossoms_Sakura_Night_View_City_Lights_Japan.jpg",
|
||||
"Mount_Fuji_Lake_Reflection_Cherry_Blossoms_Sakura_Spring.jpg"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default placeholder haiku.
|
||||
/// </summary>
|
||||
public static Haiku CreatePlaceholder() => new()
|
||||
{
|
||||
Japanese = ["仮の句よ", "まっさらながら", "花を呼ぶ"],
|
||||
English = ["A placeholder verse—", "even in a blank canvas,", "it beckons flowers."],
|
||||
ImageName = null,
|
||||
Gradient = string.Empty
|
||||
};
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.ToolBasedGenerativeUI;
|
||||
|
||||
/// <summary>
|
||||
/// Template for rendering generate_haiku function call content.
|
||||
/// Renders a HaikuCard component inline in the chat messages.
|
||||
/// </summary>
|
||||
public class HaikuCallTemplate : ContentTemplateBase
|
||||
{
|
||||
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
|
||||
|
||||
public override void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
// This component never renders anything by itself.
|
||||
this.ChildContent = this.RenderHaikuCall;
|
||||
}
|
||||
|
||||
public override Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
this.Context.RegisterContentTemplate(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if this template should handle the given content.
|
||||
/// Matches FunctionCallContent for the generate_haiku function.
|
||||
/// </summary>
|
||||
public override bool When(ContentContext context)
|
||||
{
|
||||
// Only match FunctionCallContent for the generate_haiku function
|
||||
return context.Content is FunctionCallContent call &&
|
||||
string.Equals(call.Name, "generate_haiku", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private RenderFragment RenderHaikuCall(ContentContext content) => builder =>
|
||||
{
|
||||
if (content.Content is FunctionCallContent call)
|
||||
{
|
||||
// Get the invocation context which tracks both call and result
|
||||
var invocation = this.Context.GetOrCreateInvocation(call);
|
||||
|
||||
// Provide the invocation context to child components
|
||||
builder.OpenComponent<CascadingValue<InvocationContext>>(0);
|
||||
builder.AddComponentParameter(1, "Value", invocation);
|
||||
builder.AddComponentParameter(2, "IsFixed", true);
|
||||
builder.AddComponentParameter(3, "ChildContent", (RenderFragment)(innerBuilder =>
|
||||
{
|
||||
// Render the HaikuCard component which uses InvocationContext
|
||||
innerBuilder.OpenComponent<HaikuCard>(0);
|
||||
innerBuilder.CloseComponent();
|
||||
}));
|
||||
builder.CloseComponent();
|
||||
}
|
||||
};
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using Microsoft.AspNetCore.Components.AI
|
||||
|
||||
<div class="haiku-card @(IsLoading ? "haiku-loading" : "")" style="@GetBackgroundStyle()">
|
||||
@if (IsLoading)
|
||||
{
|
||||
<div class="haiku-content">
|
||||
<div class="haiku-lines">
|
||||
<div class="haiku-line skeleton-line"></div>
|
||||
<div class="haiku-line skeleton-line"></div>
|
||||
<div class="haiku-line skeleton-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (CurrentHaiku is not null)
|
||||
{
|
||||
<div class="haiku-content">
|
||||
<div class="haiku-lines">
|
||||
@for (int i = 0; i < CurrentHaiku.Japanese.Count && i < 3; i++)
|
||||
{
|
||||
var index = i;
|
||||
<div class="haiku-line" style="animation-delay: @(index * 100)ms">
|
||||
<p class="japanese-text" data-testid="haiku-japanese-line">@CurrentHaiku.Japanese[index]</p>
|
||||
@if (index < CurrentHaiku.English.Count)
|
||||
{
|
||||
<p class="english-text" data-testid="haiku-english-line">@CurrentHaiku.English[index]</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(CurrentHaiku.ImageName))
|
||||
{
|
||||
<div class="haiku-image-container">
|
||||
<img src="images/@CurrentHaiku.ImageName"
|
||||
alt="@CurrentHaiku.ImageName"
|
||||
class="haiku-image"
|
||||
data-testid="haiku-image" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Gets the haiku to display from the cascaded InvocationContext.
|
||||
/// </summary>
|
||||
[CascadingParameter]
|
||||
public InvocationContext? Invocation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the haiku to display directly (used when not rendering from a tool call).
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Haiku? Haiku { get; set; }
|
||||
|
||||
private Haiku? CurrentHaiku => Haiku ?? GetHaikuFromInvocation();
|
||||
private bool IsLoading => CurrentHaiku is null;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (Invocation is not null && !Invocation.HasResult)
|
||||
{
|
||||
Invocation.ResultArrived += OnResultArrived;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnResultArrived()
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private Haiku? GetHaikuFromInvocation()
|
||||
{
|
||||
if (Invocation?.HasResult == true)
|
||||
{
|
||||
return Invocation.GetResult<Haiku>();
|
||||
}
|
||||
|
||||
// Try to get partial data from arguments while streaming
|
||||
if (Invocation is not null)
|
||||
{
|
||||
var japanese = Invocation.GetArgument<string[]>("japanese");
|
||||
var english = Invocation.GetArgument<string[]>("english");
|
||||
var imageName = Invocation.GetArgument<string>("image_name");
|
||||
var gradient = Invocation.GetArgument<string>("gradient");
|
||||
|
||||
if (japanese is not null && japanese.Length > 0)
|
||||
{
|
||||
return new Haiku
|
||||
{
|
||||
Japanese = japanese,
|
||||
English = english ?? [],
|
||||
ImageName = imageName,
|
||||
Gradient = gradient ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private string GetBackgroundStyle()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(CurrentHaiku?.Gradient))
|
||||
{
|
||||
return $"background: {CurrentHaiku.Gradient};";
|
||||
}
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/* Haiku Card Styles */
|
||||
.haiku-card {
|
||||
background: linear-gradient(120deg, #ffffff 0%, #fdfdfd 50%, #ffffff 100%);
|
||||
background-size: 200% 200%;
|
||||
animation: animated-gradient 10s ease infinite;
|
||||
border: 1px solid #dee2e6;
|
||||
border-top: 10px solid #ff6f61;
|
||||
padding: 2rem 2.5rem;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.07),
|
||||
inset 0 1px 2px rgba(0, 0, 0, 0.01),
|
||||
0 0 15px rgba(255, 111, 97, 0.25);
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
margin: 1.5rem auto;
|
||||
transition: transform 0.35s ease, box-shadow 0.35s ease, border-top-width 0.35s ease, border-top-color 0.35s ease;
|
||||
}
|
||||
|
||||
.haiku-card:hover {
|
||||
transform: translateY(-8px) scale(1.03);
|
||||
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1),
|
||||
inset 0 1px 2px rgba(0, 0, 0, 0.01),
|
||||
0 0 25px rgba(255, 91, 74, 0.5);
|
||||
border-top-width: 14px;
|
||||
border-top-color: #ff5b4a;
|
||||
}
|
||||
|
||||
.haiku-card.haiku-loading {
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
@keyframes animated-gradient {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
@keyframes fade-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Haiku Content */
|
||||
.haiku-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.haiku-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.haiku-line {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
animation: fade-slide-in 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.japanese-text {
|
||||
font-family: serif;
|
||||
font-weight: bold;
|
||||
font-size: 2.5rem;
|
||||
background: linear-gradient(to right, #1e293b, #475569);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.english-text {
|
||||
font-weight: 300;
|
||||
font-size: 1rem;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
max-width: 400px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Skeleton Loading */
|
||||
.skeleton-line {
|
||||
width: 80%;
|
||||
height: 3rem;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
border-radius: 8px;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Image Container */
|
||||
.haiku-image-container {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.haiku-image {
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
object-fit: cover;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.haiku-image:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* Responsive Styles */
|
||||
@media (max-width: 768px) {
|
||||
.haiku-card {
|
||||
padding: 1.5rem;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.japanese-text {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.english-text {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.haiku-image {
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
|
||||
<div class="haiku-carousel-container" data-testid="haiku-carousel">
|
||||
@if (Haikus.Count > 1)
|
||||
{
|
||||
<button class="carousel-button carousel-prev" @onclick="PreviousHaiku" disabled="@(currentIndex == 0)">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
|
||||
<div class="carousel-content">
|
||||
@if (Haikus.Count > 0 && currentIndex < Haikus.Count)
|
||||
{
|
||||
<div class="carousel-item" data-testid="carousel-item-@currentIndex">
|
||||
<HaikuCard Haiku="@Haikus[currentIndex]" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Haikus.Count > 1)
|
||||
{
|
||||
<button class="carousel-button carousel-next" @onclick="NextHaiku" disabled="@(currentIndex >= Haikus.Count - 1)">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Haikus.Count > 1)
|
||||
{
|
||||
<div class="carousel-indicators">
|
||||
@for (int i = 0; i < Haikus.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
<button class="indicator @(index == currentIndex ? "active" : "")"
|
||||
@onclick="() => GoToHaiku(index)">
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private int currentIndex = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of haikus to display in the carousel.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public IReadOnlyList<Haiku> Haikus { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Event callback when the current haiku index changes.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<int> OnIndexChanged { get; set; }
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Reset to first haiku when new haiku is added at the beginning
|
||||
if (Haikus.Count > 0 && currentIndex >= Haikus.Count)
|
||||
{
|
||||
currentIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NextHaiku()
|
||||
{
|
||||
if (currentIndex < Haikus.Count - 1)
|
||||
{
|
||||
currentIndex++;
|
||||
await OnIndexChanged.InvokeAsync(currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PreviousHaiku()
|
||||
{
|
||||
if (currentIndex > 0)
|
||||
{
|
||||
currentIndex--;
|
||||
await OnIndexChanged.InvokeAsync(currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GoToHaiku(int index)
|
||||
{
|
||||
if (index >= 0 && index < Haikus.Count)
|
||||
{
|
||||
currentIndex = index;
|
||||
await OnIndexChanged.InvokeAsync(currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the carousel to show the first (newest) haiku.
|
||||
/// </summary>
|
||||
public void ResetToFirst()
|
||||
{
|
||||
currentIndex = 0;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/* Haiku Carousel Styles */
|
||||
.haiku-carousel-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.carousel-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.carousel-item {
|
||||
width: 100%;
|
||||
animation: fade-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Navigation Buttons */
|
||||
.carousel-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.carousel-button:hover:not(:disabled) {
|
||||
background: #f8fafc;
|
||||
border-color: #ff6f61;
|
||||
color: #ff6f61;
|
||||
box-shadow: 0 4px 12px rgba(255, 111, 97, 0.2);
|
||||
}
|
||||
|
||||
.carousel-button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.carousel-button svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Carousel Indicators */
|
||||
.carousel-indicators {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #e2e8f0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.indicator:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.indicator.active {
|
||||
background: #ff6f61;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* Responsive Styles */
|
||||
@media (max-width: 768px) {
|
||||
.haiku-carousel-container {
|
||||
padding: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.carousel-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.carousel-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using Microsoft.AspNetCore.Components.AI
|
||||
@using Microsoft.Agents.AI
|
||||
@using Microsoft.Extensions.AI
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using AGUIDojoClient.Components.Shared
|
||||
@using System.ComponentModel
|
||||
@inject IServiceProvider ServiceProvider
|
||||
|
||||
<PageTitle>Tool Based Generative UI</PageTitle>
|
||||
|
||||
<div class="tool-generative-ui-layout">
|
||||
<div class="main-display">
|
||||
<HaikuCarousel @ref="carouselRef" Haikus="@haikus" />
|
||||
</div>
|
||||
|
||||
<div class="chat-panel">
|
||||
<div class="chat-header">
|
||||
<div class="chat-title">Haiku Generator</div>
|
||||
<button class="new-chat-button" @onclick="ResetConversation">
|
||||
<span class="button-icon">+</span> New chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AgentBoundary Agent="@agent" OnContextCreated="OnContextCreated">
|
||||
<div class="chat-content">
|
||||
<Messages>
|
||||
<ContentTemplates>
|
||||
<HaikuCallTemplate />
|
||||
<TextTemplate />
|
||||
<FunctionCallTemplate />
|
||||
<FunctionResultTemplate />
|
||||
<ErrorTemplate />
|
||||
<DataContentTemplate />
|
||||
</ContentTemplates>
|
||||
</Messages>
|
||||
<AgentLoadingIndicator />
|
||||
</div>
|
||||
|
||||
<div class="chat-input-container">
|
||||
<AgentSuggestions Suggestions="@suggestions" />
|
||||
<AgentInput Placeholder="Ask for a haiku..." />
|
||||
</div>
|
||||
</AgentBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private AIAgent? agent;
|
||||
private HaikuCarousel? carouselRef;
|
||||
private List<Haiku> haikus = [Haiku.CreatePlaceholder()];
|
||||
|
||||
private Suggestion[] suggestions = [
|
||||
new Suggestion("Nature Haiku", new ChatMessage(ChatRole.User, "Write me a haiku about nature.")),
|
||||
new Suggestion("Ocean Haiku", new ChatMessage(ChatRole.User, "Create a haiku about the ocean.")),
|
||||
new Suggestion("Spring Haiku", new ChatMessage(ChatRole.User, "Generate a haiku about spring."))
|
||||
];
|
||||
|
||||
[Parameter]
|
||||
public string ScenarioId { get; set; } = "tool_based_generative_ui";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("tool-based-generative-ui");
|
||||
}
|
||||
|
||||
private void OnContextCreated(IAgentBoundaryContext context)
|
||||
{
|
||||
// Register the generate_haiku tool as a frontend tool
|
||||
var generateHaikuTool = AIFunctionFactory.Create(
|
||||
(string[] japanese, string[] english, string? image_name, string? gradient) =>
|
||||
GenerateHaiku(japanese, english, image_name, gradient),
|
||||
"generate_haiku",
|
||||
$"Generate a haiku with Japanese text, English translation, and an optional image. Valid image names: {string.Join(", ", Haiku.ValidImageNames)}");
|
||||
|
||||
context.RegisterTool(generateHaikuTool);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frontend tool handler that creates a new haiku and adds it to the carousel.
|
||||
/// </summary>
|
||||
[Description("Generate a haiku with Japanese and English text, plus an optional image.")]
|
||||
private Haiku GenerateHaiku(
|
||||
[Description("3 lines of haiku in Japanese")] string[] japanese,
|
||||
[Description("3 lines of haiku translated to English")] string[] english,
|
||||
[Description("One relevant image name from the valid list")] string? image_name,
|
||||
[Description("CSS Gradient color for the background")] string? gradient)
|
||||
{
|
||||
var newHaiku = new Haiku
|
||||
{
|
||||
Japanese = japanese ?? [],
|
||||
English = english ?? [],
|
||||
ImageName = image_name,
|
||||
Gradient = gradient ?? string.Empty
|
||||
};
|
||||
|
||||
// Add to beginning of list (newest first), removing placeholder if present
|
||||
var updatedHaikus = new List<Haiku> { newHaiku };
|
||||
updatedHaikus.AddRange(haikus.Where(h => h.English.Count == 0 || h.English[0] != "A placeholder verse—"));
|
||||
haikus = updatedHaikus;
|
||||
|
||||
// Reset carousel to show the new haiku
|
||||
InvokeAsync(() =>
|
||||
{
|
||||
carouselRef?.ResetToFirst();
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
return newHaiku;
|
||||
}
|
||||
|
||||
private void ResetConversation()
|
||||
{
|
||||
haikus = [Haiku.CreatePlaceholder()];
|
||||
carouselRef?.ResetToFirst();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/* Tool Based Generative UI Demo Layout */
|
||||
.tool-generative-ui-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
height: 100%;
|
||||
background: linear-gradient(170deg, #e9ecef 0%, #ced4da 100%);
|
||||
}
|
||||
|
||||
/* Main Display Area (Carousel) */
|
||||
.main-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Chat Panel */
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: white;
|
||||
border-left: 1px solid #e2e8f0;
|
||||
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.new-chat-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.new-chat-button:hover {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* Override agent suggestions for this demo */
|
||||
::deep .agent-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
::deep .suggestion-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #ff6f61;
|
||||
border-radius: 20px;
|
||||
color: #ff6f61;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
::deep .suggestion-button:hover {
|
||||
background: #ff6f61;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Responsive Layout */
|
||||
@media (max-width: 1024px) {
|
||||
.tool-generative-ui-layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
border-left: none;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.main-display {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tool-generative-ui-layout {
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.main-display {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using AGUIDojoClient.Components.Shared
|
||||
@inject DemoService DemoService
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="demo-sidebar">
|
||||
<div class="demo-sidebar-header">
|
||||
<h1>AG-UI Dojo</h1>
|
||||
<p>Microsoft Agent Framework (.NET)</p>
|
||||
</div>
|
||||
<div class="demo-sidebar-list">
|
||||
@foreach (DemoScenario scenario in DemoService.AllScenarios)
|
||||
{
|
||||
<button class="demo-item @(IsSelected(scenario.Id) ? "selected" : "")"
|
||||
@onclick="@(() => NavigateToScenario(scenario.Id))">
|
||||
<div class="demo-icon">@scenario.Icon</div>
|
||||
<div class="demo-content">
|
||||
<div class="demo-title">@scenario.Title</div>
|
||||
<div class="demo-description">@scenario.Description</div>
|
||||
<div class="demo-tags">
|
||||
@foreach (string tag in scenario.Tags)
|
||||
{
|
||||
<span class="demo-tag">@tag</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Gets or sets the currently selected scenario ID.
|
||||
/// </summary>
|
||||
[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}");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
|
||||
<div class="demo-view-tabs">
|
||||
<div class="tabs-header">
|
||||
<button class="tab-button @(CurrentTab == "preview" ? "active" : "")"
|
||||
@onclick="@(() => OnTabChanged("preview"))">
|
||||
Preview
|
||||
</button>
|
||||
<button class="tab-button @(CurrentTab == "code" ? "active" : "")"
|
||||
@onclick="@(() => OnTabChanged("code"))">
|
||||
Code
|
||||
</button>
|
||||
<button class="tab-button @(CurrentTab == "docs" ? "active" : "")"
|
||||
@onclick="@(() => OnTabChanged("docs"))">
|
||||
Docs
|
||||
</button>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
@if (CurrentTab == "preview")
|
||||
{
|
||||
<div class="tab-panel">
|
||||
@PreviewContent
|
||||
</div>
|
||||
}
|
||||
else if (CurrentTab == "code")
|
||||
{
|
||||
<div class="tab-panel">
|
||||
@CodeContent
|
||||
</div>
|
||||
}
|
||||
else if (CurrentTab == "docs")
|
||||
{
|
||||
<div class="tab-panel">
|
||||
@DocsContent
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Gets or sets the currently active tab.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string CurrentTab { get; set; } = "preview";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the callback for tab changes.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<string> OnTabChange { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content for the Preview tab.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? PreviewContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content for the Code tab.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? CodeContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content for the Docs tab.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? DocsContent { get; set; }
|
||||
|
||||
private async Task OnTabChanged(string tab)
|
||||
{
|
||||
CurrentTab = tab;
|
||||
await OnTabChange.InvokeAsync(tab);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<div class="lds-ellipsis"><div></div><div></div><div></div><div></div></div>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
@Body
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<PageTitle>@(scenario?.Title ?? "AG-UI Dojo")</PageTitle>
|
||||
|
||||
<div class="dojo-container">
|
||||
<DemoSidebar CurrentScenarioId="@ScenarioId" />
|
||||
<div class="dojo-main">
|
||||
@if (scenario is not null)
|
||||
{
|
||||
<DemoViewTabs CurrentTab="@currentTab" OnTabChange="@OnTabChanged">
|
||||
<PreviewContent>
|
||||
<div class="preview-panel">
|
||||
@RenderScenarioDemo()
|
||||
</div>
|
||||
</PreviewContent>
|
||||
<CodeContent>
|
||||
<div class="code-panel">
|
||||
<div class="placeholder-content">
|
||||
<h2>Code</h2>
|
||||
<p>Code view for <strong>@scenario.Title</strong> will be available soon.</p>
|
||||
<p>This will show the relevant source code for this scenario.</p>
|
||||
</div>
|
||||
</div>
|
||||
</CodeContent>
|
||||
<DocsContent>
|
||||
<div class="docs-panel">
|
||||
<div class="placeholder-content">
|
||||
<h2>Documentation</h2>
|
||||
<p>Documentation for <strong>@scenario.Title</strong> will be available soon.</p>
|
||||
<p>This will include:</p>
|
||||
<ul>
|
||||
<li>Overview of the scenario</li>
|
||||
<li>Key concepts</li>
|
||||
<li>How to use the demo</li>
|
||||
<li>Links to related documentation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DocsContent>
|
||||
</DemoViewTabs>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="error-panel">
|
||||
<h2>Scenario Not Found</h2>
|
||||
<p>The scenario "@ScenarioId" could not be found.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private DemoScenario? scenario;
|
||||
private string currentTab = "preview";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scenario ID from the route.
|
||||
/// </summary>
|
||||
[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<AgenticChatDemo>(0);
|
||||
builder.AddAttribute(1, nameof(AgenticChatDemo.ScenarioId), scenario.Id);
|
||||
builder.CloseComponent();
|
||||
break;
|
||||
|
||||
case "backend_tool_rendering":
|
||||
builder.OpenComponent<BackendToolRenderingDemo>(0);
|
||||
builder.AddAttribute(1, nameof(BackendToolRenderingDemo.ScenarioId), scenario.Id);
|
||||
builder.CloseComponent();
|
||||
break;
|
||||
|
||||
case "human_in_the_loop":
|
||||
builder.OpenComponent<HumanInTheLoopDemo>(0);
|
||||
builder.AddAttribute(1, nameof(HumanInTheLoopDemo.ScenarioId), scenario.Id);
|
||||
builder.CloseComponent();
|
||||
break;
|
||||
|
||||
case "tool_based_generative_ui":
|
||||
builder.OpenComponent<ToolBasedGenerativeUIDemo>(0);
|
||||
builder.AddAttribute(1, nameof(ToolBasedGenerativeUIDemo.ScenarioId), scenario.Id);
|
||||
builder.CloseComponent();
|
||||
break;
|
||||
|
||||
case "agentic_generative_ui":
|
||||
builder.OpenComponent<AgenticGenerativeUIDemo>(0);
|
||||
builder.AddAttribute(1, nameof(AgenticGenerativeUIDemo.ScenarioId), scenario.Id);
|
||||
builder.CloseComponent();
|
||||
break;
|
||||
|
||||
case "shared_state":
|
||||
builder.OpenComponent<SharedStateDemo>(0);
|
||||
builder.AddAttribute(1, nameof(SharedStateDemo.ScenarioId), scenario.Id);
|
||||
builder.CloseComponent();
|
||||
break;
|
||||
|
||||
case "predictive_state_updates":
|
||||
builder.OpenComponent<PredictiveStateUpdatesDemo>(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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -0,0 +1,94 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using System.ComponentModel
|
||||
@inject IChatClient ChatClient
|
||||
@inject NavigationManager Nav
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>Chat</PageTitle>
|
||||
|
||||
<ChatHeader OnNewChat="@ResetConversationAsync" />
|
||||
|
||||
<ChatMessageList Messages="@messages" InProgressMessage="@currentResponseMessage">
|
||||
<NoMessagesContent>
|
||||
<div>Ask the assistant a question to start a conversation.</div>
|
||||
</NoMessagesContent>
|
||||
</ChatMessageList>
|
||||
<div class="chat-container">
|
||||
<ChatSuggestions OnSelected="@AddUserMessageAsync" @ref="@chatSuggestions" />
|
||||
<ChatInput OnSend="@AddUserMessageAsync" @ref="@chatInput" />
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private const string SystemPrompt = @"
|
||||
You are a helpful assistant.
|
||||
";
|
||||
|
||||
private int statefulMessageCount;
|
||||
private readonly ChatOptions chatOptions = new();
|
||||
private readonly List<ChatMessage> 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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
@using System.Web
|
||||
@if (!string.IsNullOrWhiteSpace(viewerUrl))
|
||||
{
|
||||
<a href="@viewerUrl" target="_blank" class="citation">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||
</svg>
|
||||
<div class="citation-content">
|
||||
<div class="citation-file">@File</div>
|
||||
<div>@Quote</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
@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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<div class="chat-header-container main-background-gradient">
|
||||
<div class="chat-header-controls page-width">
|
||||
<button class="btn-default" @onclick="@OnNewChat">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="new-chat-icon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
New chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1 class="page-width">AGUI WebChat</h1>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public EventCallback OnNewChat { get; set; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<EditForm Model="@this" OnValidSubmit="@SendMessageAsync">
|
||||
<label class="input-box page-width">
|
||||
<textarea @ref="@textArea" @bind="@messageText" placeholder="Type your message..." rows="1"></textarea>
|
||||
|
||||
<div class="tools">
|
||||
<button type="submit" title="Send" class="send-button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="tool-icon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</EditForm>
|
||||
|
||||
@code {
|
||||
private ElementReference textArea;
|
||||
private string? messageText;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<ChatMessage> 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<IJSObjectReference>("import", "./Components/Shared/Chat/ChatInput.razor.js");
|
||||
await module.InvokeVoidAsync("init", textArea);
|
||||
await module.DisposeAsync();
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
@using System.Runtime.CompilerServices
|
||||
@using System.Text.RegularExpressions
|
||||
@using System.Linq
|
||||
|
||||
@if (Message.Role == ChatRole.User)
|
||||
{
|
||||
<div class="user-message">
|
||||
@Message.Text
|
||||
</div>
|
||||
}
|
||||
else if (Message.Role == ChatRole.Assistant)
|
||||
{
|
||||
foreach (var content in Message.Contents)
|
||||
{
|
||||
if (content is TextContent { Text: { Length: > 0 } text })
|
||||
{
|
||||
<div class="assistant-message">
|
||||
<div>
|
||||
<div class="assistant-message-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 0 0 1.5-.189m-1.5.189a6.01 6.01 0 0 1-1.5-.189m3.75 7.478a12.06 12.06 0 0 1-4.5 0m3.75 2.383a14.406 14.406 0 0 1-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 1 0-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="assistant-message-header">Assistant</div>
|
||||
<div class="assistant-message-text">
|
||||
<div>@((MarkupString)text)</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true)
|
||||
{
|
||||
<div class="assistant-search">
|
||||
<div class="assistant-search-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="assistant-search-content">
|
||||
Searching:
|
||||
<span class="assistant-search-phrase">@searchPhrase</span>
|
||||
@if (fcc.Arguments?.TryGetValue("filenameFilter", out var filenameObj) is true && filenameObj is string filename && !string.IsNullOrEmpty(filename))
|
||||
{
|
||||
<text> in </text><span class="assistant-search-phrase">@filename</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly ConditionalWeakTable<ChatMessage, ChatMessageItem> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div class="message-list-container">
|
||||
<chat-messages class="page-width message-list" in-progress="@(InProgressMessage is not null)">
|
||||
@foreach (var message in Messages)
|
||||
{
|
||||
<ChatMessageItem @key="@message" Message="@message" />
|
||||
}
|
||||
|
||||
@if (InProgressMessage is not null)
|
||||
{
|
||||
<ChatMessageItem Message="@InProgressMessage" InProgress="true" />
|
||||
<LoadingSpinner />
|
||||
}
|
||||
else if (IsEmpty)
|
||||
{
|
||||
<div class="no-messages">@NoMessagesContent</div>
|
||||
}
|
||||
</chat-messages>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public required IEnumerable<ChatMessage> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
@inject IChatClient ChatClient
|
||||
|
||||
@if (suggestions is not null)
|
||||
{
|
||||
<div class="page-width suggestions">
|
||||
@foreach (var suggestion in suggestions)
|
||||
{
|
||||
<button class="btn-subtle" @onclick="@(() => AddSuggestionAsync(suggestion))">
|
||||
@suggestion
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@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<ChatMessage> OnSelected { get; set; }
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
suggestions = null;
|
||||
cancellation?.Cancel();
|
||||
}
|
||||
|
||||
public void Update(IReadOnlyList<ChatMessage> messages)
|
||||
{
|
||||
// Runs in the background and handles its own cancellation/errors
|
||||
_ = UpdateSuggestionsAsync(messages);
|
||||
}
|
||||
|
||||
private async Task UpdateSuggestionsAsync(IReadOnlyList<ChatMessage> messages)
|
||||
{
|
||||
cancellation?.Cancel();
|
||||
cancellation = new CancellationTokenSource();
|
||||
|
||||
try
|
||||
{
|
||||
var response = await ChatClient.GetResponseAsync<string[]>(
|
||||
[.. 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<ChatMessage> ReduceMessages(IReadOnlyList<ChatMessage> 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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace AGUIDojoClient.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a demo scenario in the AG-UI dojo.
|
||||
/// </summary>
|
||||
/// <param name="Id">Unique identifier for the scenario (e.g., "agentic_chat").</param>
|
||||
/// <param name="Title">Display title of the scenario.</param>
|
||||
/// <param name="Description">Brief description of what the scenario demonstrates.</param>
|
||||
/// <param name="Tags">Collection of tags categorizing the scenario's features.</param>
|
||||
/// <param name="Endpoint">Server endpoint path for the AG-UI connection.</param>
|
||||
/// <param name="Icon">Optional emoji icon for the scenario.</param>
|
||||
public record DemoScenario(
|
||||
string Id,
|
||||
string Title,
|
||||
string Description,
|
||||
IReadOnlyList<string> Tags,
|
||||
string Endpoint,
|
||||
string Icon = "💬"
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user