| File: Components\MessageList.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 Microsoft.AspNetCore.Components.Rendering; namespace Microsoft.AspNetCore.Components.AI; /// <summary> /// Renders the turns of the cascaded <see cref="AgentContext"/> and updates them as blocks /// stream in. /// </summary> [StreamRendering] public class MessageList : IComponent, IDisposable { private RenderHandle _renderHandle; private AgentContext _agentContext = default!; private RenderFragment? _childContent; private RenderFragment<AgentContext>? _footer; private readonly MessageListContext _listContext = new(); private readonly List<ConversationTurnRenderer> _turnRenderers = new(); private IDisposable? _turnAddedSub; private IDisposable? _statusChangedSub; /// <summary> /// Gets or sets the conversation rendered by this list. /// </summary> [CascadingParameter] public AgentContext AgentContext { get; set; } = default!; /// <summary> /// Gets or sets the content rendered above the turns. Use it to register /// <see cref="BlockRenderer{TBlock}"/> components. /// </summary> [Parameter] public RenderFragment? ChildContent { get; set; } /// <summary> /// Gets or sets the content rendered when the conversation has no turns. /// </summary> [Parameter] public RenderFragment? EmptyContent { get; set; } /// <summary> /// Gets or sets the content rendered below the turns. Defaults to the streaming and error /// indicators. /// </summary> [Parameter] public RenderFragment<AgentContext>? Footer { get; set; } void IComponent.Attach(RenderHandle renderHandle) { _renderHandle = renderHandle; } Task IComponent.SetParametersAsync(ParameterView parameters) { parameters.SetParameterProperties(this); var newAgentContext = AgentContext ?? throw new InvalidOperationException( "MessageList must be inside an AgentBoundary."); if (!ReferenceEquals(_agentContext, newAgentContext)) { ResetRegistrations(); _agentContext = newAgentContext; _listContext.OnRegistrationsChanged = Render; _turnAddedSub = _agentContext.RegisterOnTurnAdded(OnTurnAdded); _statusChangedSub = _agentContext.RegisterOnStatusChanged(_ => Render()); foreach (var turn in _agentContext.Turns) { var renderer = new ConversationTurnRenderer( _agentContext, turn, _listContext, Render); _turnRenderers.Add(renderer); } } _childContent = ChildContent; _footer = Footer; Render(); return Task.CompletedTask; } private void OnTurnAdded(ConversationTurn turn) { var renderer = new ConversationTurnRenderer( _agentContext, turn, _listContext, Render); _turnRenderers.Add(renderer); Render(); } private void Render() { _renderHandle.Render(builder => { builder.OpenComponent<CascadingValue<MessageListContext>>(0); builder.AddComponentParameter(1, "Value", _listContext); builder.AddComponentParameter(2, "IsFixed", true); builder.AddComponentParameter(3, "ChildContent", (RenderFragment)(inner => { inner.OpenElement(3, "div"); inner.AddAttribute(3, "class", "sc-ai-message-list"); if (_childContent is not null) { inner.AddContent(4, _childContent); } if (_turnRenderers.Count == 0 && EmptyContent is not null) { inner.AddContent(5, EmptyContent); } var seq = 100; foreach (var turnRenderer in _turnRenderers) { turnRenderer.RenderTo(inner, seq); seq += 100; } inner.OpenElement(seq, "div"); inner.AddAttribute(seq + 1, "class", "sc-ai-message-list__footer"); if (_footer is not null) { inner.AddContent(seq + 2, _footer(_agentContext)); } else { RenderDefaultFooter(inner, seq + 2); } inner.CloseElement(); // footer div inner.CloseElement(); // sc-ai-message-list div })); builder.CloseComponent(); }); } private void RenderDefaultFooter(RenderTreeBuilder builder, int seq) { switch (_agentContext.Status) { case ConversationStatus.Streaming: builder.OpenElement(seq, "div"); builder.AddAttribute(seq + 1, "class", "sc-ai-typing"); builder.AddAttribute(seq + 2, "role", "status"); builder.AddAttribute(seq + 3, "aria-label", "Agent is typing"); for (var i = 0; i < 3; i++) { builder.OpenElement(seq + 4 + i, "span"); builder.AddAttribute(seq + 7 + i, "class", "sc-ai-typing__dot"); builder.CloseElement(); } builder.CloseElement(); break; case ConversationStatus.Error: builder.OpenElement(seq, "div"); builder.AddAttribute(seq + 1, "class", "sc-ai-error"); builder.AddAttribute(seq + 2, "role", "alert"); builder.OpenElement(seq + 3, "span"); builder.AddAttribute(seq + 4, "class", "sc-ai-error__message"); builder.AddContent(seq + 5, "Something went wrong. Please try again."); builder.CloseElement(); // span builder.OpenElement(seq + 6, "button"); builder.AddAttribute(seq + 7, "type", "button"); builder.AddAttribute(seq + 8, "class", "sc-ai-btn sc-ai-btn--secondary"); builder.AddAttribute(seq + 9, "onclick", EventCallback.Factory.Create(this, () => _agentContext.RetryAsync())); builder.AddContent(seq + 10, "Retry"); builder.CloseElement(); // button builder.CloseElement(); // div break; } } private void ResetRegistrations() { _turnAddedSub?.Dispose(); _turnAddedSub = null; _statusChangedSub?.Dispose(); _statusChangedSub = null; foreach (var renderer in _turnRenderers) { renderer.Dispose(); } _turnRenderers.Clear(); } /// <summary> /// Removes the subscriptions this list registered on the conversation. /// </summary> public void Dispose() { ResetRegistrations(); GC.SuppressFinalize(this); } }