diff --git a/ChatRPG/API/HttpClientFactory.cs b/ChatRPG/API/HttpClientFactory.cs new file mode 100644 index 0000000..dc55acb --- /dev/null +++ b/ChatRPG/API/HttpClientFactory.cs @@ -0,0 +1,16 @@ +namespace ChatRPG.API; + +public class HttpClientFactory : IHttpClientFactory +{ + private HttpMessageHandlerFactory _httpMessageHandlerFactory; + + public HttpClientFactory(HttpMessageHandlerFactory httpMessageHandlerFactory) + { + _httpMessageHandlerFactory = httpMessageHandlerFactory; + } + + public HttpClient CreateClient(string name) + { + return new HttpClient(_httpMessageHandlerFactory.CreateHandler()); + } +} diff --git a/ChatRPG/API/HttpMessageHandlerFactory.cs b/ChatRPG/API/HttpMessageHandlerFactory.cs index ee8d8af..4d874d5 100644 --- a/ChatRPG/API/HttpMessageHandlerFactory.cs +++ b/ChatRPG/API/HttpMessageHandlerFactory.cs @@ -18,18 +18,18 @@ public HttpMessageHandler CreateHandler(string name) return new HttpClientHandler(); } - var mockHttpMessageHandler = new MockHttpMessageHandler(); - mockHttpMessageHandler.When("*") + MockHttpMessageHandler messageHandler = new MockHttpMessageHandler(); + messageHandler.When("*") .Respond(GenerateMockResponse); - return mockHttpMessageHandler; + return messageHandler; } private static HttpResponseMessage GenerateMockResponse(HttpRequestMessage request) { Console.Write("Please enter mocked API response: "); - var input = Console.ReadLine(); - var responseContent = new StringContent($$""" + string? input = Console.ReadLine(); + StringContent responseContent = new StringContent($$""" { "id": "chatcmpl-000", "object": "chat.completion", diff --git a/ChatRPG/API/IFoodWasteClient.cs b/ChatRPG/API/IFoodWasteClient.cs deleted file mode 100644 index 9e9b6c7..0000000 --- a/ChatRPG/API/IFoodWasteClient.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace ChatRPG.API; - -public interface IFoodWasteClient -{ - Task> GetFoodwasteResponse(string zip); -} - -public record Offer(string Currency, double Discount, string Ean, DateTime EndTime, DateTime LastUpdate, - double NewPrice, double OriginalPrice, double PercentDiscount, DateTime StartTime, double Stock, string StockUnit); - -public record Product(string Description, string Ean, string Image); - -public record Clearance(Offer Offer, Product Product); - -public record Address(string City, string Country, string Extra, string Street, string Zip); - -public record Hour(DateOnly Date, string Type, DateTime Open, string Close, bool Closed, List CustomerFlow); - -public record Store(Address Address, string Brand, List Coordinates, List Hours, string Name, string Id, string Type); - -public record FoodWasteResponse(List Clearances, Store Store); diff --git a/ChatRPG/API/IOpenAiLlmClient.cs b/ChatRPG/API/IOpenAiLlmClient.cs index 71b0cc1..9f4c1f9 100644 --- a/ChatRPG/API/IOpenAiLlmClient.cs +++ b/ChatRPG/API/IOpenAiLlmClient.cs @@ -2,17 +2,8 @@ namespace ChatRPG.API; public interface IOpenAiLlmClient { - Task GetChatCompletion(List inputs); + Task GetChatCompletion(params OpenAiGptMessage[] inputs); + IAsyncEnumerable GetStreamedChatCompletion(params OpenAiGptMessage[] inputs); } -public record ChatCompletionObject(string Id, string Object, int Created, string Model, Choice[] Choices, Usage Usage); - -public record Choice(int Index, Message Message, string FinishReason); - -public record Message(string Role, string Content); - -public record Usage(int PromptTokens, int CompletionTokens, int TotalTokens); - -public record OpenAiGptInputMessage(string Role, string Content); - -public record OpenAiGptInput(string Model, List Messages, double Temperature); +public record OpenAiGptMessage(string Role, string Content); diff --git a/ChatRPG/API/OpenAiLlmClient.cs b/ChatRPG/API/OpenAiLlmClient.cs index 79fc9aa..1324eef 100644 --- a/ChatRPG/API/OpenAiLlmClient.cs +++ b/ChatRPG/API/OpenAiLlmClient.cs @@ -1,101 +1,48 @@ using Microsoft.IdentityModel.Tokens; -using RestSharp; -using RestSharp.Authenticators; +using OpenAI_API; +using OpenAI_API.Chat; namespace ChatRPG.API; -public class OpenAiLlmClient : IOpenAiLlmClient, IDisposable +public class OpenAiLlmClient : IOpenAiLlmClient { - private const string OpenAiBaseUrl = "https://api.openai.com/v1/"; private const string Model = "gpt-3.5-turbo"; private const double Temperature = 0.7; - private const double PromptToken1KCost = 0.0015; - private const double CompletionToken1KCost = 0.002; - private readonly ILogger _logger; - private readonly RestClient _client; + private readonly OpenAIAPI _openAiApi; - public OpenAiLlmClient(ILogger logger, IConfiguration configuration, - HttpMessageHandler httpMessageHandler) + public OpenAiLlmClient(IConfiguration configuration, IHttpClientFactory httpClientFactory) { - _logger = logger; - - var options = new RestClientOptions(OpenAiBaseUrl) - { - Authenticator = new JwtAuthenticator(configuration.GetSection("ApiKeys").GetValue("OpenAI") ?? string.Empty), - FailOnDeserializationError = false, - ConfigureMessageHandler = _ => httpMessageHandler - }; - _client = new RestClient(options); + _openAiApi = new OpenAIAPI(configuration.GetSection("ApiKeys")?.GetValue("OpenAI")); + _openAiApi.Chat.DefaultChatRequestArgs.Model = Model; + _openAiApi.Chat.DefaultChatRequestArgs.Temperature = Temperature; + _openAiApi.HttpClientFactory = httpClientFactory; } - public async Task GetChatCompletion(List inputs) + public async Task GetChatCompletion(params OpenAiGptMessage[] inputs) { - if (inputs.IsNullOrEmpty()) throw new ArgumentNullException(nameof(inputs)); - - var openAiGptInput = new OpenAiGptInput(Model, inputs, Temperature); + Conversation chat = CreateConversation(inputs); - var request = new RestRequest("chat/completions", Method.Post); - request.AddJsonBody(openAiGptInput, ContentType.Json); - - _logger.LogInformation(""" - Request URL: {Url} - Method: {Method} - Parameters: {Parameters} - Messages: {Messages} - """, - OpenAiBaseUrl + request.Resource, - request.Method, - string.Join(", ", request.Parameters.Select(p => $"{p.Name}={p.Value}")), - string.Join(", ", inputs.Select(input => input.Content)) - ); + return await chat.GetResponseFromChatbotAsync(); + } - var response = await _client.ExecuteAsync(request); + public IAsyncEnumerable GetStreamedChatCompletion(params OpenAiGptMessage[] inputs) + { + Conversation chat = CreateConversation(inputs); - if (response.ErrorException != null) - { - _logger.LogError("Error retrieving data from API: {ErrorExceptionMessage}", response.ErrorException.Message); - } + return chat.StreamResponseEnumerableFromChatbotAsync(); + } - var data = response!.Data; + private Conversation CreateConversation(params OpenAiGptMessage[] messages) + { + if (messages.IsNullOrEmpty()) throw new ArgumentNullException(nameof(messages)); - if (data is null) + Conversation chat = _openAiApi.Chat.CreateConversation(); + foreach (OpenAiGptMessage openAiGptInputMessage in messages) { - throw new EmptyResponseException(response, "The API response has no data."); + chat.AppendMessage(ChatMessageRole.FromString(openAiGptInputMessage.Role), openAiGptInputMessage.Content); } - var promptTokens = data.Usage.PromptTokens; - var completionTokens = data.Usage.CompletionTokens; - - var promptCost = (promptTokens / 1000.0) * PromptToken1KCost; - var completionCost = (completionTokens / 1000.0) * CompletionToken1KCost; - var estimatedCost = promptCost + completionCost; - - _logger.LogInformation(""" - Prompt tokens: {PTokens} - Completion tokens: {CTokens} - Estimated cost: {EstCost} - """, - promptTokens, - completionTokens, - "$" + estimatedCost - ); - - return data; - } - - public void Dispose() - { - _client.Dispose(); - GC.SuppressFinalize(this); - } -} - -public class EmptyResponseException : Exception -{ - public RestResponse Response; - public EmptyResponseException(RestResponse response, string message) : base(message) - { - Response = response; + return chat; } } diff --git a/ChatRPG/API/SallingClient.cs b/ChatRPG/API/SallingClient.cs deleted file mode 100644 index 824a6f4..0000000 --- a/ChatRPG/API/SallingClient.cs +++ /dev/null @@ -1,55 +0,0 @@ -using RestSharp; -using RestSharp.Authenticators; - -namespace ChatRPG.API; - -public class SallingClient : IFoodWasteClient, IDisposable -{ - private const string SallingBaseUrl = "https://api.sallinggroup.com/v1/"; - private readonly ILogger _logger; - private readonly RestClient _client; - - public SallingClient(ILogger logger, IConfiguration configuration) - { - _logger = logger; - - var options = new RestClientOptions(SallingBaseUrl) - { - Authenticator = new JwtAuthenticator(configuration.GetSection("ApiKeys").GetValue("Salling")), - FailOnDeserializationError = false - }; - _client = new RestClient(options); - } - - public async Task> GetFoodwasteResponse(string zip) - { - var request = new RestRequest("food-waste/", Method.Get); - request.AddQueryParameter("zip", zip); - - _logger.LogInformation(""" - Request URL: {Url} - Method: {Method} - Parameters: {Parameters} - """, - SallingBaseUrl + request.Resource, - request.Method, - string.Join(", ", request.Parameters.Select(p => $"{p.Name}={p.Value}")) - ); - - - var response = await _client.ExecuteAsync>(request); - - if (response.ErrorException != null) - { - _logger.LogError($"Error retrieving data from API: {response.ErrorException.Message}"); - } - - return response!.Data; - } - - public void Dispose() - { - _client.Dispose(); - GC.SuppressFinalize(this); - } -} diff --git a/ChatRPG/Areas/Identity/Pages/Account/LogOut.cshtml b/ChatRPG/Areas/Identity/Pages/Account/LogOut.cshtml index 21b7fe2..0addf87 100644 --- a/ChatRPG/Areas/Identity/Pages/Account/LogOut.cshtml +++ b/ChatRPG/Areas/Identity/Pages/Account/LogOut.cshtml @@ -1,5 +1,4 @@ @page -@using Microsoft.AspNetCore.Identity @attribute [IgnoreAntiforgeryToken] @inject SignInManager SignInManager @functions { diff --git a/ChatRPG/Areas/Identity/Pages/Account/Login.cshtml b/ChatRPG/Areas/Identity/Pages/Account/Login.cshtml index 6354164..6946e7c 100644 --- a/ChatRPG/Areas/Identity/Pages/Account/Login.cshtml +++ b/ChatRPG/Areas/Identity/Pages/Account/Login.cshtml @@ -1,4 +1,5 @@ @page +@using Microsoft.AspNetCore.Authentication @model LoginModel @{ @@ -65,7 +66,7 @@

- @foreach (var provider in Model.ExternalLogins!) + @foreach (AuthenticationScheme provider in Model.ExternalLogins!) { } diff --git a/ChatRPG/Areas/Identity/Pages/Account/Login.cshtml.cs b/ChatRPG/Areas/Identity/Pages/Account/Login.cshtml.cs index 2034743..c22bcd0 100644 --- a/ChatRPG/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/ChatRPG/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -2,19 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. #nullable disable -using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; using ChatRPG.Data.Models; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.Extensions.Logging; +using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; namespace ChatRPG.Areas.Identity.Pages.Account { @@ -112,7 +106,7 @@ public async Task OnPostAsync(string returnUrl = null) { // This doesn't count login failures towards account lockout // To enable password failures to trigger account lockout, set lockoutOnFailure: true - var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false); + SignInResult result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false); if (result.Succeeded) { _logger.LogInformation("User logged in."); diff --git a/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml b/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml index 6fbfc8f..0fdd818 100644 --- a/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml +++ b/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml @@ -1,4 +1,5 @@ @page +@using Microsoft.AspNetCore.Authentication @model RegisterModel @{ ViewData["Title"] = "Register"; @@ -49,7 +50,7 @@

- @foreach (var provider in Model.ExternalLogins!) + @foreach (AuthenticationScheme provider in Model.ExternalLogins!) { } diff --git a/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml.cs b/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml.cs index 1406bcc..e7539d6 100644 --- a/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -2,23 +2,16 @@ // The .NET Foundation licenses this file to you under the MIT license. #nullable disable -using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Linq; using System.Text; using System.Text.Encodings.Web; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; using ChatRPG.Data.Models; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.Logging; namespace ChatRPG.Areas.Identity.Pages.Account { @@ -113,20 +106,20 @@ public async Task OnPostAsync(string returnUrl = null) ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); if (ModelState.IsValid) { - var user = CreateUser(); + User user = CreateUser(); await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None); await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None); - var result = await _userManager.CreateAsync(user, Input.Password); + IdentityResult result = await _userManager.CreateAsync(user, Input.Password); if (result.Succeeded) { _logger.LogInformation("User created a new account with password."); - var userId = await _userManager.GetUserIdAsync(user); - var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); + string userId = await _userManager.GetUserIdAsync(user); + string code = await _userManager.GenerateEmailConfirmationTokenAsync(user); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); - var callbackUrl = Url.Page( + string callbackUrl = Url.Page( "/Account/ConfirmEmail", pageHandler: null, values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl }, @@ -145,7 +138,7 @@ await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", return LocalRedirect(returnUrl); } } - foreach (var error in result.Errors) + foreach (IdentityError error in result.Errors) { ModelState.AddModelError(string.Empty, error.Description); } diff --git a/ChatRPG/Areas/Identity/Pages/Shared/_LoginPartial.cshtml b/ChatRPG/Areas/Identity/Pages/Shared/_LoginPartial.cshtml index 2fc6411..d71541d 100644 --- a/ChatRPG/Areas/Identity/Pages/Shared/_LoginPartial.cshtml +++ b/ChatRPG/Areas/Identity/Pages/Shared/_LoginPartial.cshtml @@ -1,6 +1,4 @@ -@using ChatRPG.Data.Models -@using Microsoft.AspNetCore.Identity -@inject SignInManager SignInManager +@inject SignInManager SignInManager @inject UserManager UserManager @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/ChatRPG/Areas/Identity/RevalidatingIdentityAuthenticationStateProvider.cs b/ChatRPG/Areas/Identity/RevalidatingIdentityAuthenticationStateProvider.cs index 4cfbe76..10e59d2 100644 --- a/ChatRPG/Areas/Identity/RevalidatingIdentityAuthenticationStateProvider.cs +++ b/ChatRPG/Areas/Identity/RevalidatingIdentityAuthenticationStateProvider.cs @@ -28,10 +28,10 @@ protected override async Task ValidateAuthenticationStateAsync( AuthenticationState authenticationState, CancellationToken cancellationToken) { // Get the user manager from a new scope to ensure it fetches fresh data - var scope = _scopeFactory.CreateScope(); + IServiceScope scope = _scopeFactory.CreateScope(); try { - var userManager = scope.ServiceProvider.GetRequiredService>(); + UserManager userManager = scope.ServiceProvider.GetRequiredService>(); return await ValidateSecurityStampAsync(userManager, authenticationState.User); } finally @@ -49,20 +49,18 @@ protected override async Task ValidateAuthenticationStateAsync( private async Task ValidateSecurityStampAsync(UserManager userManager, ClaimsPrincipal principal) { - var user = await userManager.GetUserAsync(principal); + TUser? user = await userManager.GetUserAsync(principal); if (user == null) { return false; } - else if (!userManager.SupportsUserSecurityStamp) + if (!userManager.SupportsUserSecurityStamp) { return true; } - else - { - var principalStamp = principal.FindFirstValue(_options.ClaimsIdentity.SecurityStampClaimType); - var userStamp = await userManager.GetSecurityStampAsync(user); - return principalStamp == userStamp; - } + + string? principalStamp = principal.FindFirstValue(_options.ClaimsIdentity.SecurityStampClaimType); + string userStamp = await userManager.GetSecurityStampAsync(user); + return principalStamp == userStamp; } } diff --git a/ChatRPG/ChatRPG.csproj b/ChatRPG/ChatRPG.csproj index 7a7185e..f93f0ed 100644 --- a/ChatRPG/ChatRPG.csproj +++ b/ChatRPG/ChatRPG.csproj @@ -24,7 +24,7 @@ - + diff --git a/ChatRPG/Data/Models/Character.cs b/ChatRPG/Data/Models/Character.cs index 574d035..c899c66 100644 --- a/ChatRPG/Data/Models/Character.cs +++ b/ChatRPG/Data/Models/Character.cs @@ -34,7 +34,7 @@ public Character(Campaign campaign, CharacterType type, string name, string desc public string Description { get; private set; } = null!; public int MaxHealth { get; private set; } public int CurrentHealth { get; private set; } - public ICollection CharacterAbilities { get; } = new List(); + public ICollection CharacterAbilities { get; } = new List(); ///

/// Adjust the current health of this character. @@ -54,9 +54,9 @@ public void AdjustHealth(int value) /// /// The ability to add. /// The created entity. - public CharacterAbility AddAbility(Ability ability) + public CharacterAbility? AddAbility(Ability ability) { - var charAbility = CharacterAbilities.FirstOrDefault(a => a!.Ability == ability, null); + CharacterAbility? charAbility = CharacterAbilities.FirstOrDefault(a => a!.Ability == ability, null); if (charAbility is not null) { return charAbility; diff --git a/ChatRPG/Pages/ApiAccess.razor b/ChatRPG/Pages/ApiAccess.razor deleted file mode 100644 index 3d3eb40..0000000 --- a/ChatRPG/Pages/ApiAccess.razor +++ /dev/null @@ -1,77 +0,0 @@ -@page "/ApiAccess" -@using ChatRPG.API -@using Microsoft.IdentityModel.Tokens -@inject ILogger Logger -@inject IOpenAiLlmClient OpenAiLlmClient; -@inject IFoodWasteClient FoodWasteClient; - -

ApiAccess

- -

OpenAI GPT 3.5 Turbo

-
- -
-
- - -@if (_openAiResponse is not null) -{ -

Response: @_openAiResponse.Choices.First().Message.Content

-} - -


- -

Salling Food-waste

-
- -
-
- - -@if (_foodWasteResponses is not null) -{ -

Stores in @_zip with food waste information:

- - @foreach (var item in _foodWasteResponses) - { -
-

@item.Store.Name, @item.Store.Address.Street

- @foreach (var clearance in item.Clearances) - { -

Produkt: @clearance.Product.Description

-
Tilbudspris: @clearance.Offer.NewPrice, Originalpris: @clearance.Offer.OriginalPrice
- -
- } -
-
- } -} -@code { - private string _input; - private ChatCompletionObject _openAiResponse; - private string _zip; - private List _foodWasteResponses; - - private async Task OpenAiInput(Microsoft.AspNetCore.Components.ChangeEventArgs patharg) - { - _input = (string)patharg.Value; - } - - private async Task CallOpenAiApiWithInput() - { - var input = new List { new ("user", _input) }; - - _openAiResponse = await OpenAiLlmClient.GetChatCompletion(input); - } - - private async Task SallingInput(Microsoft.AspNetCore.Components.ChangeEventArgs patharg) - { - _zip = (string)patharg.Value; - } - - private async Task CallFoodWasteApiWithZip() - { - _foodWasteResponses = await FoodWasteClient.GetFoodwasteResponse(_zip); - } -} diff --git a/ChatRPG/Pages/Campaign.razor b/ChatRPG/Pages/Campaign.razor index 9b4c65c..4aba160 100644 --- a/ChatRPG/Pages/Campaign.razor +++ b/ChatRPG/Pages/Campaign.razor @@ -1,6 +1,8 @@ @page "/Campaign" @using ChatRPG.Data @using ChatRPG.API +@using Microsoft.IdentityModel.Tokens +@using OpenAiGptMessage = ChatRPG.API.OpenAiGptMessage @inject IConfiguration Configuration @inject IOpenAiLlmClient OpenAiLlmClient; @inject IJSRuntime JsRuntime @@ -14,11 +16,14 @@
- @foreach (string message in _conversation) + @foreach (OpenAiGptMessage message in _conversation) { - string prefix = message.StartsWith("Player: ") ? "user" : "assistant"; -
-

@message

+ + } + @if (!_tempMessage.IsNullOrEmpty()) + { +
+

Assistant: @_tempMessage

}
@@ -27,9 +32,9 @@
+ @bind="_userInput" placeholder="What do you do?" @onkeyup="@EnterKeyHandler" disabled="@_isWaitingForResponse"/>
-
@@ -43,8 +48,11 @@ private bool _shouldSave; private IJSObjectReference? _scrollJsScript; private FileUtility? _fileUtil; - readonly List _conversation = new List(); + readonly List _conversation = new(); private string _userInput = ""; + private string _tempMessage = ""; + private bool _shouldStream; + private bool _isWaitingForResponse; protected override async Task OnInitializedAsync() { @@ -52,7 +60,15 @@ _loggedInUsername = authenticationState.User.Identity?.Name; if (_loggedInUsername != null) _fileUtil = new FileUtility(_loggedInUsername); _shouldSave = Configuration.GetValue("SaveConversationsToFile"); - _scrollJsScript = await JsRuntime.InvokeAsync("import", "./js/scroll.js"); + _shouldStream = !Configuration.GetValue("UseMocks") && Configuration.GetValue("StreamChatCompletions"); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _scrollJsScript ??= await JsRuntime.InvokeAsync("import", "./js/scroll.js"); + } } async Task EnterKeyHandler(KeyboardEventArgs e) @@ -65,35 +81,53 @@ private async Task SendPrompt() { - if (!string.IsNullOrWhiteSpace(_userInput)) + if (string.IsNullOrWhiteSpace(_userInput)) { - // Use the user input to generate a response - _conversation.Add($"Player: {_userInput}"); - string response = await ProcessUserInput(_userInput); - _conversation.Add($"Assistant: {response}"); - if (_shouldSave && _fileUtil != null) - { - await _fileUtil.UpdateSaveFileAsync(new MessagePair(_userInput, response)); - } - - // Clear the user input - _userInput = ""; - - // Notify the component to re-render because of async methods - StateHasChanged(); + return; + } + _isWaitingForResponse = true; - // Scroll to bottom of page - await ScrollToElement("bottom-id"); + OpenAiGptMessage userInput = new OpenAiGptMessage("user", _userInput); + _conversation.Add(userInput); + + if (_shouldStream) + { + await HandleStreamedResponse(OpenAiLlmClient.GetStreamedChatCompletion(userInput)); } + else + { + string response = await OpenAiLlmClient.GetChatCompletion(userInput); + HandleResponse(response); + } + + if (_shouldSave && _fileUtil != null) + { + string assistantOutput = _conversation.Last().Content; + await _fileUtil.UpdateSaveFileAsync(new MessagePair(_userInput, assistantOutput)); + } + + _userInput = ""; + StateHasChanged(); + await ScrollToElement("bottom-id"); + _isWaitingForResponse = false; } - async Task ProcessUserInput(string input) + private void HandleResponse(string response) { - List inputMessage = new List { new OpenAiGptInputMessage("user", input) }; - - ChatCompletionObject response = await OpenAiLlmClient.GetChatCompletion(inputMessage); + OpenAiGptMessage assistantOutput = new OpenAiGptMessage("assistant", response); + _conversation.Add(assistantOutput); + } - return response.Choices[0].Message.Content; + private async Task HandleStreamedResponse(IAsyncEnumerable streamedResponse) + { + await foreach (string res in streamedResponse) + { + _tempMessage += res; + StateHasChanged(); + await ScrollToElement("bottom-id"); + } + HandleResponse(_tempMessage); + _tempMessage = ""; } private async Task ScrollToElement(string elementId) diff --git a/ChatRPG/Pages/OpenAiGptMessageComponent.razor b/ChatRPG/Pages/OpenAiGptMessageComponent.razor new file mode 100644 index 0000000..ad88bdf --- /dev/null +++ b/ChatRPG/Pages/OpenAiGptMessageComponent.razor @@ -0,0 +1,24 @@ +@using ChatRPG.API +@inherits ComponentBase + +
+

@MessagePrefix@OpenAiGptMessage.Content

+
+ +@code { + [Parameter] + public required OpenAiGptMessage OpenAiGptMessage { get; set; } + + private string MessagePrefix + { + get + { + return OpenAiGptMessage.Role switch + { + "assistant" => "Assistant: ", + "user" => "Player: ", + _ => "" + }; + } + } +} diff --git a/ChatRPG/Pages/Shared/_Layout.cshtml b/ChatRPG/Pages/Shared/_Layout.cshtml index 92fcc1a..1118ede 100644 --- a/ChatRPG/Pages/Shared/_Layout.cshtml +++ b/ChatRPG/Pages/Shared/_Layout.cshtml @@ -32,7 +32,7 @@