| 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); } }