Added chat UI

This commit is contained in:
Thorsten Sommer 2024-05-04 11:11:09 +02:00
parent 862ec9c36a
commit bb663d1b73
Signed by: tsommer
GPG Key ID: 371BBA77A02C0108
12 changed files with 631 additions and 20 deletions

View File

@ -0,0 +1,64 @@
using MudBlazor;
namespace AIStudio.Chat;
/// <summary>
/// Possible roles in the chat.
/// </summary>
public enum ChatRole
{
NONE,
UNKNOWN,
SYSTEM,
USER,
AI,
}
/// <summary>
/// Extensions for the ChatRole enum.
/// </summary>
public static class ExtensionsChatRole
{
/// <summary>
/// Returns the name of the role.
/// </summary>
/// <param name="role">The role.</param>
/// <returns>The name of the role.</returns>
public static string ToName(this ChatRole role) => role switch
{
ChatRole.SYSTEM => "System",
ChatRole.USER => "You",
ChatRole.AI => "AI",
_ => "Unknown",
};
/// <summary>
/// Returns the color of the role.
/// </summary>
/// <param name="role">The role.</param>
/// <returns>The color of the role.</returns>
public static Color ToColor(this ChatRole role) => role switch
{
ChatRole.SYSTEM => Color.Info,
ChatRole.USER => Color.Primary,
ChatRole.AI => Color.Tertiary,
_ => Color.Error,
};
/// <summary>
/// Returns the icon of the role.
/// </summary>
/// <param name="role">The role.</param>
/// <returns>The icon of the role.</returns>
public static string ToIcon(this ChatRole role) => role switch
{
ChatRole.SYSTEM => Icons.Material.Filled.Settings,
ChatRole.USER => Icons.Material.Filled.Person,
ChatRole.AI => Icons.Material.Filled.AutoAwesome,
_ => Icons.Material.Filled.Help,
};
}

View File

@ -0,0 +1,31 @@
namespace AIStudio.Chat;
/// <summary>
/// Data structure for a chat thread.
/// </summary>
/// <param name="name">The name of the chat thread.</param>
/// <param name="seed">The seed for the chat thread. Some providers use this to generate deterministic results.</param>
/// <param name="systemPrompt">The system prompt for the chat thread.</param>
/// <param name="blocks">The content blocks of the chat thread.</param>
public sealed class ChatThread(string name, int seed, string systemPrompt, IEnumerable<ContentBlock> blocks)
{
/// <summary>
/// The name of the chat thread. Usually generated by an AI model or manually edited by the user.
/// </summary>
public string Name { get; set; } = name;
/// <summary>
/// The seed for the chat thread. Some providers use this to generate deterministic results.
/// </summary>
public int Seed { get; set; } = seed;
/// <summary>
/// The current system prompt for the chat thread.
/// </summary>
public string SystemPrompt { get; set; } = systemPrompt;
/// <summary>
/// The content blocks of the chat thread.
/// </summary>
public List<ContentBlock> Blocks { get; init; } = blocks.ToList();
}

View File

@ -0,0 +1,30 @@
namespace AIStudio.Chat;
/// <summary>
/// A block of content in a chat thread. Might be any type of content, e.g., text, image, voice, etc.
/// </summary>
/// <param name="time">Time when the content block was created.</param>
/// <param name="type">Type of the content block, e.g., text, image, voice, etc.</param>
/// <param name="content">The content of the block.</param>
public class ContentBlock(DateTimeOffset time, ContentType type, IContent content)
{
/// <summary>
/// Time when the content block was created.
/// </summary>
public DateTimeOffset Time => time;
/// <summary>
/// Type of the content block, e.g., text, image, voice, etc.
/// </summary>
public ContentType ContentType => type;
/// <summary>
/// The content of the block.
/// </summary>
public IContent Content => content;
/// <summary>
/// The role of the content block in the chat thread, e.g., user, AI, etc.
/// </summary>
public ChatRole Role { get; init; } = ChatRole.NONE;
}

View File

@ -3,16 +3,58 @@
<MudCard Class="my-2 rounded-lg" Outlined="@true">
<MudCardHeader>
<CardHeaderAvatar>
<MudAvatar Color="Color.Primary">Y</MudAvatar>
<MudAvatar Color="@this.Role.ToColor()">
<MudIcon Icon="@this.Role.ToIcon()"/>
</MudAvatar>
</CardHeaderAvatar>
<CardHeaderContent>
<MudText Typo="Typo.body1">You</MudText>
<MudText Typo="Typo.body1">@this.Role.ToName() (@this.Time)</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy" Color="Color.Default" />
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy" Color="Color.Default" OnClick="@this.CopyToClipboard" />
</CardHeaderActions>
</MudCardHeader>
<MudCardContent>
<MudText Typo="Typo.body2">bla bla bla</MudText>
@if (!this.HideContent)
{
if (this.Content.IsStreaming)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-6" />
}
switch (this.Type)
{
case ContentType.TEXT:
if (this.Content is ContentText textContent)
{
if (textContent.InitialRemoteWait)
{
<MudSkeleton Width="30%" Height="42px;"/>
<MudSkeleton Width="80%"/>
<MudSkeleton Width="100%"/>
}
else
{
<MudMarkdown Value="@textContent.Text"/>
}
}
break;
case ContentType.IMAGE:
if (this.Content is ContentImage imageContent)
{
<MudImage Src="@imageContent.URL"/>
}
break;
default:
<MudText Typo="Typo.body2">
Cannot render content of type @this.Type yet.
</MudText>
break;
}
}
</MudCardContent>
</MudCard>

View File

@ -1,7 +1,113 @@
using AIStudio.Tools;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using MudBlazor;
namespace AIStudio.Chat;
/// <summary>
/// The UI component for a chat content block, i.e., for any IContent.
/// </summary>
public partial class ContentBlockComponent : ComponentBase
{
/// <summary>
/// The role of the chat content block.
/// </summary>
[Parameter]
public ChatRole Role { get; init; } = ChatRole.NONE;
/// <summary>
/// The content.
/// </summary>
[Parameter]
public IContent Content { get; init; } = new ContentText();
/// <summary>
/// The content type.
/// </summary>
[Parameter]
public ContentType Type { get; init; } = ContentType.NONE;
/// <summary>
/// When was the content created?
/// </summary>
[Parameter]
public DateTimeOffset Time { get; init; }
[Inject]
private Rust Rust { get; init; } = null!;
[Inject]
private IJSRuntime JsRuntime { get; init; } = null!;
[Inject]
private ISnackbar Snackbar { get; init; } = null!;
private bool HideContent { get; set; }
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
// Register the streaming events:
this.Content.StreamingDone = this.AfterStreaming;
this.Content.StreamingEvent = () => this.InvokeAsync(this.StateHasChanged);
await base.OnInitializedAsync();
}
/// <summary>
/// Gets called when the content stream ended.
/// </summary>
private async Task AfterStreaming()
{
// Might be called from a different thread, so we need to invoke the UI thread:
await this.InvokeAsync(() =>
{
//
// Issue we try to solve: When the content changes during streaming,
// Blazor might fail to see all changes made to the render tree.
// This happens mostly when Markdown code blocks are streamed.
//
// Hide the content for a short time:
this.HideContent = true;
// Let Blazor update the UI, i.e., to see the render tree diff:
this.StateHasChanged();
// Show the content again:
this.HideContent = false;
// Let Blazor update the UI, i.e., to see the render tree diff:
this.StateHasChanged();
});
}
#endregion
/// <summary>
/// Copy this block's content to the clipboard.
/// </summary>
private async Task CopyToClipboard()
{
switch (this.Type)
{
case ContentType.TEXT:
var textContent = (ContentText) this.Content;
await this.Rust.CopyText2Clipboard(this.JsRuntime, this.Snackbar, textContent.Text);
break;
default:
this.Snackbar.Add("Cannot copy this content type to clipboard!", Severity.Error, config =>
{
config.Icon = Icons.Material.Filled.ContentCopy;
config.IconSize = Size.Large;
config.IconColor = Color.Error;
});
break;
}
}
}

View File

@ -0,0 +1,44 @@
using AIStudio.Provider;
using AIStudio.Settings;
using Microsoft.JSInterop;
namespace AIStudio.Chat;
/// <summary>
/// Represents an image inside the chat.
/// </summary>
public sealed class ContentImage : IContent
{
#region Implementation of IContent
/// <inheritdoc />
public bool InitialRemoteWait { get; set; } = false;
/// <inheritdoc />
public bool IsStreaming { get; set; } = false;
/// <inheritdoc />
public Func<Task> StreamingDone { get; set; } = () => Task.CompletedTask;
/// <inheritdoc />
public Func<Task> StreamingEvent { get; set; } = () => Task.CompletedTask;
/// <inheritdoc />
public Task CreateFromProviderAsync(IProvider provider, IJSRuntime jsRuntime, SettingsManager settings, Model chatModel, ChatThread chatChatThread, CancellationToken token = default)
{
throw new NotImplementedException();
}
#endregion
/// <summary>
/// The URL of the image.
/// </summary>
public string URL { get; set; } = string.Empty;
/// <summary>
/// The local path of the image.
/// </summary>
public string LocalPath { get; set; } = string.Empty;
}

View File

@ -0,0 +1,102 @@
using AIStudio.Provider;
using AIStudio.Settings;
using Microsoft.JSInterop;
namespace AIStudio.Chat;
/// <summary>
/// Text content in the chat.
/// </summary>
public sealed class ContentText : IContent
{
/// <summary>
/// The minimum time between two streaming events, when the user
/// enables the energy saving mode.
/// </summary>
private static readonly TimeSpan MIN_TIME = TimeSpan.FromSeconds(3);
#region Implementation of IContent
/// <inheritdoc />
public bool InitialRemoteWait { get; set; }
/// <inheritdoc />
public bool IsStreaming { get; set; }
/// <inheritdoc />
public Func<Task> StreamingDone { get; set; } = () => Task.CompletedTask;
public Func<Task> StreamingEvent { get; set; } = () => Task.CompletedTask;
/// <inheritdoc />
public async Task CreateFromProviderAsync(IProvider provider, IJSRuntime jsRuntime, SettingsManager settings, Model chatModel, ChatThread? chatThread, CancellationToken token = default)
{
if(chatThread is null)
return;
// Store the last time we got a response. We use this later,
// to determine whether we should notify the UI about the
// new content or not. Depends on the energy saving mode
// the user chose.
var last = DateTimeOffset.Now;
// Start another thread by using a task, to uncouple
// the UI thread from the AI processing:
await Task.Run(async () =>
{
// We show the waiting animation until we get the first response:
this.InitialRemoteWait = true;
// Iterate over the responses from the AI:
await foreach (var deltaText in provider.StreamChatCompletion(jsRuntime, settings, chatModel, chatThread, token))
{
// When the user cancels the request, we stop the loop:
if (token.IsCancellationRequested)
break;
// Stop the waiting animation:
this.InitialRemoteWait = false;
this.IsStreaming = true;
// Add the response to the text:
this.Text += deltaText;
// Notify the UI that the content has changed,
// depending on the energy saving mode:
var now = DateTimeOffset.Now;
switch (settings.ConfigurationData.IsSavingEnergy)
{
// Energy saving mode is off. We notify the UI
// as fast as possible -- no matter the odds:
case false:
await this.StreamingEvent();
break;
// Energy saving mode is on. We notify the UI
// only when the time between two events is
// greater than the minimum time:
case true when now - last > MIN_TIME:
last = now;
await this.StreamingEvent();
break;
}
}
// Stop the waiting animation (in case the loop
// was stopped or no content was received):
this.InitialRemoteWait = false;
this.IsStreaming = false;
}, token);
// Inform the UI that the streaming is done:
await this.StreamingDone();
}
#endregion
/// <summary>
/// The text content.
/// </summary>
public string Text { get; set; } = string.Empty;
}

View File

@ -0,0 +1,16 @@
namespace AIStudio.Chat;
/// <summary>
/// The content type of messages.
/// </summary>
public enum ContentType
{
NONE,
UNKNOWN,
TEXT,
IMAGE,
VIDEO,
AUDIO,
SPEECH,
}

View File

@ -0,0 +1,40 @@
using AIStudio.Provider;
using AIStudio.Settings;
using Microsoft.JSInterop;
namespace AIStudio.Chat;
/// <summary>
/// The interface for any content in the chat.
/// </summary>
public interface IContent
{
/// <summary>
/// Do we need to wait for the remote, i.e., the AI, to process the related request?
/// Does not indicate that the stream is finished; it only indicates that we are
/// waiting for the first response, i.e., wait for the remote to pick up the request.
/// </summary>
public bool InitialRemoteWait { get; set; }
/// <summary>
/// Indicates whether the content is streaming right now. False, if the content is
/// either static or the stream has finished.
/// </summary>
public bool IsStreaming { get; set; }
/// <summary>
/// An action that is called when the content was changed during streaming.
/// </summary>
public Func<Task> StreamingEvent { get; set; }
/// <summary>
/// An action that is called when the streaming is done.
/// </summary>
public Func<Task> StreamingDone { get; set; }
/// <summary>
/// Uses the provider to create the content.
/// </summary>
public Task CreateFromProviderAsync(IProvider provider, IJSRuntime jsRuntime, SettingsManager settings, Model chatModel, ChatThread chatChatThread, CancellationToken token = default);
}

View File

@ -0,0 +1,12 @@
namespace AIStudio.Chat;
/// <summary>
/// Data about a workspace.
/// </summary>
/// <param name="name">The name of the workspace.</param>
public sealed class Workspace(string name)
{
public string Name { get; set; } = name;
public List<ChatThread> Threads { get; set; } = new();
}

View File

@ -1,26 +1,27 @@
@page "/chat"
@using AIStudio.Chat
@using AIStudio.Settings
<MudText Typo="Typo.h3" Class="mb-12">Chats</MudText>
<MudText Typo="Typo.h3" Class="mb-2">Chats</MudText>
<MudSelect T="Provider" @bind-Value="@this.selectedProvider" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Apps" Margin="Margin.Dense" Label="Provider" Class="mb-2 rounded-lg" Variant="Variant.Outlined">
@foreach (var provider in this.SettingsManager.ConfigurationData.Providers)
{
<MudSelectItem Value="@provider"/>
}
</MudSelect>
<div class="d-flex flex-column" style="height: calc(100vh - 8.3em);">
<div class="d-flex flex-column" style="height: calc(100vh - 12.3em);">
<div class="flex-auto overflow-auto">
<ContentBlockComponent/>
<ContentBlockComponent/>
<ContentBlockComponent/>
<ContentBlockComponent/>
<ContentBlockComponent/>
<ContentBlockComponent/>
<ContentBlockComponent/>
<ContentBlockComponent/>
<ContentBlockComponent/>
<ContentBlockComponent/>
<ContentBlockComponent/>
<ContentBlockComponent/>
@if (this.chatThread is not null)
{
foreach (var block in this.chatThread.Blocks.OrderBy(n => n.Time))
{
<ContentBlockComponent Role="@block.Role" Type="@block.ContentType" Time="@block.Time" Content="@block.Content"/>
}
}
</div>
<MudPaper Style="flex: 0 0 auto;">
<MudTextField T="string" Variant="Variant.Filled" AutoGrow="@true" Lines="3" MaxLines="12" Label="Your Prompt" Placeholder="Type your question here..." Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Send" OnAdornmentClick="() => this.SendMessage()">
</MudTextField>
<MudTextField T="string" @ref="@this.inputField" @bind-Text="@this.userInput" Variant="Variant.Outlined" AutoGrow="@true" Lines="3" MaxLines="12" Label="@this.InputLabel" Placeholder="@this.ProviderPlaceholder" Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Send" OnAdornmentClick="() => this.SendMessage()" ReadOnly="!this.IsProviderSelected || this.isStreaming" Immediate="@true" OnKeyUp="this.InputKeyEvent"/>
</MudPaper>
</div>

View File

@ -1,4 +1,12 @@
using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Settings;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using MudBlazor;
namespace AIStudio.Components.Pages;
@ -7,7 +15,122 @@ namespace AIStudio.Components.Pages;
/// </summary>
public partial class Chat : ComponentBase
{
[Inject]
private SettingsManager SettingsManager { get; set; } = null!;
[Inject]
public IJSRuntime JsRuntime { get; init; } = null!;
[Inject]
public Random RNG { get; set; } = null!;
private AIStudio.Settings.Provider selectedProvider;
private ChatThread? chatThread;
private bool isStreaming;
private string userInput = string.Empty;
// Unfortunately, we need the input field reference to clear it after sending a message.
// This is necessary because we have to handle the key events ourselves. Otherwise,
// the clearing would be done automatically.
private MudTextField<string> inputField = null!;
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
// Ensure that the settings are loaded:
await this.SettingsManager.LoadSettings();
// For now, we just create a new chat thread.
// Later we want the chats to be persisted
// across page loads and organize them in
// a chat history & workspaces.
this.chatThread = new("Thread 1", this.RNG.Next(), "You are a helpful assistant!", []);
await base.OnInitializedAsync();
}
#endregion
private bool IsProviderSelected => this.selectedProvider.UsedProvider != Providers.NONE;
private string ProviderPlaceholder => this.IsProviderSelected ? "Type your input here..." : "Select a provider first";
private string InputLabel => this.IsProviderSelected ? $"Your Prompt (use selected instance '{this.selectedProvider.InstanceName}', provider '{this.selectedProvider.UsedProvider.ToName()}')" : "Select a provider first";
private async Task SendMessage()
{
if (!this.IsProviderSelected)
return;
//
// Add the user message to the thread:
//
var time = DateTimeOffset.Now;
this.chatThread?.Blocks.Add(new ContentBlock(time, ContentType.TEXT, new ContentText
{
// Text content properties:
Text = this.userInput,
})
{
// Content block properties:
Role = ChatRole.USER,
});
//
// Add the AI response to the thread:
//
var aiText = new ContentText
{
// We have to wait for the remote
// for the content stream:
InitialRemoteWait = true,
};
this.chatThread?.Blocks.Add(new ContentBlock(time, ContentType.TEXT, aiText)
{
Role = ChatRole.AI,
});
// Clear the input field:
await this.inputField.Clear();
this.userInput = string.Empty;
// Enable the stream state for the chat component:
this.isStreaming = true;
this.StateHasChanged();
// Use the selected provider to get the AI response.
// By awaiting this line, we wait for the entire
// content to be streamed.
await aiText.CreateFromProviderAsync(this.selectedProvider.UsedProvider.CreateProvider(), this.JsRuntime, this.SettingsManager, new Model("gpt-4-turbo-preview"), this.chatThread);
// Disable the stream state:
this.isStreaming = false;
this.StateHasChanged();
}
private async Task InputKeyEvent(KeyboardEventArgs keyEvent)
{
var key = keyEvent.Code.ToLowerInvariant();
// Was the enter key (either enter or numpad enter) pressed?
var isEnter = key is "enter" or "numpadenter";
// Was a modifier key pressed as well?
var isModifier = keyEvent.AltKey || keyEvent.CtrlKey || keyEvent.MetaKey || keyEvent.ShiftKey;
// Depending on the user's settings, might react to shortcuts:
switch (this.SettingsManager.ConfigurationData.ShortcutSendBehavior)
{
case SendBehavior.ENTER_IS_SENDING:
if (!isModifier && isEnter)
await this.SendMessage();
break;
case SendBehavior.MODIFER_ENTER_IS_SENDING:
if (isEnter && isModifier)
await this.SendMessage();
break;
}
}
}