From bb663d1b73e5f0a1897c9484bc97ef5ae9cde7dd Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sat, 4 May 2024 11:11:09 +0200 Subject: [PATCH] Added chat UI --- app/MindWork AI Studio/Chat/ChatRole.cs | 64 +++++++++ app/MindWork AI Studio/Chat/ChatThread.cs | 31 +++++ app/MindWork AI Studio/Chat/ContentBlock.cs | 30 +++++ .../Chat/ContentBlockComponent.razor | 50 ++++++- .../Chat/ContentBlockComponent.razor.cs | 106 +++++++++++++++ app/MindWork AI Studio/Chat/ContentImage.cs | 44 +++++++ app/MindWork AI Studio/Chat/ContentText.cs | 102 +++++++++++++++ app/MindWork AI Studio/Chat/ContentType.cs | 16 +++ app/MindWork AI Studio/Chat/IContent.cs | 40 ++++++ app/MindWork AI Studio/Chat/Workspace.cs | 12 ++ .../Components/Pages/Chat.razor | 33 ++--- .../Components/Pages/Chat.razor.cs | 123 ++++++++++++++++++ 12 files changed, 631 insertions(+), 20 deletions(-) create mode 100644 app/MindWork AI Studio/Chat/ChatRole.cs create mode 100644 app/MindWork AI Studio/Chat/ChatThread.cs create mode 100644 app/MindWork AI Studio/Chat/ContentBlock.cs create mode 100644 app/MindWork AI Studio/Chat/ContentImage.cs create mode 100644 app/MindWork AI Studio/Chat/ContentText.cs create mode 100644 app/MindWork AI Studio/Chat/ContentType.cs create mode 100644 app/MindWork AI Studio/Chat/IContent.cs create mode 100644 app/MindWork AI Studio/Chat/Workspace.cs diff --git a/app/MindWork AI Studio/Chat/ChatRole.cs b/app/MindWork AI Studio/Chat/ChatRole.cs new file mode 100644 index 0000000..763b432 --- /dev/null +++ b/app/MindWork AI Studio/Chat/ChatRole.cs @@ -0,0 +1,64 @@ +using MudBlazor; + +namespace AIStudio.Chat; + +/// +/// Possible roles in the chat. +/// +public enum ChatRole +{ + NONE, + UNKNOWN, + + SYSTEM, + USER, + AI, +} + +/// +/// Extensions for the ChatRole enum. +/// +public static class ExtensionsChatRole +{ + /// + /// Returns the name of the role. + /// + /// The role. + /// The name of the role. + public static string ToName(this ChatRole role) => role switch + { + ChatRole.SYSTEM => "System", + ChatRole.USER => "You", + ChatRole.AI => "AI", + + _ => "Unknown", + }; + + /// + /// Returns the color of the role. + /// + /// The role. + /// The color of the role. + public static Color ToColor(this ChatRole role) => role switch + { + ChatRole.SYSTEM => Color.Info, + ChatRole.USER => Color.Primary, + ChatRole.AI => Color.Tertiary, + + _ => Color.Error, + }; + + /// + /// Returns the icon of the role. + /// + /// The role. + /// The icon of the role. + 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, + }; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/ChatThread.cs b/app/MindWork AI Studio/Chat/ChatThread.cs new file mode 100644 index 0000000..7ecb6a2 --- /dev/null +++ b/app/MindWork AI Studio/Chat/ChatThread.cs @@ -0,0 +1,31 @@ +namespace AIStudio.Chat; + +/// +/// Data structure for a chat thread. +/// +/// The name of the chat thread. +/// The seed for the chat thread. Some providers use this to generate deterministic results. +/// The system prompt for the chat thread. +/// The content blocks of the chat thread. +public sealed class ChatThread(string name, int seed, string systemPrompt, IEnumerable blocks) +{ + /// + /// The name of the chat thread. Usually generated by an AI model or manually edited by the user. + /// + public string Name { get; set; } = name; + + /// + /// The seed for the chat thread. Some providers use this to generate deterministic results. + /// + public int Seed { get; set; } = seed; + + /// + /// The current system prompt for the chat thread. + /// + public string SystemPrompt { get; set; } = systemPrompt; + + /// + /// The content blocks of the chat thread. + /// + public List Blocks { get; init; } = blocks.ToList(); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/ContentBlock.cs b/app/MindWork AI Studio/Chat/ContentBlock.cs new file mode 100644 index 0000000..65e1f29 --- /dev/null +++ b/app/MindWork AI Studio/Chat/ContentBlock.cs @@ -0,0 +1,30 @@ +namespace AIStudio.Chat; + +/// +/// A block of content in a chat thread. Might be any type of content, e.g., text, image, voice, etc. +/// +/// Time when the content block was created. +/// Type of the content block, e.g., text, image, voice, etc. +/// The content of the block. +public class ContentBlock(DateTimeOffset time, ContentType type, IContent content) +{ + /// + /// Time when the content block was created. + /// + public DateTimeOffset Time => time; + + /// + /// Type of the content block, e.g., text, image, voice, etc. + /// + public ContentType ContentType => type; + + /// + /// The content of the block. + /// + public IContent Content => content; + + /// + /// The role of the content block in the chat thread, e.g., user, AI, etc. + /// + public ChatRole Role { get; init; } = ChatRole.NONE; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor index f103336..cda11f9 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor @@ -3,16 +3,58 @@ - Y + + + - You + @this.Role.ToName() (@this.Time) - + - bla bla bla + @if (!this.HideContent) + { + if (this.Content.IsStreaming) + { + + } + + switch (this.Type) + { + case ContentType.TEXT: + if (this.Content is ContentText textContent) + { + if (textContent.InitialRemoteWait) + { + + + + } + else + { + + } + } + + break; + + case ContentType.IMAGE: + if (this.Content is ContentImage imageContent) + { + + } + + break; + + default: + + Cannot render content of type @this.Type yet. + + break; + } + } \ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs index 08fc3f5..8ab4c89 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs @@ -1,7 +1,113 @@ +using AIStudio.Tools; + using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +using MudBlazor; namespace AIStudio.Chat; +/// +/// The UI component for a chat content block, i.e., for any IContent. +/// public partial class ContentBlockComponent : ComponentBase { + /// + /// The role of the chat content block. + /// + [Parameter] + public ChatRole Role { get; init; } = ChatRole.NONE; + + /// + /// The content. + /// + [Parameter] + public IContent Content { get; init; } = new ContentText(); + + /// + /// The content type. + /// + [Parameter] + public ContentType Type { get; init; } = ContentType.NONE; + + /// + /// When was the content created? + /// + [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(); + } + + /// + /// Gets called when the content stream ended. + /// + 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 + + /// + /// Copy this block's content to the clipboard. + /// + 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; + } + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/ContentImage.cs b/app/MindWork AI Studio/Chat/ContentImage.cs new file mode 100644 index 0000000..ccd845c --- /dev/null +++ b/app/MindWork AI Studio/Chat/ContentImage.cs @@ -0,0 +1,44 @@ +using AIStudio.Provider; +using AIStudio.Settings; + +using Microsoft.JSInterop; + +namespace AIStudio.Chat; + +/// +/// Represents an image inside the chat. +/// +public sealed class ContentImage : IContent +{ + #region Implementation of IContent + + /// + public bool InitialRemoteWait { get; set; } = false; + + /// + public bool IsStreaming { get; set; } = false; + + /// + public Func StreamingDone { get; set; } = () => Task.CompletedTask; + + /// + public Func StreamingEvent { get; set; } = () => Task.CompletedTask; + + /// + public Task CreateFromProviderAsync(IProvider provider, IJSRuntime jsRuntime, SettingsManager settings, Model chatModel, ChatThread chatChatThread, CancellationToken token = default) + { + throw new NotImplementedException(); + } + + #endregion + + /// + /// The URL of the image. + /// + public string URL { get; set; } = string.Empty; + + /// + /// The local path of the image. + /// + public string LocalPath { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/ContentText.cs b/app/MindWork AI Studio/Chat/ContentText.cs new file mode 100644 index 0000000..b926c46 --- /dev/null +++ b/app/MindWork AI Studio/Chat/ContentText.cs @@ -0,0 +1,102 @@ +using AIStudio.Provider; +using AIStudio.Settings; + +using Microsoft.JSInterop; + +namespace AIStudio.Chat; + +/// +/// Text content in the chat. +/// +public sealed class ContentText : IContent +{ + /// + /// The minimum time between two streaming events, when the user + /// enables the energy saving mode. + /// + private static readonly TimeSpan MIN_TIME = TimeSpan.FromSeconds(3); + + #region Implementation of IContent + + /// + public bool InitialRemoteWait { get; set; } + + /// + public bool IsStreaming { get; set; } + + /// + public Func StreamingDone { get; set; } = () => Task.CompletedTask; + + public Func StreamingEvent { get; set; } = () => Task.CompletedTask; + + /// + 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 + + /// + /// The text content. + /// + public string Text { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/ContentType.cs b/app/MindWork AI Studio/Chat/ContentType.cs new file mode 100644 index 0000000..c39ad6e --- /dev/null +++ b/app/MindWork AI Studio/Chat/ContentType.cs @@ -0,0 +1,16 @@ +namespace AIStudio.Chat; + +/// +/// The content type of messages. +/// +public enum ContentType +{ + NONE, + UNKNOWN, + + TEXT, + IMAGE, + VIDEO, + AUDIO, + SPEECH, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/IContent.cs b/app/MindWork AI Studio/Chat/IContent.cs new file mode 100644 index 0000000..228b967 --- /dev/null +++ b/app/MindWork AI Studio/Chat/IContent.cs @@ -0,0 +1,40 @@ +using AIStudio.Provider; +using AIStudio.Settings; + +using Microsoft.JSInterop; + +namespace AIStudio.Chat; + +/// +/// The interface for any content in the chat. +/// +public interface IContent +{ + /// + /// 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. + /// + public bool InitialRemoteWait { get; set; } + + /// + /// Indicates whether the content is streaming right now. False, if the content is + /// either static or the stream has finished. + /// + public bool IsStreaming { get; set; } + + /// + /// An action that is called when the content was changed during streaming. + /// + public Func StreamingEvent { get; set; } + + /// + /// An action that is called when the streaming is done. + /// + public Func StreamingDone { get; set; } + + /// + /// Uses the provider to create the content. + /// + public Task CreateFromProviderAsync(IProvider provider, IJSRuntime jsRuntime, SettingsManager settings, Model chatModel, ChatThread chatChatThread, CancellationToken token = default); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/Workspace.cs b/app/MindWork AI Studio/Chat/Workspace.cs new file mode 100644 index 0000000..0dad2b9 --- /dev/null +++ b/app/MindWork AI Studio/Chat/Workspace.cs @@ -0,0 +1,12 @@ +namespace AIStudio.Chat; + +/// +/// Data about a workspace. +/// +/// The name of the workspace. +public sealed class Workspace(string name) +{ + public string Name { get; set; } = name; + + public List Threads { get; set; } = new(); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Pages/Chat.razor b/app/MindWork AI Studio/Components/Pages/Chat.razor index b004101..f9367ad 100644 --- a/app/MindWork AI Studio/Components/Pages/Chat.razor +++ b/app/MindWork AI Studio/Components/Pages/Chat.razor @@ -1,26 +1,27 @@ @page "/chat" @using AIStudio.Chat +@using AIStudio.Settings -Chats +Chats + + @foreach (var provider in this.SettingsManager.ConfigurationData.Providers) + { + + } + -
+
- - - - - - - - - - - - + @if (this.chatThread is not null) + { + foreach (var block in this.chatThread.Blocks.OrderBy(n => n.Time)) + { + + } + }
- - +
\ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Pages/Chat.razor.cs b/app/MindWork AI Studio/Components/Pages/Chat.razor.cs index 5dad1c7..9072b68 100644 --- a/app/MindWork AI Studio/Components/Pages/Chat.razor.cs +++ b/app/MindWork AI Studio/Components/Pages/Chat.razor.cs @@ -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; /// 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 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; + } } } \ No newline at end of file