diff --git a/app/MindWork AI Studio/Chat/ChatThread.cs b/app/MindWork AI Studio/Chat/ChatThread.cs index 7ecb6a20..8c5f3819 100644 --- a/app/MindWork AI Studio/Chat/ChatThread.cs +++ b/app/MindWork AI Studio/Chat/ChatThread.cs @@ -3,29 +3,35 @@ 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) +public sealed class ChatThread { + /// + /// The unique identifier of the chat thread. + /// + public Guid ChatId { get; init; } + + /// + /// The unique identifier of the workspace. + /// + public Guid WorkspaceId { get; set; } + /// /// The name of the chat thread. Usually generated by an AI model or manually edited by the user. /// - public string Name { get; set; } = name; + public string Name { get; set; } = string.Empty; /// /// The seed for the chat thread. Some providers use this to generate deterministic results. /// - public int Seed { get; set; } = seed; + public int Seed { get; init; } /// /// The current system prompt for the chat thread. /// - public string SystemPrompt { get; set; } = systemPrompt; - + public string SystemPrompt { get; init; } = string.Empty; + /// /// The content blocks of the chat thread. /// - public List Blocks { get; init; } = blocks.ToList(); + public List Blocks { get; init; } = []; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/ContentBlock.cs b/app/MindWork AI Studio/Chat/ContentBlock.cs index 65e1f290..05a6bd1f 100644 --- a/app/MindWork AI Studio/Chat/ContentBlock.cs +++ b/app/MindWork AI Studio/Chat/ContentBlock.cs @@ -3,25 +3,22 @@ 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) +public class ContentBlock { /// /// Time when the content block was created. /// - public DateTimeOffset Time => time; + public DateTimeOffset Time { get; init; } /// /// Type of the content block, e.g., text, image, voice, etc. /// - public ContentType ContentType => type; + public ContentType ContentType { get; init; } = ContentType.NONE; /// /// The content of the block. /// - public IContent Content => content; + public IContent? Content { get; init; } = null; /// /// The role of the content block in the chat thread, e.g., user, AI, etc. diff --git a/app/MindWork AI Studio/Chat/ContentImage.cs b/app/MindWork AI Studio/Chat/ContentImage.cs index 247c4848..9afe4476 100644 --- a/app/MindWork AI Studio/Chat/ContentImage.cs +++ b/app/MindWork AI Studio/Chat/ContentImage.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + using AIStudio.Provider; using AIStudio.Settings; @@ -11,15 +13,19 @@ public sealed class ContentImage : IContent #region Implementation of IContent /// + [JsonIgnore] public bool InitialRemoteWait { get; set; } = false; /// + [JsonIgnore] public bool IsStreaming { get; set; } = false; /// + [JsonIgnore] public Func StreamingDone { get; set; } = () => Task.CompletedTask; /// + [JsonIgnore] public Func StreamingEvent { get; set; } = () => Task.CompletedTask; /// diff --git a/app/MindWork AI Studio/Chat/ContentText.cs b/app/MindWork AI Studio/Chat/ContentText.cs index 24c7b63b..f125d4b7 100644 --- a/app/MindWork AI Studio/Chat/ContentText.cs +++ b/app/MindWork AI Studio/Chat/ContentText.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + using AIStudio.Provider; using AIStudio.Settings; @@ -17,14 +19,19 @@ public sealed class ContentText : IContent #region Implementation of IContent /// + [JsonIgnore] public bool InitialRemoteWait { get; set; } /// + // [JsonIgnore] public bool IsStreaming { get; set; } /// + [JsonIgnore] public Func StreamingDone { get; set; } = () => Task.CompletedTask; + /// + [JsonIgnore] public Func StreamingEvent { get; set; } = () => Task.CompletedTask; /// diff --git a/app/MindWork AI Studio/Chat/IContent.cs b/app/MindWork AI Studio/Chat/IContent.cs index e8dcd4d6..8f6bc0ad 100644 --- a/app/MindWork AI Studio/Chat/IContent.cs +++ b/app/MindWork AI Studio/Chat/IContent.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + using AIStudio.Provider; using AIStudio.Settings; @@ -6,6 +8,8 @@ namespace AIStudio.Chat; /// /// The interface for any content in the chat. /// +[JsonDerivedType(typeof(ContentText), typeDiscriminator: "text")] +[JsonDerivedType(typeof(ContentImage), typeDiscriminator: "image")] public interface IContent { /// @@ -13,22 +17,26 @@ public interface IContent /// 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. /// + [JsonIgnore] 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. /// + [JsonIgnore] public bool IsStreaming { get; set; } /// /// An action that is called when the content was changed during streaming. /// + [JsonIgnore] public Func StreamingEvent { get; set; } /// /// An action that is called when the streaming is done. /// + [JsonIgnore] public Func StreamingDone { get; set; } /// diff --git a/app/MindWork AI Studio/Components/Blocks/Changelog.Logs.cs b/app/MindWork AI Studio/Components/Blocks/Changelog.Logs.cs index 0a166d69..50b50bb9 100644 --- a/app/MindWork AI Studio/Components/Blocks/Changelog.Logs.cs +++ b/app/MindWork AI Studio/Components/Blocks/Changelog.Logs.cs @@ -13,6 +13,7 @@ public readonly record struct Log(int Build, string Display, string Filename) public static readonly Log[] LOGS = [ + new (160, "v0.7.0, build 160 (2024-07-13 08:21 UTC)", "v0.7.0.md"), new (159, "v0.6.3, build 159 (2024-07-03 18:26 UTC)", "v0.6.3.md"), new (158, "v0.6.2, build 158 (2024-07-01 18:03 UTC)", "v0.6.2.md"), new (157, "v0.6.1, build 157 (2024-06-30 19:00 UTC)", "v0.6.1.md"), diff --git a/app/MindWork AI Studio/Components/Blocks/ITreeItem.cs b/app/MindWork AI Studio/Components/Blocks/ITreeItem.cs new file mode 100644 index 00000000..ad718e71 --- /dev/null +++ b/app/MindWork AI Studio/Components/Blocks/ITreeItem.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Components.Blocks; + +public interface ITreeItem; \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor.cs b/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor.cs index 4dee88c7..22483e78 100644 --- a/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor.cs +++ b/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor.cs @@ -51,6 +51,11 @@ protected override async Task OnInitializedAsync() return Task.CompletedTask; } + public override Task ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) where TResult : default where TPayload : default + { + return Task.FromResult(default(TResult)); + } + #endregion private string Height => $"height: calc(100vh - {this.HeaderHeight} - {this.MainLayout.AdditionalHeight});"; diff --git a/app/MindWork AI Studio/Components/Blocks/TreeButton.cs b/app/MindWork AI Studio/Components/Blocks/TreeButton.cs new file mode 100644 index 00000000..ff7eaa5c --- /dev/null +++ b/app/MindWork AI Studio/Components/Blocks/TreeButton.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Components.Blocks; + +public readonly record struct TreeButton(WorkspaceBranch Branch, int Depth, string Text, string Icon, Func Action) : ITreeItem; \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Blocks/TreeDivider.cs b/app/MindWork AI Studio/Components/Blocks/TreeDivider.cs new file mode 100644 index 00000000..6c311272 --- /dev/null +++ b/app/MindWork AI Studio/Components/Blocks/TreeDivider.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Components.Blocks; + +public readonly record struct TreeDivider : ITreeItem; \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Blocks/TreeItemData.cs b/app/MindWork AI Studio/Components/Blocks/TreeItemData.cs new file mode 100644 index 00000000..aa96877a --- /dev/null +++ b/app/MindWork AI Studio/Components/Blocks/TreeItemData.cs @@ -0,0 +1,22 @@ +namespace AIStudio.Components.Blocks; + +public class TreeItemData : ITreeItem +{ + public WorkspaceBranch Branch { get; init; } = WorkspaceBranch.NONE; + + public int Depth { get; init; } + + public string Text { get; init; } = string.Empty; + + public string ShortenedText => Text.Length > 30 ? this.Text[..30] + "..." : this.Text; + + public string Icon { get; init; } = string.Empty; + + public TreeItemType Type { get; init; } + + public string Path { get; init; } = string.Empty; + + public bool Expandable { get; init; } = true; + + public HashSet Children { get; init; } = []; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Blocks/TreeItemType.cs b/app/MindWork AI Studio/Components/Blocks/TreeItemType.cs new file mode 100644 index 00000000..de860ab9 --- /dev/null +++ b/app/MindWork AI Studio/Components/Blocks/TreeItemType.cs @@ -0,0 +1,9 @@ +namespace AIStudio.Components.Blocks; + +public enum TreeItemType +{ + NONE, + + CHAT, + WORKSPACE, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Blocks/WorkspaceBranch.cs b/app/MindWork AI Studio/Components/Blocks/WorkspaceBranch.cs new file mode 100644 index 00000000..1b19bd34 --- /dev/null +++ b/app/MindWork AI Studio/Components/Blocks/WorkspaceBranch.cs @@ -0,0 +1,9 @@ +namespace AIStudio.Components.Blocks; + +public enum WorkspaceBranch +{ + NONE, + + WORKSPACES, + TEMPORARY_CHATS, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Blocks/Workspaces.razor b/app/MindWork AI Studio/Components/Blocks/Workspaces.razor new file mode 100644 index 00000000..e45fea7a --- /dev/null +++ b/app/MindWork AI Studio/Components/Blocks/Workspaces.razor @@ -0,0 +1,88 @@ + + + @switch (item) + { + case TreeDivider: +
  • + +
  • + break; + + case TreeItemData treeItem: + @if (treeItem.Type is TreeItemType.CHAT) + { + + +
    + + @if (string.IsNullOrWhiteSpace(treeItem.Text)) + { + @("Empty chat") + } + else + { + @treeItem.ShortenedText + } + +
    + + + + + + + + + + + + +
    +
    +
    +
    + } + else if (treeItem.Type is TreeItemType.WORKSPACE) + { + + +
    + @treeItem.Text +
    + + + + + + + +
    +
    +
    +
    + } + else + { + + +
    + @treeItem.Text +
    +
    +
    + } + break; + + case TreeButton treeButton: +
  • +
    +
    + + @treeButton.Text + +
    +
  • + break; + } +
    +
    \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Blocks/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Blocks/Workspaces.razor.cs new file mode 100644 index 00000000..6030470b --- /dev/null +++ b/app/MindWork AI Studio/Components/Blocks/Workspaces.razor.cs @@ -0,0 +1,499 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +using AIStudio.Chat; +using AIStudio.Components.CommonDialogs; +using AIStudio.Settings; +using AIStudio.Tools; + +using Microsoft.AspNetCore.Components; + +using DialogOptions = AIStudio.Components.CommonDialogs.DialogOptions; + +namespace AIStudio.Components.Blocks; + +public partial class Workspaces : ComponentBase +{ + [Inject] + private SettingsManager SettingsManager { get; set; } = null!; + + [Inject] + private IDialogService DialogService { get; set; } = null!; + + [Inject] + public Random RNG { get; set; } = null!; + + [Parameter] + public ChatThread? CurrentChatThread { get; set; } + + [Parameter] + public EventCallback CurrentChatThreadChanged { get; set; } + + [Parameter] + public Func LoadedChatWasChanged { get; set; } = () => Task.CompletedTask; + + private const Placement WORKSPACE_ITEM_TOOLTIP_PLACEMENT = Placement.Bottom; + + 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 treeItems = new(); + + #region Overrides of ComponentBase + + protected override async Task OnInitializedAsync() + { + // + // Notice: In order to get the server-based loading to work, we need to respect the following rules: + // - We must have initial tree items + // - Those initial tree items cannot have children + // - When assigning the tree items to the MudTreeViewItem component, we must set the Value property to the value of the item + // + + await this.LoadTreeItems(); + await base.OnInitializedAsync(); + } + + #endregion + + private async Task LoadTreeItems() + { + this.treeItems.Clear(); + this.treeItems.Add(new TreeItemData + { + Depth = 0, + Branch = WorkspaceBranch.WORKSPACES, + Text = "Workspaces", + Icon = Icons.Material.Filled.Folder, + Expandable = true, + Path = "root", + Children = await this.LoadWorkspaces(), + }); + + this.treeItems.Add(new TreeDivider()); + this.treeItems.Add(new TreeItemData + { + Depth = 0, + Branch = WorkspaceBranch.TEMPORARY_CHATS, + Text = "Temporary chats", + Icon = Icons.Material.Filled.Timer, + Expandable = true, + Path = "temp", + Children = await this.LoadTemporaryChats(), + }); + } + + private async Task> LoadTemporaryChats() + { + var tempChildren = new HashSet(); + + // + // Search for workspace folders in the data directory: + // + + // Get the workspace root directory: + var temporaryDirectories = Path.Join(SettingsManager.DataDirectory, "tempChats"); + + // Ensure the directory exists: + Directory.CreateDirectory(temporaryDirectories); + + // 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 + { + Type = TreeItemType.CHAT, + Depth = 1, + Branch = WorkspaceBranch.TEMPORARY_CHATS, + Text = chatName, + Icon = Icons.Material.Filled.Timer, + Expandable = false, + Path = tempChatDirPath, + }); + } + + return tempChildren; + } + + public async Task LoadWorkspaceName(Guid workspaceId) + { + if(workspaceId == Guid.Empty) + return string.Empty; + + var workspacePath = Path.Join(SettingsManager.DataDirectory, "workspaces", workspaceId.ToString()); + var workspaceNamePath = Path.Join(workspacePath, "name"); + return await File.ReadAllTextAsync(workspaceNamePath, Encoding.UTF8); + } + + private async Task> LoadWorkspaces() + { + var workspaces = new HashSet(); + + // + // Search for workspace folders in the data directory: + // + + // Get the workspace root directory: + var workspaceDirectories = Path.Join(SettingsManager.DataDirectory, "workspaces"); + + // Ensure the directory exists: + Directory.CreateDirectory(workspaceDirectories); + + // 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); + + workspaces.Add(new TreeItemData + { + Type = TreeItemType.WORKSPACE, + Depth = 1, + Branch = WorkspaceBranch.WORKSPACES, + Text = workspaceName, + Icon = Icons.Material.Filled.Description, + Expandable = true, + Path = workspaceDirPath, + Children = await this.LoadWorkspaceChats(workspaceDirPath), + }); + } + + workspaces.Add(new TreeButton(WorkspaceBranch.WORKSPACES, 1, "Add workspace",Icons.Material.Filled.LibraryAdd, this.AddWorkspace)); + return workspaces; + } + + private async Task> LoadWorkspaceChats(string workspacePath) + { + var workspaceChats = new HashSet(); + + // Enumerate the workspace directory: + foreach (var chatPath in Directory.EnumerateDirectories(workspacePath)) + { + // Read the `name` file: + var chatNamePath = Path.Join(chatPath, "name"); + var chatName = await File.ReadAllTextAsync(chatNamePath, Encoding.UTF8); + + workspaceChats.Add(new TreeItemData + { + Type = TreeItemType.CHAT, + Depth = 2, + Branch = WorkspaceBranch.WORKSPACES, + Text = chatName, + Icon = Icons.Material.Filled.Chat, + Expandable = false, + Path = chatPath, + }); + } + + workspaceChats.Add(new TreeButton(WorkspaceBranch.WORKSPACES, 2, "Add chat",Icons.Material.Filled.AddComment, () => this.AddChat(workspacePath))); + return workspaceChats; + } + + public async Task StoreChat(ChatThread chat) + { + string chatDirectory; + if (chat.WorkspaceId == Guid.Empty) + chatDirectory = Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()); + else + chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()); + + // Ensure the directory exists: + Directory.CreateDirectory(chatDirectory); + + // Save the chat name: + var chatNamePath = Path.Join(chatDirectory, "name"); + await File.WriteAllTextAsync(chatNamePath, chat.Name); + + // Save the thread as thread.json: + var chatPath = Path.Join(chatDirectory, "thread.json"); + await File.WriteAllTextAsync(chatPath, JsonSerializer.Serialize(chat, JSON_OPTIONS), Encoding.UTF8); + + // Reload the tree items: + await this.LoadTreeItems(); + this.StateHasChanged(); + } + + private async Task LoadChat(string? chatPath, bool switchToChat) + { + if(string.IsNullOrWhiteSpace(chatPath)) + return null; + + if(!Directory.Exists(chatPath)) + return null; + + // Check if the chat has unsaved changes: + if (switchToChat && await MessageBus.INSTANCE.SendMessageUseFirstResult(this, Event.HAS_CHAT_UNSAVED_CHANGES)) + { + var dialogParameters = new DialogParameters + { + { "Message", "Are you sure you want to load another chat? All unsaved changes will be lost." }, + }; + + var dialogReference = await this.DialogService.ShowAsync("Load Chat", dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult.Canceled) + return null; + } + + try + { + var chatData = await File.ReadAllTextAsync(Path.Join(chatPath, "thread.json"), Encoding.UTF8); + var chat = JsonSerializer.Deserialize(chatData, JSON_OPTIONS); + if (switchToChat) + { + this.CurrentChatThread = chat; + await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); + await this.LoadedChatWasChanged(); + } + + return chat; + } + catch (Exception e) + { + Console.WriteLine(e); + } + + return null; + } + + public async Task DeleteChat(string? chatPath, bool askForConfirmation = true, bool unloadChat = true) + { + var chat = await this.LoadChat(chatPath, false); + if (chat is null) + return; + + if (askForConfirmation) + { + var workspaceName = await this.LoadWorkspaceName(chat.WorkspaceId); + var dialogParameters = new DialogParameters + { + { + "Message", (chat.WorkspaceId == Guid.Empty) switch + { + true => $"Are you sure you want to delete the temporary chat '{chat.Name}'?", + false => $"Are you sure you want to delete the chat '{chat.Name}' in the workspace '{workspaceName}'?", + } + }, + }; + + var dialogReference = await this.DialogService.ShowAsync("Delete Chat", dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult.Canceled) + return; + } + + string chatDirectory; + if (chat.WorkspaceId == Guid.Empty) + chatDirectory = Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()); + else + chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()); + + Directory.Delete(chatDirectory, true); + await this.LoadTreeItems(); + + if(unloadChat && this.CurrentChatThread?.ChatId == chat.ChatId) + { + this.CurrentChatThread = null; + await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); + await this.LoadedChatWasChanged(); + } + } + + private async Task RenameChat(string? chatPath) + { + var chat = await this.LoadChat(chatPath, false); + if (chat is null) + return; + + var dialogParameters = new DialogParameters + { + { "Message", $"Please enter a new or edit the name for your chat '{chat.Name}':" }, + { "UserInput", chat.Name }, + { "ConfirmText", "Rename" }, + { "ConfirmColor", Color.Info }, + }; + + var dialogReference = await this.DialogService.ShowAsync("Rename Chat", dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult.Canceled) + return; + + chat.Name = (dialogResult.Data as string)!; + await this.StoreChat(chat); + await this.LoadTreeItems(); + } + + private async Task RenameWorkspace(string? workspacePath) + { + if(workspacePath is null) + return; + + var workspaceId = Guid.Parse(Path.GetFileName(workspacePath)); + var workspaceName = await this.LoadWorkspaceName(workspaceId); + var dialogParameters = new DialogParameters + { + { "Message", $"Please enter a new or edit the name for your workspace '{workspaceName}':" }, + { "UserInput", workspaceName }, + { "ConfirmText", "Rename" }, + { "ConfirmColor", Color.Info }, + }; + + var dialogReference = await this.DialogService.ShowAsync("Rename Workspace", dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult.Canceled) + return; + + var alteredWorkspaceName = (dialogResult.Data as string)!; + var workspaceNamePath = Path.Join(workspacePath, "name"); + await File.WriteAllTextAsync(workspaceNamePath, alteredWorkspaceName, Encoding.UTF8); + await this.LoadTreeItems(); + } + + private async Task AddWorkspace() + { + var dialogParameters = new DialogParameters + { + { "Message", "Please name your workspace:" }, + { "UserInput", string.Empty }, + { "ConfirmText", "Add workspace" }, + { "ConfirmColor", Color.Info }, + }; + + var dialogReference = await this.DialogService.ShowAsync("Add Workspace", dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult.Canceled) + return; + + var workspaceId = Guid.NewGuid(); + var workspacePath = Path.Join(SettingsManager.DataDirectory, "workspaces", workspaceId.ToString()); + Directory.CreateDirectory(workspacePath); + + var workspaceNamePath = Path.Join(workspacePath, "name"); + await File.WriteAllTextAsync(workspaceNamePath, (dialogResult.Data as string)!, Encoding.UTF8); + + await this.LoadTreeItems(); + } + + private async Task DeleteWorkspace(string? workspacePath) + { + if(workspacePath is null) + return; + + var workspaceId = Guid.Parse(Path.GetFileName(workspacePath)); + var workspaceName = await this.LoadWorkspaceName(workspaceId); + + // Determine how many chats are in the workspace: + var chatCount = Directory.EnumerateDirectories(workspacePath).Count(); + + var dialogParameters = new DialogParameters + { + { "Message", $"Are you sure you want to delete the workspace '{workspaceName}'? This will also delete {chatCount} chat(s) in this workspace." }, + }; + + var dialogReference = await this.DialogService.ShowAsync("Delete Workspace", dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult.Canceled) + return; + + Directory.Delete(workspacePath, true); + await this.LoadTreeItems(); + } + + private async Task MoveChat(string? chatPath) + { + var chat = await this.LoadChat(chatPath, false); + if (chat is null) + return; + + var dialogParameters = new DialogParameters + { + { "Message", "Please select the workspace where you want to move the chat to." }, + { "SelectedWorkspace", chat.WorkspaceId }, + { "ConfirmText", "Move chat" }, + }; + + var dialogReference = await this.DialogService.ShowAsync("Move Chat to Workspace", dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult.Canceled) + return; + + var workspaceId = dialogResult.Data is Guid id ? id : default; + if (workspaceId == Guid.Empty) + return; + + // Delete the chat from the current workspace or the temporary storage: + if (chat.WorkspaceId == Guid.Empty) + { + // Case: The chat is stored in the temporary storage: + await this.DeleteChat(Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()), askForConfirmation: false, unloadChat: false); + } + else + { + // Case: The chat is stored in a workspace. + await this.DeleteChat(Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()), askForConfirmation: false, unloadChat: false); + } + + // Update the chat's workspace: + chat.WorkspaceId = workspaceId; + + // Handle the case where the chat is the active chat: + if (this.CurrentChatThread?.ChatId == chat.ChatId) + { + this.CurrentChatThread = chat; + await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); + await this.LoadedChatWasChanged(); + } + + await this.StoreChat(chat); + } + + private async Task AddChat(string workspacePath) + { + // Check if the chat has unsaved changes: + if (await MessageBus.INSTANCE.SendMessageUseFirstResult(this, Event.HAS_CHAT_UNSAVED_CHANGES)) + { + var dialogParameters = new DialogParameters + { + { "Message", "Are you sure you want to create a another chat? All unsaved changes will be lost." }, + }; + + var dialogReference = await this.DialogService.ShowAsync("Create Chat", dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult.Canceled) + return; + } + + var workspaceId = Guid.Parse(Path.GetFileName(workspacePath)); + var chat = new ChatThread + { + WorkspaceId = workspaceId, + ChatId = Guid.NewGuid(), + Name = string.Empty, + Seed = this.RNG.Next(), + SystemPrompt = "You are a helpful assistant!", + Blocks = [], + }; + + var chatPath = Path.Join(workspacePath, chat.ChatId.ToString()); + + await this.StoreChat(chat); + await this.LoadChat(chatPath, switchToChat: true); + await this.LoadTreeItems(); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/CommonDialogs/SingleInputDialog.razor b/app/MindWork AI Studio/Components/CommonDialogs/SingleInputDialog.razor new file mode 100644 index 00000000..0b8d808e --- /dev/null +++ b/app/MindWork AI Studio/Components/CommonDialogs/SingleInputDialog.razor @@ -0,0 +1,10 @@ + + + @this.Message + + + + Cancel + @this.ConfirmText + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/CommonDialogs/SingleInputDialog.razor.cs b/app/MindWork AI Studio/Components/CommonDialogs/SingleInputDialog.razor.cs new file mode 100644 index 00000000..414f0683 --- /dev/null +++ b/app/MindWork AI Studio/Components/CommonDialogs/SingleInputDialog.razor.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components.CommonDialogs; + +public partial class SingleInputDialog : ComponentBase +{ + [CascadingParameter] + private MudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public string Message { get; set; } = string.Empty; + + [Parameter] + public string UserInput { get; set; } = string.Empty; + + [Parameter] + public string ConfirmText { get; set; } = "OK"; + + [Parameter] + public Color ConfirmColor { get; set; } = Color.Error; + + private void Cancel() => this.MudDialog.Cancel(); + + private void Confirm() => this.MudDialog.Close(DialogResult.Ok(this.UserInput)); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/CommonDialogs/WorkspaceSelectionDialog.razor b/app/MindWork AI Studio/Components/CommonDialogs/WorkspaceSelectionDialog.razor new file mode 100644 index 00000000..3927b6c9 --- /dev/null +++ b/app/MindWork AI Studio/Components/CommonDialogs/WorkspaceSelectionDialog.razor @@ -0,0 +1,15 @@ + + + @this.Message + + @foreach (var (workspaceName, workspaceId) in this.workspaces) + { + + } + + + + Cancel + @this.ConfirmText + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/CommonDialogs/WorkspaceSelectionDialog.razor.cs b/app/MindWork AI Studio/Components/CommonDialogs/WorkspaceSelectionDialog.razor.cs new file mode 100644 index 00000000..821d3d67 --- /dev/null +++ b/app/MindWork AI Studio/Components/CommonDialogs/WorkspaceSelectionDialog.razor.cs @@ -0,0 +1,60 @@ +using System.Text; + +using AIStudio.Settings; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components.CommonDialogs; + +public partial class WorkspaceSelectionDialog : ComponentBase +{ + [CascadingParameter] + private MudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public string Message { get; set; } = string.Empty; + + [Parameter] + public Guid SelectedWorkspace { get; set; } = Guid.Empty; + + [Parameter] + public string ConfirmText { get; set; } = "OK"; + + private readonly Dictionary workspaces = new(); + private object? selectedWorkspace; + + #region Overrides of ComponentBase + + protected override async Task OnInitializedAsync() + { + this.selectedWorkspace = this.SelectedWorkspace; + + // Get the workspace root directory: + var workspaceDirectories = Path.Join(SettingsManager.DataDirectory, "workspaces"); + if(!Directory.Exists(workspaceDirectories)) + { + await base.OnInitializedAsync(); + return; + } + + // 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); + + // Add the workspace to the list: + this.workspaces.Add(workspaceName, Guid.Parse(Path.GetFileName(workspaceDirPath))); + } + + this.StateHasChanged(); + await base.OnInitializedAsync(); + } + + #endregion + + private void Cancel() => this.MudDialog.Cancel(); + + private void Confirm() => this.MudDialog.Close(DialogResult.Ok(this.selectedWorkspace is Guid workspaceId ? workspaceId : default)); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ConfigurationSelectData.cs b/app/MindWork AI Studio/Components/ConfigurationSelectData.cs index 1e77a1d4..6ed436ed 100644 --- a/app/MindWork AI Studio/Components/ConfigurationSelectData.cs +++ b/app/MindWork AI Studio/Components/ConfigurationSelectData.cs @@ -30,4 +30,21 @@ public static IEnumerable> GetUpdateBeha yield return new("Check every day", UpdateBehavior.DAILY); yield return new ("Check every week", UpdateBehavior.WEEKLY); } + + public static IEnumerable> GetWorkspaceStorageBehaviorData() + { + yield return new("Disable workspaces", WorkspaceStorageBehavior.DISABLE_WORKSPACES); + yield return new("Store chats automatically", WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY); + yield return new("Store chats manually", WorkspaceStorageBehavior.STORE_CHATS_MANUALLY); + } + + public static IEnumerable> GetWorkspaceStorageTemporaryMaintenancePolicyData() + { + yield return new("No automatic maintenance for temporary chats", WorkspaceStorageTemporaryMaintenancePolicy.NO_AUTOMATIC_MAINTENANCE); + yield return new("Delete temporary chats older than 7 days", WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_7_DAYS); + yield return new("Delete temporary chats older than 30 days", WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_30_DAYS); + yield return new("Delete temporary chats older than 90 days", WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_90_DAYS); + yield return new("Delete temporary chats older than 180 days", WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_180_DAYS); + yield return new("Delete temporary chats older than 1 year", WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_365_DAYS); + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Layout/MainLayout.razor b/app/MindWork AI Studio/Components/Layout/MainLayout.razor index 8843c671..f791489f 100644 --- a/app/MindWork AI Studio/Components/Layout/MainLayout.razor +++ b/app/MindWork AI Studio/Components/Layout/MainLayout.razor @@ -10,8 +10,8 @@ Home - - Chats + + Chat Supporters diff --git a/app/MindWork AI Studio/Components/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Components/Layout/MainLayout.razor.cs index b58c5abf..7c7f09dd 100644 --- a/app/MindWork AI Studio/Components/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Components/Layout/MainLayout.razor.cs @@ -3,6 +3,7 @@ using AIStudio.Tools; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Routing; using DialogOptions = AIStudio.Components.CommonDialogs.DialogOptions; @@ -27,6 +28,9 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver [Inject] private ISnackbar Snackbar { get; init; } = null!; + + [Inject] + private NavigationManager NavigationManager { get; init; } = null!; public string AdditionalHeight { get; private set; } = "0em"; @@ -40,6 +44,8 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver protected override async Task OnInitializedAsync() { + this.NavigationManager.RegisterLocationChangingHandler(this.OnLocationChanging); + // // We use the Tauri API (Rust) to get the data and config directories // for this app. @@ -49,7 +55,8 @@ protected override async Task OnInitializedAsync() // Store the directories in the settings manager: SettingsManager.ConfigDirectory = configDir; - SettingsManager.DataDirectory = dataDir; + SettingsManager.DataDirectory = Path.Join(dataDir, "data"); + Directory.CreateDirectory(SettingsManager.DataDirectory); // Ensure that all settings are loaded: await this.SettingsManager.LoadSettings(); @@ -60,6 +67,7 @@ protected override async Task OnInitializedAsync() // Set the js runtime for the update service: UpdateService.SetBlazorDependencies(this.JsRuntime, this.Snackbar); + TemporaryChatService.Initialize(); await base.OnInitializedAsync(); } @@ -91,6 +99,11 @@ public async Task ProcessMessage(ComponentBase? sendingComponent, Event trigg } } + public Task ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) + { + return Task.FromResult(default); + } + #endregion private async Task DismissUpdate() @@ -145,4 +158,26 @@ private async Task ShowUpdateDialog() this.StateHasChanged(); await this.Rust.InstallUpdate(this.JsRuntime); } + + private async ValueTask OnLocationChanging(LocationChangingContext context) + { + if (await MessageBus.INSTANCE.SendMessageUseFirstResult(this, Event.HAS_CHAT_UNSAVED_CHANGES)) + { + var dialogParameters = new DialogParameters + { + { "Message", "Are you sure you want to leave the chat page? All unsaved changes will be lost." }, + }; + + var dialogReference = await this.DialogService.ShowAsync("Leave Chat Page", dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult.Canceled) + { + context.PreventNavigation(); + return; + } + + // User accepted to leave the chat page, reset the chat state: + await MessageBus.INSTANCE.SendMessage(this, Event.RESET_CHAT_STATE); + } + } } \ 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 ae9138da..0017303b 100644 --- a/app/MindWork AI Studio/Components/Pages/Chat.razor +++ b/app/MindWork AI Studio/Components/Pages/Chat.razor @@ -2,7 +2,19 @@ @using AIStudio.Chat @using AIStudio.Settings -Chats +@inherits AIStudio.Tools.MSGComponentBase + + + @if (this.chatThread is not null && this.chatThread.WorkspaceId != Guid.Empty) + { + @($"Chat in Workspace \"{this.currentWorkspaceName}\"") + } + else + { + @("Temporary Chat") + } + + @foreach (var provider in this.SettingsManager.ConfigurationData.Providers) { @@ -24,5 +36,64 @@ + + + @if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES) + { + + + + } + + @if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY) + { + + + + } + + + + + + @if (!string.IsNullOrWhiteSpace(this.currentWorkspaceName)) + { + + + + } + + @if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) + { + + + + } + + @if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES) + { + + + + } + + - \ No newline at end of file + + +@if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior != WorkspaceStorageBehavior.DISABLE_WORKSPACES) +{ + + + + + Your workspaces + + + + + + + + +} \ 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 08275bd1..67076370 100644 --- a/app/MindWork AI Studio/Components/Pages/Chat.razor.cs +++ b/app/MindWork AI Studio/Components/Pages/Chat.razor.cs @@ -1,16 +1,21 @@ using AIStudio.Chat; +using AIStudio.Components.Blocks; +using AIStudio.Components.CommonDialogs; using AIStudio.Provider; using AIStudio.Settings; +using AIStudio.Tools; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; +using DialogOptions = AIStudio.Components.CommonDialogs.DialogOptions; + namespace AIStudio.Components.Pages; /// /// The chat page. /// -public partial class Chat : ComponentBase +public partial class Chat : MSGComponentBase, IAsyncDisposable { [Inject] private SettingsManager SettingsManager { get; set; } = null!; @@ -20,13 +25,22 @@ public partial class Chat : ComponentBase [Inject] public Random RNG { get; set; } = null!; + + [Inject] + public IDialogService DialogService { get; set; } = null!; + private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Bottom; private static readonly Dictionary USER_INPUT_ATTRIBUTES = new(); private AIStudio.Settings.Provider selectedProvider; private ChatThread? chatThread; + private bool hasUnsavedChanges; private bool isStreaming; private string userInput = string.Empty; + private string currentWorkspaceName = string.Empty; + private Guid currentWorkspaceId = Guid.Empty; + private bool workspacesVisible; + private Workspaces? workspaces; // 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, @@ -37,14 +51,11 @@ public partial class Chat : ComponentBase protected override async Task OnInitializedAsync() { + this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE ]); + // 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(); } @@ -56,25 +67,60 @@ protected override async Task OnInitializedAsync() 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.chatThread is not null && this.chatThread.Blocks.Count > 0; + + private string TooltipAddChatToWorkspace => $"Start new chat in workspace \"{this.currentWorkspaceName}\""; + private async Task SendMessage() { if (!this.IsProviderSelected) return; + // Create a new chat thread if necessary: + var threadName = this.ExtractThreadName(this.userInput); + + if (this.chatThread is null) + { + this.chatThread = new() + { + WorkspaceId = this.currentWorkspaceId, + ChatId = Guid.NewGuid(), + Name = threadName, + Seed = this.RNG.Next(), + SystemPrompt = "You are a helpful assistant!", + Blocks = [], + }; + } + else + { + // Set the thread name if it is empty: + if (string.IsNullOrWhiteSpace(this.chatThread.Name)) + this.chatThread.Name = threadName; + } + // // 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(); + this.hasUnsavedChanges = false; + this.StateHasChanged(); + } + // // Add the AI response to the thread: // @@ -85,9 +131,12 @@ private async Task SendMessage() 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: @@ -96,6 +145,7 @@ private async Task SendMessage() // Enable the stream state for the chat component: this.isStreaming = true; + this.hasUnsavedChanges = true; this.StateHasChanged(); // Use the selected provider to get the AI response. @@ -103,6 +153,13 @@ private async Task SendMessage() // 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(); + this.hasUnsavedChanges = false; + } + // Disable the stream state: this.isStreaming = false; this.StateHasChanged(); @@ -110,6 +167,7 @@ private async Task SendMessage() private async Task InputKeyEvent(KeyboardEventArgs keyEvent) { + this.hasUnsavedChanges = true; var key = keyEvent.Code.ToLowerInvariant(); // Was the enter key (either enter or numpad enter) pressed? @@ -132,4 +190,216 @@ private async Task InputKeyEvent(KeyboardEventArgs keyEvent) break; } } + + private void ToggleWorkspaces() + { + 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); + this.hasUnsavedChanges = false; + } + + 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; + } + + private async Task StartNewChat(bool useSameWorkspace = false, bool deletePreviousChat = false) + { + if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges) + { + var dialogParameters = new DialogParameters + { + { "Message", "Are you sure you want to start a new chat? All unsaved changes will be lost." }, + }; + + var dialogReference = await this.DialogService.ShowAsync("Delete Chat", dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult.Canceled) + return; + } + + if (this.chatThread is not null && this.workspaces is not null && deletePreviousChat) + { + string chatPath; + if (this.chatThread.WorkspaceId == Guid.Empty) + { + chatPath = Path.Join(SettingsManager.DataDirectory, "tempChats", this.chatThread.ChatId.ToString()); + } + else + { + chatPath = Path.Join(SettingsManager.DataDirectory, "workspaces", this.chatThread.WorkspaceId.ToString(), this.chatThread.ChatId.ToString()); + } + + await this.workspaces.DeleteChat(chatPath, askForConfirmation: false, unloadChat: true); + } + + this.isStreaming = false; + this.hasUnsavedChanges = false; + this.userInput = string.Empty; + + if (!useSameWorkspace) + { + this.chatThread = null; + this.currentWorkspaceId = Guid.Empty; + this.currentWorkspaceName = string.Empty; + } + else + { + this.chatThread = new() + { + WorkspaceId = this.currentWorkspaceId, + ChatId = Guid.NewGuid(), + Name = string.Empty, + Seed = this.RNG.Next(), + SystemPrompt = "You are a helpful assistant!", + Blocks = [], + }; + } + + await this.inputField.Clear(); + } + + private async Task MoveChatToWorkspace() + { + if(this.chatThread is null) + return; + + if(this.workspaces is null) + return; + + if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges) + { + var confirmationDialogParameters = new DialogParameters + { + { "Message", "Are you sure you want to move this chat? All unsaved changes will be lost." }, + }; + + var confirmationDialogReference = await this.DialogService.ShowAsync("Unsaved Changes", confirmationDialogParameters, DialogOptions.FULLSCREEN); + var confirmationDialogResult = await confirmationDialogReference.Result; + if (confirmationDialogResult.Canceled) + return; + } + + var dialogParameters = new DialogParameters + { + { "Message", "Please select the workspace where you want to move the chat to." }, + { "SelectedWorkspace", this.chatThread?.WorkspaceId }, + { "ConfirmText", "Move chat" }, + }; + + var dialogReference = await this.DialogService.ShowAsync("Move Chat to Workspace", dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult.Canceled) + return; + + var workspaceId = dialogResult.Data is Guid id ? id : default; + if (workspaceId == Guid.Empty) + return; + + // Delete the chat from the current workspace or the temporary storage: + if (this.chatThread!.WorkspaceId == Guid.Empty) + { + // Case: The chat is stored in the temporary storage: + await this.workspaces.DeleteChat(Path.Join(SettingsManager.DataDirectory, "tempChats", this.chatThread.ChatId.ToString()), askForConfirmation: false, unloadChat: false); + } + else + { + // Case: The chat is stored in a workspace. + await this.workspaces.DeleteChat(Path.Join(SettingsManager.DataDirectory, "workspaces", this.chatThread.WorkspaceId.ToString(), this.chatThread.ChatId.ToString()), askForConfirmation: false, unloadChat: false); + } + + this.chatThread!.WorkspaceId = workspaceId; + await this.SaveThread(); + + this.currentWorkspaceId = this.chatThread.WorkspaceId; + this.currentWorkspaceName = await this.workspaces.LoadWorkspaceName(this.chatThread.WorkspaceId); + } + + private async Task LoadedChatChanged() + { + if(this.workspaces is null) + return; + + this.isStreaming = false; + this.hasUnsavedChanges = false; + this.userInput = string.Empty; + this.currentWorkspaceId = this.chatThread?.WorkspaceId ?? Guid.Empty; + this.currentWorkspaceName = this.chatThread is null ? string.Empty : await this.workspaces.LoadWorkspaceName(this.chatThread.WorkspaceId); + + await this.inputField.Clear(); + } + + private void ResetState() + { + this.isStreaming = false; + this.hasUnsavedChanges = false; + this.userInput = string.Empty; + this.currentWorkspaceId = Guid.Empty; + this.currentWorkspaceName = string.Empty; + this.chatThread = null; + } + + #region Overrides of MSGComponentBase + + public override Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default + { + switch (triggeredEvent) + { + case Event.RESET_CHAT_STATE: + this.ResetState(); + break; + } + + return Task.CompletedTask; + } + + public override Task ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) where TResult : default where TPayload : default + { + switch (triggeredEvent) + { + case Event.HAS_CHAT_UNSAVED_CHANGES: + if(this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) + return Task.FromResult((TResult?) (object) false); + + return Task.FromResult((TResult?)(object)this.hasUnsavedChanges); + } + + return Task.FromResult(default(TResult)); + } + + #endregion + + #region Implementation of IAsyncDisposable + + public async ValueTask DisposeAsync() + { + if(this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) + { + await this.SaveThread(); + this.hasUnsavedChanges = false; + } + } + + #endregion } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Pages/Settings.razor b/app/MindWork AI Studio/Components/Pages/Settings.razor index 2a9414e4..c8b827be 100644 --- a/app/MindWork AI Studio/Components/Pages/Settings.razor +++ b/app/MindWork AI Studio/Components/Pages/Settings.razor @@ -62,5 +62,7 @@ + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 4c1ad4a2..1ca814e9 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -31,6 +31,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddHubOptions(options => diff --git a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs index 82a458e3..4093224c 100644 --- a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs +++ b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs @@ -81,7 +81,7 @@ public async IAsyncEnumerable StreamChatCompletion(IJSRuntime jsRuntime, // Read the stream, line by line: while(!streamReader.EndOfStream) { - // Check if the token is cancelled: + // Check if the token is canceled: if(token.IsCancellationRequested) yield break; diff --git a/app/MindWork AI Studio/Settings/Data.cs b/app/MindWork AI Studio/Settings/Data.cs index 8ce5120b..e4efb078 100644 --- a/app/MindWork AI Studio/Settings/Data.cs +++ b/app/MindWork AI Studio/Settings/Data.cs @@ -41,4 +41,14 @@ public sealed class Data /// If and when we should look for updates. ///
    public UpdateBehavior UpdateBehavior { get; set; } = UpdateBehavior.ONCE_STARTUP; + + /// + /// The chat storage behavior. + /// + public WorkspaceStorageBehavior WorkspaceStorageBehavior { get; set; } = WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY; + + /// + /// The chat storage maintenance behavior. + /// + public WorkspaceStorageTemporaryMaintenancePolicy WorkspaceStorageTemporaryMaintenancePolicy { get; set; } = WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_90_DAYS; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/SendBehavior.cs b/app/MindWork AI Studio/Settings/SendBehavior.cs index 96a3fd59..cc92883a 100644 --- a/app/MindWork AI Studio/Settings/SendBehavior.cs +++ b/app/MindWork AI Studio/Settings/SendBehavior.cs @@ -13,7 +13,7 @@ public enum SendBehavior /// /// The user can send the input to the AI by pressing any modifier key - /// together with the enter key. Alternatively, the user can click the send + /// together with the enter key. Alternatively, the user can click the sent /// button. The enter key alone adds a new line. /// MODIFER_ENTER_IS_SENDING, diff --git a/app/MindWork AI Studio/Settings/WorkspaceStorageBehavior.cs b/app/MindWork AI Studio/Settings/WorkspaceStorageBehavior.cs new file mode 100644 index 00000000..d115fb1d --- /dev/null +++ b/app/MindWork AI Studio/Settings/WorkspaceStorageBehavior.cs @@ -0,0 +1,9 @@ +namespace AIStudio.Settings; + +public enum WorkspaceStorageBehavior +{ + DISABLE_WORKSPACES, + + STORE_CHATS_AUTOMATICALLY, + STORE_CHATS_MANUALLY, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/WorkspaceStorageTemporaryMaintenancePolicy.cs b/app/MindWork AI Studio/Settings/WorkspaceStorageTemporaryMaintenancePolicy.cs new file mode 100644 index 00000000..d635f014 --- /dev/null +++ b/app/MindWork AI Studio/Settings/WorkspaceStorageTemporaryMaintenancePolicy.cs @@ -0,0 +1,12 @@ +namespace AIStudio.Settings; + +public enum WorkspaceStorageTemporaryMaintenancePolicy +{ + NO_AUTOMATIC_MAINTENANCE, + + DELETE_OLDER_THAN_7_DAYS, + DELETE_OLDER_THAN_30_DAYS, + DELETE_OLDER_THAN_90_DAYS, + DELETE_OLDER_THAN_180_DAYS, + DELETE_OLDER_THAN_365_DAYS, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Event.cs b/app/MindWork AI Studio/Tools/Event.cs index 2b259f4a..62a6ae93 100644 --- a/app/MindWork AI Studio/Tools/Event.cs +++ b/app/MindWork AI Studio/Tools/Event.cs @@ -10,4 +10,8 @@ public enum Event // Update events: USER_SEARCH_FOR_UPDATE, UPDATE_AVAILABLE, + + // Chat events: + HAS_CHAT_UNSAVED_CHANGES, + RESET_CHAT_STATE, } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/IMessageBusReceiver.cs b/app/MindWork AI Studio/Tools/IMessageBusReceiver.cs index 401a2118..019ce115 100644 --- a/app/MindWork AI Studio/Tools/IMessageBusReceiver.cs +++ b/app/MindWork AI Studio/Tools/IMessageBusReceiver.cs @@ -5,4 +5,6 @@ namespace AIStudio.Tools; public interface IMessageBusReceiver { public Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data); + + public Task ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MSGComponentBase.cs b/app/MindWork AI Studio/Tools/MSGComponentBase.cs index b8ccaf82..ff72e0e7 100644 --- a/app/MindWork AI Studio/Tools/MSGComponentBase.cs +++ b/app/MindWork AI Studio/Tools/MSGComponentBase.cs @@ -20,6 +20,8 @@ protected override void OnInitialized() #region Implementation of IMessageBusReceiver public abstract Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data); + + public abstract Task ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data); #endregion @@ -37,6 +39,11 @@ protected async Task SendMessage(Event triggeredEvent, T? data = default) await this.MessageBus.SendMessage(this, triggeredEvent, data); } + protected async Task SendMessageWithResult(Event triggeredEvent, TPayload? data) + { + return await this.MessageBus.SendMessageUseFirstResult(this, triggeredEvent, data); + } + protected void ApplyFilters(ComponentBase[] components, Event[] events) { this.MessageBus.ApplyFilters(this, components, events); diff --git a/app/MindWork AI Studio/Tools/MessageBus.cs b/app/MindWork AI Studio/Tools/MessageBus.cs index 53b215c7..9a6aac88 100644 --- a/app/MindWork AI Studio/Tools/MessageBus.cs +++ b/app/MindWork AI Studio/Tools/MessageBus.cs @@ -64,4 +64,23 @@ public async Task SendMessage(ComponentBase? sendingComponent, Event triggere this.sendingSemaphore.Release(); } } + + public async Task SendMessageUseFirstResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data = default) + { + foreach (var (receiver, componentFilter) in this.componentFilters) + { + if (componentFilter.Length > 0 && sendingComponent is not null && !componentFilter.Contains(sendingComponent)) + continue; + + var eventFilter = this.componentEvents[receiver]; + if (eventFilter.Length == 0 || eventFilter.Contains(triggeredEvent)) + { + var result = await receiver.ProcessMessageWithResult(sendingComponent, triggeredEvent, data); + if (result is not null) + return (TResult) result; + } + } + + return default; + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/TemporaryChatService.cs b/app/MindWork AI Studio/Tools/TemporaryChatService.cs new file mode 100644 index 00000000..c7122ae2 --- /dev/null +++ b/app/MindWork AI Studio/Tools/TemporaryChatService.cs @@ -0,0 +1,71 @@ +using AIStudio.Settings; + +namespace AIStudio.Tools; + +public class TemporaryChatService(SettingsManager settingsManager) : BackgroundService +{ + private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromDays(1); + private static bool IS_INITIALIZED; + + #region Overrides of BackgroundService + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested && !IS_INITIALIZED) + await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken); + + await settingsManager.LoadSettings(); + if(settingsManager.ConfigurationData.WorkspaceStorageTemporaryMaintenancePolicy is WorkspaceStorageTemporaryMaintenancePolicy.NO_AUTOMATIC_MAINTENANCE) + { + Console.WriteLine("Automatic maintenance of temporary chat storage is disabled. Exiting maintenance service."); + return; + } + + await this.StartMaintenance(); + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(CHECK_INTERVAL, stoppingToken); + await this.StartMaintenance(); + } + } + + #endregion + + private Task StartMaintenance() + { + var temporaryDirectories = Path.Join(SettingsManager.DataDirectory, "tempChats"); + if(!Directory.Exists(temporaryDirectories)) + return Task.CompletedTask; + + foreach (var tempChatDirPath in Directory.EnumerateDirectories(temporaryDirectories)) + { + var chatPath = Path.Join(tempChatDirPath, "thread.json"); + var chatMetadata = new FileInfo(chatPath); + if (!chatMetadata.Exists) + continue; + + var lastWriteTime = chatMetadata.LastWriteTimeUtc; + var deleteChat = settingsManager.ConfigurationData.WorkspaceStorageTemporaryMaintenancePolicy switch + { + WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_7_DAYS => DateTime.UtcNow - lastWriteTime > TimeSpan.FromDays(7), + WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_30_DAYS => DateTime.UtcNow - lastWriteTime > TimeSpan.FromDays(30), + WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_90_DAYS => DateTime.UtcNow - lastWriteTime > TimeSpan.FromDays(90), + WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_180_DAYS => DateTime.UtcNow - lastWriteTime > TimeSpan.FromDays(180), + WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_365_DAYS => DateTime.UtcNow - lastWriteTime > TimeSpan.FromDays(365), + + WorkspaceStorageTemporaryMaintenancePolicy.NO_AUTOMATIC_MAINTENANCE => false, + _ => false, + }; + + if(deleteChat) + Directory.Delete(tempChatDirPath, true); + } + + return Task.CompletedTask; + } + + public static void Initialize() + { + IS_INITIALIZED = true; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/UpdateService.cs b/app/MindWork AI Studio/Tools/UpdateService.cs index 809009a8..29193430 100644 --- a/app/MindWork AI Studio/Tools/UpdateService.cs +++ b/app/MindWork AI Studio/Tools/UpdateService.cs @@ -14,10 +14,11 @@ public sealed class UpdateService : BackgroundService, IMessageBusReceiver private static ISnackbar? SNACKBAR; private readonly SettingsManager settingsManager; - private readonly TimeSpan updateInterval; private readonly MessageBus messageBus; private readonly Rust rust; + private TimeSpan updateInterval; + public UpdateService(MessageBus messageBus, SettingsManager settingsManager, Rust rust) { this.settingsManager = settingsManager; @@ -26,8 +27,16 @@ public UpdateService(MessageBus messageBus, SettingsManager settingsManager, Rus this.messageBus.RegisterComponent(this); this.ApplyFilters([], [ Event.USER_SEARCH_FOR_UPDATE ]); - - this.updateInterval = settingsManager.ConfigurationData.UpdateBehavior switch + } + + #region Overrides of BackgroundService + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested && !IS_INITIALIZED) + await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken); + + this.updateInterval = this.settingsManager.ConfigurationData.UpdateBehavior switch { UpdateBehavior.NO_CHECK => Timeout.InfiniteTimeSpan, UpdateBehavior.ONCE_STARTUP => Timeout.InfiniteTimeSpan, @@ -38,21 +47,11 @@ public UpdateService(MessageBus messageBus, SettingsManager settingsManager, Rus _ => TimeSpan.FromHours(1) }; - } - - #region Overrides of BackgroundService - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - while (!stoppingToken.IsCancellationRequested && !IS_INITIALIZED) - { - await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); - } - - await this.settingsManager.LoadSettings(); - if(this.settingsManager.ConfigurationData.UpdateBehavior != UpdateBehavior.NO_CHECK) - await this.CheckForUpdate(); + if(this.settingsManager.ConfigurationData.UpdateBehavior is UpdateBehavior.NO_CHECK) + return; + + await this.CheckForUpdate(); while (!stoppingToken.IsCancellationRequested) { await Task.Delay(this.updateInterval, stoppingToken); @@ -73,6 +72,11 @@ public async Task ProcessMessage(ComponentBase? sendingComponent, Event trigg break; } } + + public Task ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) + { + return Task.FromResult(default); + } #endregion diff --git a/app/MindWork AI Studio/packages.lock.json b/app/MindWork AI Studio/packages.lock.json index 6a38f3fe..ef66cf46 100644 --- a/app/MindWork AI Studio/packages.lock.json +++ b/app/MindWork AI Studio/packages.lock.json @@ -13,9 +13,9 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[8.0.6, )", - "resolved": "8.0.6", - "contentHash": "E+lDylsTeP4ZiDmnEkiJ5wobnGaIJzFhOgZppznJCb69UZgbh6G3cfv1pnLDLLBx6JAgl0kAlnINDeT3uCuczQ==" + "requested": "[8.0.7, )", + "resolved": "8.0.7", + "contentHash": "iI52ptEKby2ymQ6B7h4TWbFmm85T4VvLgc/HvS45Yr3lgi4IIFbQtjON3bQbX/Vc94jXNSLvrDOp5Kh7SJyFYQ==" }, "MudBlazor": { "type": "Direct", diff --git a/app/MindWork AI Studio/wwwroot/changelog/v0.7.0.md b/app/MindWork AI Studio/wwwroot/changelog/v0.7.0.md new file mode 100644 index 00000000..fea2d5cb --- /dev/null +++ b/app/MindWork AI Studio/wwwroot/changelog/v0.7.0.md @@ -0,0 +1,16 @@ +# v0.7.0, build 160 (2024-07-13 08:21 UTC) +- Added workspaces for organizing your chats +- Added temporary chats for quick conversations +- Added configurable chat maintenance settings for temporary chats +- Added configuration to disable workspace and temporary chat features; no chat will be stored in this case +- Added possibility to rename chats +- Added possibility to delete chats +- Added possibility to move chats between workspaces +- Added possibility to delete entire workspaces +- Added possibility to rename workspaces +- Added possibility to create new workspaces +- Added feature to delete the current chat and start a new one +- Added feature to start a new chat within the current workspace +- Added a confirmation dialog for when unsaved changes are about to be lost +- Show the current workspace in the title bar +- Fixed a bug where the periodic, automatic update check would not work \ No newline at end of file diff --git a/metadata.txt b/metadata.txt index 6d8edecc..8c9ddf2e 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,9 +1,9 @@ -0.6.3 -2024-07-03 18:26:31 UTC -159 -8.0.206 (commit bb12410699) -8.0.6 (commit 3b8b000a0e) +0.7.0 +2024-07-13 08:21:49 UTC +160 +8.0.107 (commit 1bdaef7265) +8.0.7 (commit 2aade6beb0) 1.79.0 (commit 129f3b996) 6.20.0 1.6.1 -ac6748e9eb5, release +09e1f8715f8, release diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index 24bdacd5..d9ac91f6 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -2313,7 +2313,7 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mindwork-ai-studio" -version = "0.6.3" +version = "0.7.0" dependencies = [ "arboard", "flexi_logger", diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index cb4728b8..8c59739e 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mindwork-ai-studio" -version = "0.6.3" +version = "0.7.0" edition = "2021" description = "MindWork AI Studio" authors = ["Thorsten Sommer"] diff --git a/runtime/tauri.conf.json b/runtime/tauri.conf.json index 07af8486..b77e2c71 100644 --- a/runtime/tauri.conf.json +++ b/runtime/tauri.conf.json @@ -6,7 +6,7 @@ }, "package": { "productName": "MindWork AI Studio", - "version": "0.6.3" + "version": "0.7.0" }, "tauri": { "allowlist": {