diff --git a/app/MindWork AI Studio/Components/Blocks/TreeItemData.cs b/app/MindWork AI Studio/Components/Blocks/TreeItemData.cs index 7f825afa..30cc992d 100644 --- a/app/MindWork AI Studio/Components/Blocks/TreeItemData.cs +++ b/app/MindWork AI Studio/Components/Blocks/TreeItemData.cs @@ -10,6 +10,8 @@ public class TreeItemData : ITreeItem public string Icon { get; init; } = string.Empty; + public bool IsChat { get; init; } + public T? Value { get; init; } public bool Expandable { get; init; } = true; diff --git a/app/MindWork AI Studio/Components/Blocks/Workspaces.razor b/app/MindWork AI Studio/Components/Blocks/Workspaces.razor index 46a4169d..123e9ea7 100644 --- a/app/MindWork AI Studio/Components/Blocks/Workspaces.razor +++ b/app/MindWork AI Studio/Components/Blocks/Workspaces.razor @@ -9,21 +9,30 @@ break; case TreeItemData treeItem: - - -
- @treeItem.Text - - @if (treeItem.Value is not "root" and not "temp") - { + @if (treeItem.IsChat) + { + + +
+ @treeItem.Text
- } -
-
-
+
+
+
+ } + else + { + + +
+ @treeItem.Text +
+
+
+ } break; case TreeButton treeButton: diff --git a/app/MindWork AI Studio/Components/Blocks/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Blocks/Workspaces.razor.cs index e7c64fa1..81dbf051 100644 --- a/app/MindWork AI Studio/Components/Blocks/Workspaces.razor.cs +++ b/app/MindWork AI Studio/Components/Blocks/Workspaces.razor.cs @@ -1,4 +1,8 @@ -using AIStudio.Chat; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +using AIStudio.Chat; using AIStudio.Settings; using Microsoft.AspNetCore.Components; @@ -12,7 +16,23 @@ public partial class Workspaces : ComponentBase [Parameter] public ChatThread? CurrentChatThread { get; set; } + + [Parameter] + public EventCallback CurrentChatThreadChanged { get; set; } + private static readonly JsonSerializerOptions JSON_OPTIONS = new() + { + WriteIndented = true, + AllowTrailingCommas = true, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + Converters = + { + new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseUpper), + } + }; + private readonly HashSet> initialTreeItems = new(); #region Overrides of ComponentBase @@ -52,7 +72,7 @@ public partial class Workspaces : ComponentBase #endregion - private Task>> LoadServerData(ITreeItem? parent) + private async Task>> LoadServerData(ITreeItem? parent) { switch (parent) { @@ -77,11 +97,16 @@ public partial class Workspaces : ComponentBase // Enumerate the workspace directories: foreach (var workspaceDirPath in Directory.EnumerateDirectories(workspaceDirectories)) { + // Read the `name` file: + var workspaceNamePath = Path.Join(workspaceDirPath, "name"); + var workspaceName = await File.ReadAllTextAsync(workspaceNamePath, Encoding.UTF8); + workspaceChildren.Add(new TreeItemData { + IsChat = false, Depth = item.Depth + 1, Branch = WorkspaceBranch.WORKSPACES, - Text = Path.GetFileName(workspaceDirPath), + Text = workspaceName, Icon = Icons.Material.Filled.Description, Expandable = true, Value = workspaceDirPath, @@ -99,18 +124,23 @@ public partial class Workspaces : ComponentBase // Get the workspace directory: var workspaceDirPath = item.Value; - - if(workspaceDirPath is null) - return Task.FromResult(new HashSet>()); + + if (workspaceDirPath is null) + return []; // Enumerate the workspace directory: foreach (var chatPath in Directory.EnumerateDirectories(workspaceDirPath)) { + // Read the `name` file: + var chatNamePath = Path.Join(chatPath, "name"); + var chatName = await File.ReadAllTextAsync(chatNamePath, Encoding.UTF8); + workspaceChildren.Add(new TreeItemData { + IsChat = true, Depth = item.Depth + 1, Branch = WorkspaceBranch.WORKSPACES, - Text = Path.GetFileNameWithoutExtension(chatPath), + Text = chatName, Icon = Icons.Material.Filled.Chat, Expandable = false, Value = chatPath, @@ -120,7 +150,7 @@ public partial class Workspaces : ComponentBase workspaceChildren.Add(new TreeButton(WorkspaceBranch.WORKSPACES, item.Depth + 1, "Add chat",Icons.Material.Filled.Add)); } - return Task.FromResult(workspaceChildren); + return workspaceChildren; case WorkspaceBranch.TEMPORARY_CHATS: var tempChildren = new HashSet>(); @@ -138,24 +168,79 @@ public partial class Workspaces : ComponentBase // Enumerate the workspace directories: foreach (var tempChatDirPath in Directory.EnumerateDirectories(temporaryDirectories)) { + // Read the `name` file: + var chatNamePath = Path.Join(tempChatDirPath, "name"); + var chatName = await File.ReadAllTextAsync(chatNamePath, Encoding.UTF8); + tempChildren.Add(new TreeItemData { + IsChat = true, Depth = item.Depth + 1, Branch = WorkspaceBranch.TEMPORARY_CHATS, - Text = Path.GetFileName(tempChatDirPath), + Text = chatName, Icon = Icons.Material.Filled.Timer, Expandable = false, Value = tempChatDirPath, }); } - return Task.FromResult(tempChildren); + return tempChildren; } - return Task.FromResult(new HashSet>()); + return []; default: - return Task.FromResult(new HashSet>()); + return []; + } + } + + public async Task StoreChat(ChatThread thread) + { + string chatDirectory; + if (thread.WorkspaceId == Guid.Empty) + chatDirectory = Path.Join(SettingsManager.DataDirectory, "tempChats", thread.ChatId.ToString()); + else + chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", thread.WorkspaceId.ToString(), thread.ChatId.ToString()); + + // Ensure the directory exists: + Directory.CreateDirectory(chatDirectory); + + // Save the chat name: + var chatNamePath = Path.Join(chatDirectory, "name"); + await File.WriteAllTextAsync(chatNamePath, thread.Name); + + // Save the thread as thread.json: + var chatPath = Path.Join(chatDirectory, "thread.json"); + await File.WriteAllTextAsync(chatPath, JsonSerializer.Serialize(thread, JSON_OPTIONS), Encoding.UTF8); + } + + private async Task LoadChat(string? chatPath) + { + if(string.IsNullOrWhiteSpace(chatPath)) + { + Console.WriteLine("Error: chat path is empty."); + return; + } + + if(!Directory.Exists(chatPath)) + { + Console.WriteLine($"Error: chat not found: '{chatPath}'"); + return; + } + + try + { + var chatData = await File.ReadAllTextAsync(Path.Join(chatPath, "thread.json"), Encoding.UTF8); + this.CurrentChatThread = JsonSerializer.Deserialize(chatData, JSON_OPTIONS); + await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); + + Console.WriteLine($"Loaded chat: {this.CurrentChatThread?.Name}"); + } + catch (Exception e) + { + Console.WriteLine(e); + Console.WriteLine(e.StackTrace); + throw; } } } \ 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 e1c73870..d482e6b4 100644 --- a/app/MindWork AI Studio/Components/Pages/Chat.razor +++ b/app/MindWork AI Studio/Components/Pages/Chat.razor @@ -29,9 +29,27 @@ - - + + @if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES) + { + + + + } + + @if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY) + { + + + + } + + + + + + @@ -49,7 +67,7 @@ - + } \ 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 2cec8c29..2877e167 100644 --- a/app/MindWork AI Studio/Components/Pages/Chat.razor.cs +++ b/app/MindWork AI Studio/Components/Pages/Chat.razor.cs @@ -1,4 +1,5 @@ using AIStudio.Chat; +using AIStudio.Components.Blocks; using AIStudio.Provider; using AIStudio.Settings; @@ -28,6 +29,7 @@ public partial class Chat : ComponentBase private bool isStreaming; private string userInput = string.Empty; private bool workspacesVisible; + private Workspaces? workspaces = null; // 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, @@ -41,11 +43,6 @@ public partial class Chat : ComponentBase // Configure the spellchecking for the user input: this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES); - // 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(); } @@ -57,25 +54,44 @@ public partial class Chat : ComponentBase private string InputLabel => this.IsProviderSelected ? $"Your Prompt (use selected instance '{this.selectedProvider.InstanceName}', provider '{this.selectedProvider.UsedProvider.ToName()}')" : "Select a provider first"; + private bool CanThreadBeSaved => this.IsProviderSelected && this.chatThread is not null && this.chatThread.Blocks.Count > 0; + private async Task SendMessage() { if (!this.IsProviderSelected) return; + // Create a new chat thread if necessary: + var threadName = this.ExtractThreadName(this.userInput); + this.chatThread ??= new() + { + WorkspaceId = Guid.Empty, + ChatId = Guid.NewGuid(), + Name = threadName, + Seed = this.RNG.Next(), + SystemPrompt = "You are a helpful assistant!", + Blocks = [], + }; + // // Add the user message to the thread: // var time = DateTimeOffset.Now; - this.chatThread?.Blocks.Add(new ContentBlock(time, ContentType.TEXT, new ContentText + this.chatThread?.Blocks.Add(new ContentBlock { - // Text content properties: - Text = this.userInput, - }) - { - // Content block properties: + Time = time, + ContentType = ContentType.TEXT, Role = ChatRole.USER, + Content = new ContentText + { + Text = this.userInput, + }, }); + // Save the chat: + if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) + await this.SaveThread(); + // // Add the AI response to the thread: // @@ -86,9 +102,12 @@ public partial class Chat : ComponentBase InitialRemoteWait = true, }; - this.chatThread?.Blocks.Add(new ContentBlock(time, ContentType.TEXT, aiText) + this.chatThread?.Blocks.Add(new ContentBlock { + Time = time, + ContentType = ContentType.TEXT, Role = ChatRole.AI, + Content = aiText, }); // Clear the input field: @@ -104,6 +123,10 @@ public partial class Chat : ComponentBase // content to be streamed. await aiText.CreateFromProviderAsync(this.selectedProvider.UsedProvider.CreateProvider(this.selectedProvider.InstanceName, this.selectedProvider.Hostname), this.JsRuntime, this.SettingsManager, this.selectedProvider.Model, this.chatThread); + // Save the chat: + if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) + await this.SaveThread(); + // Disable the stream state: this.isStreaming = false; this.StateHasChanged(); @@ -138,4 +161,31 @@ public partial class Chat : ComponentBase { this.workspacesVisible = !this.workspacesVisible; } + + private async Task SaveThread() + { + if(this.workspaces is null) + return; + + if(this.chatThread is null) + return; + + if (!this.CanThreadBeSaved) + return; + + await this.workspaces.StoreChat(this.chatThread); + } + + private string ExtractThreadName(string firstUserInput) + { + // We select the first 10 words of the user input: + var words = firstUserInput.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var threadName = string.Join(' ', words.Take(10)); + + // If the thread name is empty, we use a default name: + if (string.IsNullOrWhiteSpace(threadName)) + threadName = "Thread"; + + return threadName; + } } \ No newline at end of file