Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<PackageReference Include="Bit.Bswup" Version="9.11.4" />
<PackageReference Include="Bit.Butil" Version="9.11.4" />
<PackageReference Include="Bit.BlazorUI" Version="9.11.4" />
<PackageReference Include="Bit.BlazorUI.Extras" Version="9.11.4" />
<PackageReference Include="Bit.BlazorES2019" Version="9.11.4" />
<PackageReference Include="Bit.BlazorUI.Assets" Version="9.11.4" />
<PackageReference Include="Bit.CodeAnalyzers" Version="9.11.4">
Expand All @@ -47,6 +48,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="9.0.8" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.8" />

<Using Include="System.Net.Http.Json" />
<Using Include="System.Collections.Concurrent" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
namespace Microsoft.Extensions.Configuration;

public static class IConfigurationExtensions
{
public static string GetApiServerAddress(this IConfiguration configuration)
{
var apiServerAddress = configuration.GetValue("ApiServerAddress", defaultValue: "api/")!;
var apiServerAddress = configuration.GetValue("ApiServerAddress", defaultValue: "").TrimEnd("/").ToString();

return Uri.TryCreate(apiServerAddress, UriKind.RelativeOrAbsolute, out _) ? apiServerAddress : throw new InvalidOperationException($"Api server address {apiServerAddress} is invalid");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Bit.Butil;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.AspNetCore.Http.Connections;
using Bit.Websites.Platform.Client.Services.HttpMessageHandlers;

namespace Microsoft.Extensions.DependencyInjection;
Expand All @@ -24,6 +26,24 @@ public static IServiceCollection AddClientSharedServices(this IServiceCollection

services.AddTransient<MessageBoxService>();

services.AddBitBlazorUIExtrasServices();

services.AddScoped(sp =>
{
var baseAddress = sp.GetRequiredService<HttpClient>().BaseAddress!;

var hubConnection = new HubConnectionBuilder()
.WithStatefulReconnect()
.WithAutomaticReconnect()
.WithUrl(new Uri(baseAddress, "/app-hub"), options =>
{
options.SkipNegotiation = true;
options.Transports = HttpTransportType.WebSockets;
})
.Build();
return hubConnection;
});

return services;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ private async Task SendMessage()

try
{
await HttpClient.PostAsJsonAsync("ContactUs/SendMessage", contactUsModel, AppJsonContext.Default.ContactUsDto);
await HttpClient.PostAsJsonAsync("api/ContactUs/SendMessage", contactUsModel, AppJsonContext.Default.ContactUsDto);
contactUsModel.Email = "";
contactUsModel.Message = "";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ private async Task SendMessage()

try
{
await HttpClient.PostAsJsonAsync("SupportPackage/BuyPackage", buyPackageModel, AppJsonContext.Default.BuyPackageDto);
await HttpClient.PostAsJsonAsync("api/SupportPackage/BuyPackage", buyPackageModel, AppJsonContext.Default.BuyPackageDto);
}
finally
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
@using Bit.Websites.Platform.Shared.Dtos.AiChat
@inherits AppComponentBase

<BitMediaQuery ScreenQuery="BitScreenQuery.LtSm" OnChange="isMatched => isSmallScreen = isMatched" />

<section>
<BitButton Float
Draggable
FloatOffset="1rem"
Class="open-panel-button"
Variant="BitVariant.Outline"
OnClick="() => isOpen = true"
Color="BitColor.PrimaryBackground"
IconUrl="/images/ai-chat-icon-64.webp"
FloatPosition="BitPosition.BottomRight" />

<BitProPanel ShowCloseButton
@bind-IsOpen="isOpen"
ModeFull="isSmallScreen is true"
Modeless="isSmallScreen is false"
OnDismiss="WrapHandled(HandleOnDismissPanel)">
<Header>
<BitStack Horizontal Alignment="BitAlignment.Center">
<BitText Typography="BitTypography.H5">AI chat panel</BitText>
@if (isLoading)
{
<BitRollingSquareLoading Size="BitSize.Small" />
}
<BitSpacer />
<BitButton IconOnly
FixedColor
Title="Clear"
IconName="Delete"
Style="padding:10px"
Variant="BitVariant.Text"
OnClick="WrapHandled(ClearChat)"
Color="BitColor.SecondaryBackground" />
</BitStack>
</Header>
<Body>
<BitStack Class="body">
<BitScrollablePane FullSize
AutoScroll
Class="scr-container"
ScrollbarWidth="BitScrollbarWidth.Thin">
<BitStack>
@foreach (var message in chatMessages)
{
if (message.Role is AiChatMessageRole.User)
{
<BitCard Background="BitColorKind.Tertiary" Class="user-message">
<BitText Element="pre">@message.Content</BitText>
</BitCard>
}
else
{
<BitMarkdownViewer Markdown="@message.Content" />
@if (message.Successful is false)
{
<BitTag Color="BitColor.Error" Size="BitSize.Small" Style="min-height:18px">Canceled</BitTag>
}
}
}

@if (isLoading && string.IsNullOrWhiteSpace(lastAssistantMessage?.Content))
{
<BitEllipsisLoading Size="BitSize.Small" />
}
</BitStack>
</BitScrollablePane>

@if (chatMessages.Count == 1)
{
<BitStack Alignment=" BitAlignment.Center" FitHeight FillContent Class="default-prompt-container">
<BitButton FixedColor
Variant="BitVariant.Outline"
Class="default-prompt-button"
Color="BitColor.SecondaryBackground"
OnClick="() => SendPromptMessage(AiChatPanelPrompt1)">
@AiChatPanelPrompt1
</BitButton>

<BitButton FixedColor
Variant="BitVariant.Outline"
Class="default-prompt-button"
Color="BitColor.SecondaryBackground"
OnClick="() => SendPromptMessage(AiChatPanelPrompt2)">
@AiChatPanelPrompt2
</BitButton>

<BitButton FixedColor
Variant="BitVariant.Outline"
Class="default-prompt-button"
Color="BitColor.SecondaryBackground"
OnClick="() => SendPromptMessage(AiChatPanelPrompt3)">
@AiChatPanelPrompt3
</BitButton>
</BitStack>
}

<BitStack FitHeight Style="position:relative">
<BitTextField Rows="1"
Immediate
Multiline
AutoHeight
PreventEnter
MaxLength="1024"
Style="width:100%"
@bind-Value="@userInput"
Placeholder="Write a message...."
OnEnter="WrapHandled((KeyboardEventArgs e) => HandleOnUserInputEnter(e))"
Styles="@(new() { FieldGroup = "padding:0.5rem; padding-inline-end:2.5rem", Input = "min-height:unset" })" />

<BitButton Float
AutoLoading
FloatAbsolute
Title="Send"
IconName="Up"
FloatOffset="0.5rem"
Class="send-message-button"
OnClick="WrapHandled(SendMessage)"
IsEnabled=@(string.IsNullOrEmpty(userInput) is false) />
</BitStack>
</BitStack>
</Body>
</BitProPanel>
</section>
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
using System.Threading.Channels;
using Bit.Websites.Platform.Shared.Dtos.AiChat;
using Bit.Websites.Platform.Shared.Services;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.SignalR.Client;

namespace Bit.Websites.Platform.Client.Shared;

public partial class AppAiChatPanel
{
[CascadingParameter] public BitDir? CurrentDir { get; set; }

[AutoInject] private HubConnection hubConnection = default!;


private bool isOpen;
private bool isLoading;
private string? userInput;
private bool isSmallScreen;
private int responseCounter;
private Channel<string>? channel;
private AiChatMessage? lastAssistantMessage;
private List<AiChatMessage> chatMessages = []; // TODO: Persist these values in client-side storage to retain them across app restarts.

private string AiChatPanelPrompt1 = "What does bitplatform MIT license mean? Is it free to use?";
private string AiChatPanelPrompt2 = "What are the benefits of dedicated support?";
private string AiChatPanelPrompt3 = "What does bit Besql do?";


protected override async Task OnAfterFirstRenderAsync()
{
SetDefaultValues();
StateHasChanged();
hubConnection.Reconnected += HubConnection_Reconnected;

await base.OnAfterFirstRenderAsync();
}


private async Task HubConnection_Reconnected(string? _)
{
if (channel is null) return;

await RestartChannel();
}

private async Task SendPromptMessage(string message)
{
userInput = message;
await SendMessage();
}

private async Task SendMessage()
{
if (string.IsNullOrWhiteSpace(userInput)) return;

if (hubConnection.State is not HubConnectionState.Connected)
{
await hubConnection.StartAsync(CurrentCancellationToken);
}

if (channel is null)
{
_ = StartChannel();
}

isLoading = true;

var input = userInput;
userInput = string.Empty;

chatMessages.Add(new() { Content = input, Role = AiChatMessageRole.User });
lastAssistantMessage = new() { Role = AiChatMessageRole.Assistant };
chatMessages.Add(lastAssistantMessage);

StateHasChanged();

await channel!.Writer.WriteAsync(input!, CurrentCancellationToken);
}

private async Task ClearChat()
{
SetDefaultValues();

await RestartChannel();
}

private void SetDefaultValues()
{
isLoading = false;
responseCounter = 0;
lastAssistantMessage = new() { Role = AiChatMessageRole.Assistant };
chatMessages = [
new()
{
Role = AiChatMessageRole.Assistant,
Content = "I'm here to make your app experience awesome! Got a question or need a hand?",
}
];
}

private async Task HandleOnDismissPanel()
{
await StopChannel();
}

private async Task HandleOnUserInputEnter(KeyboardEventArgs e)
{
if (e.ShiftKey) return;

await SendMessage();
}

private async Task StartChannel()
{
channel = Channel.CreateUnbounded<string>(new() { SingleReader = true, SingleWriter = true });

await foreach (var response in hubConnection.StreamAsync<string>("Chatbot",
new StartChatbotRequest()
{
ChatMessagesHistory = chatMessages
},
channel.Reader.ReadAllAsync(CurrentCancellationToken),
cancellationToken: CurrentCancellationToken))
{
int expectedResponsesCount = chatMessages.Count(c => c.Role is AiChatMessageRole.User);

if (response is SharedChatProcessMessages.MESSAGE_RPOCESS_SUCESS)
{
responseCounter++;
isLoading = false;
}
else if (response is SharedChatProcessMessages.MESSAGE_RPOCESS_ERROR)
{
responseCounter++;
if (responseCounter == expectedResponsesCount)
{
isLoading = false; // Hide loading only if this is an error for the last user's message.
}
chatMessages[responseCounter * 2].Successful = false;
}
else
{
if ((responseCounter + 1) == expectedResponsesCount)
{
lastAssistantMessage!.Content += response;
}
}

StateHasChanged();
}
}

private async Task StopChannel()
{
if (channel is null) return;

channel.Writer.Complete();
channel = null;
}

private async Task RestartChannel()
{
await StopChannel();
await StartChannel();
}

protected override async ValueTask DisposeAsync(bool disposing)
{
hubConnection.Reconnected -= HubConnection_Reconnected;

await StopChannel();

await base.DisposeAsync(disposing);
}
}
Loading
Loading