| File: Engine\UIAgent.cs | Web Access |
| Project: src\aspnetcore\src\Components\AI\src\Microsoft.AspNetCore.Components.AI.csproj (Microsoft.AspNetCore.Components.AI) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Linq; using System.Runtime.CompilerServices; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.AspNetCore.Components.AI; /// <summary> /// Turns an <see cref="IChatClient"/> stream into content blocks the UI can render as they /// arrive, and keeps the conversation history for subsequent turns. /// </summary> /// <remarks> /// A <see cref="UIAgent"/> is protocol- and provider-neutral: it only depends on /// <see cref="IChatClient"/>, so any Microsoft.Extensions.AI client can drive it. /// </remarks> /// <example> /// <code> /// var agent = new UIAgent(chatClient); /// await foreach (var block in agent.SendMessageAsync(new ChatMessage(ChatRole.User, "Hello"))) /// { /// Console.WriteLine(block.Id); /// } /// </code> /// </example> public class UIAgent : IDisposable { private readonly IChatClient _chatClient; private readonly UIAgentOptions _options; private readonly ILogger _logger; private readonly List<ChatMessage> _history = new(); private bool _disposed; internal UIAgentOptions Options => _options; /// <summary> /// Initializes a new instance of the <see cref="UIAgent"/> class. /// </summary> /// <param name="chatClient">The chat client that produces model responses.</param> public UIAgent(IChatClient chatClient) : this(chatClient, configure: null) { } /// <summary> /// Initializes a new instance of the <see cref="UIAgent"/> class. /// </summary> /// <param name="chatClient">The chat client that produces model responses.</param> /// <param name="chatOptions">The options passed to the chat client.</param> public UIAgent(IChatClient chatClient, ChatOptions chatOptions) : this(chatClient, options => options.ChatOptions = chatOptions) { } /// <summary> /// Initializes a new instance of the <see cref="UIAgent"/> class. /// </summary> /// <param name="chatClient">The chat client that produces model responses.</param> /// <param name="chatOptions">The options passed to the chat client.</param> /// <param name="loggerFactory">The logger factory used to trace block mapping.</param> public UIAgent(IChatClient chatClient, ChatOptions chatOptions, ILoggerFactory? loggerFactory) : this(chatClient, options => options.ChatOptions = chatOptions, loggerFactory) { } /// <summary> /// Initializes a new instance of the <see cref="UIAgent"/> class. /// </summary> /// <param name="chatClient">The chat client that produces model responses.</param> /// <param name="configure">A callback that configures the agent.</param> public UIAgent(IChatClient chatClient, Action<UIAgentOptions>? configure) : this(chatClient, configure, loggerFactory: null) { } /// <summary> /// Initializes a new instance of the <see cref="UIAgent"/> class. /// </summary> /// <param name="chatClient">The chat client that produces model responses.</param> /// <param name="configure">A callback that configures the agent.</param> /// <param name="loggerFactory">The logger factory used to trace block mapping.</param> public UIAgent(IChatClient chatClient, Action<UIAgentOptions>? configure, ILoggerFactory? loggerFactory) { ArgumentNullException.ThrowIfNull(chatClient); _chatClient = chatClient; _options = new UIAgentOptions(); configure?.Invoke(_options); _logger = (ILogger?)loggerFactory?.CreateLogger<BlockMappingPipeline>() ?? NullLogger.Instance; } internal UIAgent(IChatClient chatClient, UIAgentOptions options, ILoggerFactory? loggerFactory) { ArgumentNullException.ThrowIfNull(chatClient); _chatClient = chatClient; _options = options; _logger = (ILogger?)loggerFactory?.CreateLogger<BlockMappingPipeline>() ?? NullLogger.Instance; } /// <summary> /// Sends a message and streams the resulting content blocks. Blocks are yielded as soon as /// they are created; a block keeps changing (raising <see cref="ContentBlock.OnChanged(Action)"/>) /// until it becomes <see cref="BlockLifecycleState.Inactive"/>. /// </summary> /// <param name="message">The message to send.</param> /// <param name="cancellationToken">A token that cancels the response.</param> /// <returns>The blocks produced by the message and by the model response to it.</returns> public async IAsyncEnumerable<ContentBlock> SendMessageAsync( ChatMessage message, [EnumeratorCancellation] CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(message); await foreach (var block in SendMessagesAsync([message], cancellationToken).ConfigureAwait(false)) { yield return block; } } internal async IAsyncEnumerable<ContentBlock> SendMessagesAsync( IReadOnlyList<ChatMessage> messages, [EnumeratorCancellation] CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); var thread = _options.Thread; foreach (var message in messages) { ArgumentNullException.ThrowIfNull(message); _history.Add(message); } var pipeline = new BlockMappingPipeline(_options, _logger); // Process user messages through pipeline foreach (var message in messages) { var userUpdate = new ChatResponseUpdate { Role = message.Role, Contents = [.. message.Contents] }; await foreach (var block in pipeline.Process(userUpdate, cancellationToken).ConfigureAwait(false)) { yield return block; } } foreach (var block in pipeline.Finalize()) { yield return block; } // Stream assistant response UIAgentLog.StreamingAssistantResponse(_logger); var responseUpdates = new List<ChatResponseUpdate>(); var assistantUpdates = new List<ChatResponseUpdate>(); var updateIndex = 0; var chatOptions = BuildChatOptions(); IEnumerable<ChatMessage> requestMessages = _history; if (thread is { IsStateful: true, ConversationId: not null }) { chatOptions = chatOptions?.Clone() ?? new ChatOptions(); chatOptions.ConversationId = thread.ConversationId; requestMessages = messages; } await foreach (var update in _chatClient.GetStreamingResponseAsync( requestMessages, chatOptions, cancellationToken).ConfigureAwait(false)) { var contentTypes = string.Join(", ", update.Contents.Select(c => c.GetType().Name)); UIAgentLog.ReceivedUpdate(_logger, updateIndex++, update.Role?.Value, contentTypes); responseUpdates.Add(update); var processUpdate = ApplyStateMapper(update); assistantUpdates.Add(processUpdate); if (processUpdate.Contents.Count == 0 && update.Contents.Count > 0) { continue; } await foreach (var block in pipeline.Process(processUpdate, cancellationToken).ConfigureAwait(false)) { yield return block; } } UIAgentLog.StreamComplete(_logger, assistantUpdates.Count); foreach (var block in pipeline.Finalize()) { yield return block; } // Add assistant response to history var response = assistantUpdates.ToChatResponse(); foreach (var msg in response.Messages) { _history.Add(msg); } if (thread is not null && messages.Count > 0) { thread.AppendUserMessage(messages[0]); foreach (var message in messages.Skip(1)) { thread.AppendUpdate(new ChatResponseUpdate { Role = message.Role, Contents = [.. message.Contents], }); } foreach (var update in responseUpdates) { thread.AppendUpdate(update); } thread.CompleteTurn(); } UIAgentLog.AddedToHistory(_logger, response.Messages.Count); } /// <summary> /// Restores the committed conversation and typed state from the configured thread. /// </summary> /// <param name="cancellationToken">A token that cancels restoration.</param> /// <returns>The restored content blocks in chronological order.</returns> public async Task<IReadOnlyList<ContentBlock>> RestoreAsync( CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); var updates = _options.Thread?.GetUpdates(); if (updates is not { Count: > 0 }) { return []; } _history.Clear(); var blocks = new List<ContentBlock>(); var pipeline = new BlockMappingPipeline(_options, _logger); var assistantUpdates = new List<ChatResponseUpdate>(); foreach (var update in updates) { cancellationToken.ThrowIfCancellationRequested(); if (update.Role == ChatRole.User) { if (assistantUpdates.Count > 0) { AddResponseToHistory(assistantUpdates); assistantUpdates.Clear(); blocks.AddRange(pipeline.Finalize()); pipeline = new BlockMappingPipeline(_options, _logger); } _history.Add(new ChatMessage(update.Role.Value, [.. update.Contents])); await foreach (var block in pipeline.Process(update, cancellationToken).ConfigureAwait(false)) { blocks.Add(block); } blocks.AddRange(pipeline.Finalize()); pipeline = new BlockMappingPipeline(_options, _logger); } else { assistantUpdates.Add(update); var processUpdate = ApplyStateMapper(update); if (processUpdate.Contents.Count == 0 && update.Contents.Count > 0) { continue; } await foreach (var block in pipeline.Process(processUpdate, cancellationToken).ConfigureAwait(false)) { blocks.Add(block); } } } if (assistantUpdates.Count > 0) { AddResponseToHistory(assistantUpdates); } blocks.AddRange(pipeline.Finalize()); return blocks; } internal virtual ChatResponseUpdate ApplyStateMapper(ChatResponseUpdate update) { if (_options.StateMapper is null) { return update; } var context = new StateMapperContext(update); _options.StateMapper(context); return context.HasHandledContent ? context.GetFilteredUpdate() : update; } private void AddResponseToHistory(List<ChatResponseUpdate> updates) { var response = updates.ToChatResponse(); foreach (var message in response.Messages) { _history.Add(message); } } private ChatOptions? BuildChatOptions() { if (_options.UIActions.Count == 0) { return _options.ChatOptions; } var chatOptions = _options.ChatOptions?.Clone() ?? new ChatOptions(); var tools = chatOptions.Tools is null ? new List<AITool>() : [.. chatOptions.Tools]; foreach (var action in _options.UIActions.Values) { tools.Add(action.AsDeclarationOnly()); } chatOptions.Tools = tools; return chatOptions; } /// <summary> /// Releases the resources used by this agent. /// </summary> public void Dispose() { _disposed = true; GC.SuppressFinalize(this); } }