File: Components\Controls\AssistantChat.razor.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Model.Assistant;
using Aspire.Dashboard.Resources;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Localization;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
 
namespace Aspire.Dashboard.Components;
 
public sealed partial class AssistantChat : ComponentBase, IAsyncDisposable
{
    [Parameter]
    public required AssistantChatViewModel ChatViewModel { get; set; }
 
    [Parameter]
    public required string Class { get; set; }
 
    [Parameter]
    public required EventCallback ModelInitialized { get; set; }
 
    [Inject]
    public required ILogger<AssistantChat> Logger { get; init; }
 
    [Inject]
    public required IJSRuntime JS { get; init; }
 
    [Inject]
    public required IStringLocalizer<AIAssistant> Loc { get; init; }
 
    [Inject]
    public required IStringLocalizer<ControlsStrings> ControlsStringsLoc { get; init; }
 
    [Inject]
    public required ILocalStorage LocalStorage { get; init; }
 
    [Inject]
    public required IAIContextProvider AIContextProvider { get; init; }
 
    private readonly CancellationTokenSource _cts = new();
    private ElementReference? _chatMessageTextBox;
    private ScrollInstruction _scrollInstruction;
    private IJSObjectReference? _module;
    private AssistantChatViewModel? _chatViewModel;
    private MenuButtonItem? _selectedModelItem;
    private readonly List<MenuButtonItem> _modelMenuItems = new()!;
    private string? _currentChatMessageId;
    private bool _currentChatMessageChanged;
    private bool _currentChatMessageComplete;
    private bool _initializedChatScript;
    private AssistantChatDisplayState _renderDisplayState;
 
    private enum ScrollInstruction
    {
        None,
        ScrollImmediately
    }
 
    private MenuButtonItem CreateItem(ModelViewModel model)
    {
        MenuButtonItem menuButtonItem = null!;
 
        menuButtonItem = new MenuButtonItem
        {
            Text = model.DisplayName,
            OnClick = async () =>
            {
                _selectedModelItem = menuButtonItem;
                ChatViewModel.SetSelectedModel(model);
 
                CreateModelMenuItems();
                StateHasChanged();
 
                await ChatViewModel.UpdateSettingsAsync();
                if (_chatMessageTextBox is { } textbox)
                {
                    await textbox.FocusAsync();
                }
            }
        };
 
        return menuButtonItem;
    }
 
    protected override async Task OnParametersSetAsync()
    {
        if (ChatViewModel != _chatViewModel)
        {
            if (_chatViewModel != null)
            {
                // The view model for the component has changed. Disconnect and dispose the old model.
                await _chatViewModel.DisconnectAsync();
                _chatViewModel.Dispose();
                _initializedChatScript = false;
            }
 
            _chatViewModel = ChatViewModel;
 
            ChatViewModel.OnToolInvokedCallback = SetStatusAsync;
            ChatViewModel.OnConversationChangedCallback = ConversationChangedAsync;
            ChatViewModel.OnContextChangedCallback = ContextChangedAsync;
            ChatViewModel.OnInitializeCallback = InitializedAsync;
 
            CreateModelMenuItems();
 
            await ChatViewModel.ComponentInitialize();
        }
    }
 
    private void CreateModelMenuItems()
    {
        _modelMenuItems.Clear();
        foreach (var model in ChatViewModel.Models)
        {
            _modelMenuItems.Add(CreateItem(model));
        }
        _selectedModelItem = _modelMenuItems.FirstOrDefault(i => i.Text == ChatViewModel.SelectedModel?.DisplayName) ?? _modelMenuItems.LastOrDefault();
        _selectedModelItem?.Icon = new Icons.Regular.Size16.Checkmark();
    }
 
    private async Task ScrollToEndOfMessagesAsync()
    {
        if (_cts.IsCancellationRequested)
        {
            return;
        }
 
        if (_module != null)
        {
            var scrollOptions = new
            {
                containerId = AssistantChatViewModel.ChatAssistantContainerId
            };
            await _module.InvokeVoidAsync("scrollToBottomChatAssistant", _cts.Token, scrollOptions);
            _scrollInstruction = ScrollInstruction.None;
        }
    }
 
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _module = await JS.InvokeAsync<IJSObjectReference>("import", "./Components/Controls/AssistantChat.razor.js");
        }
 
        if (_module != null)
        {
            // Use the display state captured during render to make sure the value changing during render has no impact.
            if (_renderDisplayState == AssistantChatDisplayState.Chat && !_initializedChatScript)
            {
                _initializedChatScript = true;
 
                // Attach an event for listening to key press in the browser to submit a message on enter.
                // A JavaScript event is better than using @onkeypress directly in Blazor:
                // - Wait until enter before making a server call.
                // - Suppress adding new line to the textarea value.
                var initializeOptions = new
                {
                    containerId = AssistantChatViewModel.ChatAssistantContainerId,
                    scrollBottomButtonId = "chat-scroll-bottom-button",
                    textareaId = "chat-message",
                    formId = "chat-form"
                };
                await _module.InvokeVoidAsync("initializeAssistantChat", initializeOptions);
            }
 
            if (_currentChatMessageChanged)
            {
                _currentChatMessageChanged = false;
 
                var initializeOptions = new
                {
                    containerId = AssistantChatViewModel.ChatAssistantContainerId,
                    chatMessageId = _currentChatMessageId
                };
                await _module.InvokeVoidAsync("initializeCurrentMessage", initializeOptions);
            }
 
            if (_currentChatMessageComplete)
            {
                _currentChatMessageComplete = false;
 
                var initializeOptions = new
                {
                    containerId = AssistantChatViewModel.ChatAssistantContainerId,
                    chatMessageId = _currentChatMessageId
                };
                await _module.InvokeVoidAsync("completeCurrentMessage", initializeOptions);
            }
        }
 
        if (_scrollInstruction == ScrollInstruction.ScrollImmediately)
        {
            await ScrollToEndOfMessagesAsync();
        }
    }
 
    private Icon GetSubmitIcon()
    {
        if (ChatViewModel.ResponseInProgress)
        {
            return new Icons.Filled.Size20.RecordStop();
        }
        else
        {
            return IsSubmitEnabled()
                ? new Icons.Filled.Size20.Send()
                : new Icons.Regular.Size20.Send();
        }
    }
 
    private bool IsSubmitEnabled()
    {
        if (ChatViewModel.ResponseInProgress)
        {
            // Enabled so the user can cancel the response.
            return true;
        }
        else
        {
            // Enabled if the user has entered a message.
            return !string.IsNullOrEmpty(ChatViewModel.UserMessage);
        }
    }
 
    private async Task HandleSubmit()
    {
        if (!ChatViewModel.ResponseInProgress)
        {
            if (!string.IsNullOrWhiteSpace(ChatViewModel.UserMessage))
            {
                var message = ChatViewModel.UserMessage.Trim();
                await AddPrompt(message, message, fromFollowUpPrompt: false);
            }
        }
        else
        {
            ChatViewModel.CancelResponseInProgress();
        }
    }
 
    private async Task InitializedAsync(CancellationToken token)
    {
        CreateModelMenuItems();
 
        await InvokeAsync(async () =>
        {
            if (ModelInitialized.HasDelegate)
            {
                await ModelInitialized.InvokeAsync(null);
            }
        });
    }
 
    private async Task ContextChangedAsync(CancellationToken token)
    {
        await InvokeAsync(StateHasChanged);
    }
 
    private async Task ConversationChangedAsync(ChatViewModel? chatViewModel, ResponseState responseState, CancellationToken token)
    {
        try
        {
            await InvokeAsync(() =>
            {
                if (responseState == ResponseState.Starting)
                {
                    // Scroll to the end. This places the new message at the top of the chat window.
                    _scrollInstruction = ScrollInstruction.ScrollImmediately;
                }
 
                if (chatViewModel != null && _currentChatMessageId != chatViewModel.ElementId && !chatViewModel.IsComplete)
                {
                    _currentChatMessageId = chatViewModel.ElementId;
                    _currentChatMessageChanged = true;
                }
 
                if (responseState == ResponseState.AddedPlaceHolders)
                {
                    _currentChatMessageComplete = true;
                }
 
                StateHasChanged();
            });
        }
        catch (Exception ex) when (_cts.Token.IsCancellationRequested)
        {
            // Ignore errors after cancellation.
            Logger.LogTrace(ex, "Error updating UI after cancellation.");
        }
    }
 
    private async Task SetStatusAsync(string toolName, string statusText, CancellationToken cancellationToken)
    {
        await InvokeAsync(StateHasChanged);
    }
 
    private async Task OnInitialPromptAsync(InitialPrompt item)
    {
        // Ensure prompt is started successfully and isn't interfered with by FocusAsync by starting prompt first.
        var task = ChatViewModel.InitialPromptSelectedAsync(item);
 
        if (_chatMessageTextBox is { } textbox)
        {
            await textbox.FocusAsync();
        }
 
        await task;
    }
 
    private async Task OnNextStepItemAsync(FollowUpPromptViewModel item)
    {
        // Ensure prompt is started successfully and isn't interfered with by FocusAsync by starting prompt first.
        var followUpPromptTask = AddPrompt(item.Text, item.Text, fromFollowUpPrompt: true);
 
        if (_chatMessageTextBox is { } textbox)
        {
            await textbox.FocusAsync();
        }
 
        await followUpPromptTask;
    }
 
    private void RefreshFollowUpPrompts()
    {
        _chatViewModel?.RefreshFollowUpPrompts();
    }
 
    private async Task AddPrompt(string displayText, string promptText, bool fromFollowUpPrompt)
    {
        await ChatViewModel.AddFollowUpPromptAsync(displayText, promptText, fromFollowUpPrompt);
 
        await ChatViewModel.CallServiceAsync();
    }
 
    public async ValueTask DisposeAsync()
    {
        await ChatViewModel.DisconnectAsync();
 
        _cts.Cancel();
 
        await JSInteropHelpers.SafeDisposeAsync(_module);
    }
 
    private void LikeChatMessage(ChatViewModel chat)
    {
        chat.IsLiked = !chat.IsLiked;
        chat.IsDisliked = false;
 
        if (chat.IsLiked)
        {
            ChatViewModel.PostFeedback(FeedbackType.ThumbsUp);
        }
    }
 
    private void DislikeChatMessage(ChatViewModel chat)
    {
        chat.IsDisliked = !chat.IsDisliked;
        chat.IsLiked = false;
 
        if (chat.IsDisliked)
        {
            ChatViewModel.PostFeedback(FeedbackType.ThumbsDown);
        }
    }
 
    private async Task RetryChatMessageAsync(ChatViewModel chat)
    {
        await ChatViewModel.RetryChatMessageAsync(chat);
    }
}