File: Pipeline\BlockMappingPipeline.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;
 
// Routes model updates to handlers in two phases:
//   1. Blocks that are still active get first refusal on the update, most recent first.
//      This keeps a streaming block accumulating instead of emitting a new block per token.
//   2. Whatever is left is offered to inactive handlers, which may emit new blocks.
internal class BlockMappingPipeline
{
    private readonly List<IHandlerEntry> _handlers = new();
    private readonly List<IActiveEntry> _activeStack = new();
    private readonly ILogger _logger;
 
    internal BlockMappingPipeline(UIAgentOptions options, ILogger? logger = null)
    {
        _logger = logger ?? NullLogger.Instance;
 
        // User-registered handlers go first so they can customize behavior
        foreach (var registration in options.HandlerRegistrations)
        {
            _handlers.Add(registration.CreateEntry());
        }
 
        // Built-in text handler is always last (fallback)
        _handlers.Add(new HandlerEntry<RichContentBlock>(new TextBlockHandler()));
    }
 
    internal async IAsyncEnumerable<ContentBlock> Process(
        ChatResponseUpdate update,
#pragma warning disable IDE0060 // cancellationToken reserved for future use
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
#pragma warning restore IDE0060
    {
        var contentTypes = string.Join(", ", update.Contents.Select(c => c.GetType().Name));
        BlockMappingPipelineLog.ProcessingUpdate(_logger, update.Role?.Value, update.Contents.Count, contentTypes);
 
        var context = new BlockMappingContext(update);
 
        // Phase 1: Active entries get priority (most recent first)
        for (var i = _activeStack.Count - 1; i >= 0; i--)
        {
            if (context.AllHandled)
            {
                break;
            }
 
            var active = _activeStack[i];
            var result = active.Invoke(context);
 
            BlockMappingPipelineLog.ActiveHandlerResult(_logger, active.Block.GetType().Name, result.Kind.ToString(), active.Block.Id);
 
            switch (result.Kind)
            {
                case HandleResult.ResultKind.Pass:
                    break;
 
                case HandleResult.ResultKind.Update:
                    active.Block.InvokeNotifyChanged();
                    break;
 
                case HandleResult.ResultKind.Complete:
                    active.Block.LifecycleState = BlockLifecycleState.Inactive;
                    active.Block.InvokeNotifyChanged();
                    _activeStack.RemoveAt(i);
                    break;
            }
        }
 
        // Phase 2: Inactive handlers try to claim remaining content
        if (!context.AllHandled)
        {
            for (var i = 0; i < _handlers.Count; i++)
            {
                if (context.AllHandled)
                {
                    break;
                }
 
                var handler = _handlers[i];
                BlockMappingPipelineLog.TryingInactiveHandler(_logger, handler.GetType().Name);
 
                while (!context.AllHandled)
                {
                    var progressBefore = context.HandledProgress;
                    var activeEntry = handler.TryHandle(context);
                    if (activeEntry is null)
                    {
                        break;
                    }
 
                    var emitBlock = activeEntry.Block;
                    emitBlock.Role = update.Role;
                    emitBlock.AuthorName = update.AuthorName;
                    emitBlock.LifecycleState = BlockLifecycleState.Active;
                    ThrowIfIdMissing(emitBlock);
                    _activeStack.Add(activeEntry);
 
                    BlockMappingPipelineLog.EmittingBlock(_logger, emitBlock.GetType().Name, emitBlock.Id, emitBlock.Role?.Value);
 
                    yield return emitBlock;
 
                    // If the handler emitted without consuming any content, stop
                    // re-invoking it to prevent infinite loops.
                    if (context.HandledProgress == progressBefore)
                    {
                        break;
                    }
                }
            }
        }
        else
        {
            BlockMappingPipelineLog.AllContentHandledAfterPhase1(_logger);
        }
 
        await Task.CompletedTask;
    }
 
    internal IReadOnlyList<ContentBlock> Finalize()
    {
        BlockMappingPipelineLog.Finalizing(_logger, _activeStack.Count);
 
        foreach (var active in _activeStack)
        {
            active.Block.LifecycleState = BlockLifecycleState.Inactive;
            active.Block.InvokeNotifyChanged();
        }
        _activeStack.Clear();
 
        return Array.Empty<ContentBlock>();
    }
 
    private static void ThrowIfIdMissing(ContentBlock block)
    {
        if (string.IsNullOrEmpty(block.Id))
        {
            throw new InvalidOperationException(
                $"Block handler emitted a {block.GetType().Name} without assigning an Id.");
        }
    }
}