Improved workspace performance (#680)
Build and Release / Read metadata (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-03-05 18:37:18 +01:00
1 parent 721d5c9070
commit 906d9ba058
16 files changed
+994 -494

No files matched your search

@@ -56,6 +56,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private bool autoSaveEnabled;
private string currentWorkspaceName = string.Empty;
private Guid currentWorkspaceId = Guid.Empty;
private Guid currentChatThreadId = Guid.Empty;
private CancellationTokenSource? cancellationTokenSource;
private HashSet<FileAttachment> chatDocumentPaths = [];
@@ -197,8 +198,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
//
if (this.ChatThread is not null)
{
this.currentChatThreadId = this.ChatThread.ChatId;
this.currentWorkspaceId = this.ChatThread.WorkspaceId;
this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceName(this.ChatThread.WorkspaceId);
this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId);
this.WorkspaceName(this.currentWorkspaceName);
}
@@ -214,12 +216,12 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.mustStoreChat = false;
if(this.Workspaces is not null)
await this.Workspaces.StoreChat(this.ChatThread);
await this.Workspaces.StoreChatAsync(this.ChatThread);
else
await WorkspaceBehaviour.StoreChat(this.ChatThread);
await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
this.currentWorkspaceId = this.ChatThread.WorkspaceId;
this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceName(this.ChatThread.WorkspaceId);
this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId);
this.WorkspaceName(this.currentWorkspaceName);
}
@@ -227,14 +229,14 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
{
this.Logger.LogInformation($"Try to load the chat '{this.loadChat.ChatId}' now.");
this.mustLoadChat = false;
this.ChatThread = await WorkspaceBehaviour.LoadChat(this.loadChat);
this.ChatThread = await WorkspaceBehaviour.LoadChatAsync(this.loadChat);
if(this.ChatThread is not null)
{
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
this.Logger.LogInformation($"The chat '{this.ChatThread!.ChatId}' with title '{this.ChatThread.Name}' ({this.ChatThread.Blocks.Count} messages) was loaded successfully.");
this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceName(this.ChatThread.WorkspaceId);
this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId);
this.WorkspaceName(this.currentWorkspaceName);
await this.SelectProviderWhenLoadingChat();
}
@@ -260,7 +262,49 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
await base.OnAfterRenderAsync(firstRender);
}
protected override async Task OnParametersSetAsync()
{
await this.SyncWorkspaceHeaderWithChatThreadAsync();
await base.OnParametersSetAsync();
}
#endregion
private async Task SyncWorkspaceHeaderWithChatThreadAsync()
{
if (this.ChatThread is null)
{
if (this.currentChatThreadId != Guid.Empty || this.currentWorkspaceId != Guid.Empty || !string.IsNullOrWhiteSpace(this.currentWorkspaceName))
{
this.currentChatThreadId = Guid.Empty;
this.currentWorkspaceId = Guid.Empty;
this.currentWorkspaceName = string.Empty;
this.WorkspaceName(this.currentWorkspaceName);
}
return;
}
// Guard: If ChatThread ID and WorkspaceId haven't changed, skip entirely.
// Using ID-based comparison instead of name-based to correctly handle
// temporary chats where the workspace name is always empty.
if (this.currentChatThreadId == this.ChatThread.ChatId
&& this.currentWorkspaceId == this.ChatThread.WorkspaceId)
return;
this.currentChatThreadId = this.ChatThread.ChatId;
this.currentWorkspaceId = this.ChatThread.WorkspaceId;
var loadedWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId);
// Only notify the parent when the name actually changed to prevent
// an infinite render loop: WorkspaceName → UpdateWorkspaceName →
// StateHasChanged → re-render → OnParametersSetAsync → WorkspaceName → ...
if (this.currentWorkspaceName != loadedWorkspaceName)
{
this.currentWorkspaceName = loadedWorkspaceName;
this.WorkspaceName(this.currentWorkspaceName);
}
}
private bool IsProviderSelected => this.Provider.UsedLLMProvider != LLMProviders.NONE;
@@ -428,8 +472,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
if(!this.ChatThread.IsLLMProviderAllowed(this.Provider))
return;
// We need to blur the focus away from the input field
// to be able to clear the field:
// Blur the focus away from the input field to be able to clear it:
await this.inputField.BlurAsync();
// Create a new chat thread if necessary:
@@ -520,8 +563,10 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// Clear the input field:
await this.inputField.FocusAsync();
this.userInput = string.Empty;
this.chatDocumentPaths.Clear();
await this.inputField.BlurAsync();
// Enable the stream state for the chat component:
@@ -583,9 +628,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// the workspace gets updated automatically when the chat is saved.
//
if (this.Workspaces is not null)
await this.Workspaces.StoreChat(this.ChatThread);
await this.Workspaces.StoreChatAsync(this.ChatThread);
else
await WorkspaceBehaviour.StoreChat(this.ChatThread);
await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
this.hasUnsavedChanges = false;
}
@@ -621,9 +666,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
chatPath = Path.Join(SettingsManager.DataDirectory, "workspaces", this.ChatThread.WorkspaceId.ToString(), this.ChatThread.ChatId.ToString());
if(this.Workspaces is null)
await WorkspaceBehaviour.DeleteChat(this.DialogService, this.ChatThread.WorkspaceId, this.ChatThread.ChatId, askForConfirmation: false);
await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, this.ChatThread.WorkspaceId, this.ChatThread.ChatId, askForConfirmation: false);
else
await this.Workspaces.DeleteChat(chatPath, askForConfirmation: false, unloadChat: true);
await this.Workspaces.DeleteChatAsync(chatPath, askForConfirmation: false, unloadChat: true);
}
//
@@ -665,6 +710,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// to reset the chat thread:
//
this.ChatThread = null;
this.currentChatThreadId = Guid.Empty;
this.currentWorkspaceId = Guid.Empty;
this.currentWorkspaceName = string.Empty;
this.WorkspaceName(this.currentWorkspaceName);
@@ -739,13 +785,13 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
return;
// Delete the chat from the current workspace or the temporary storage:
await WorkspaceBehaviour.DeleteChat(this.DialogService, this.ChatThread!.WorkspaceId, this.ChatThread.ChatId, askForConfirmation: false);
await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, this.ChatThread!.WorkspaceId, this.ChatThread.ChatId, askForConfirmation: false);
this.ChatThread!.WorkspaceId = workspaceId;
await this.SaveThread();
this.currentWorkspaceId = this.ChatThread.WorkspaceId;
this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceName(this.ChatThread.WorkspaceId);
this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId);
this.WorkspaceName(this.currentWorkspaceName);
}
@@ -758,12 +804,14 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
if (this.ChatThread is not null)
{
this.currentWorkspaceId = this.ChatThread.WorkspaceId;
this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceName(this.ChatThread.WorkspaceId);
this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId);
this.WorkspaceName(this.currentWorkspaceName);
this.currentChatThreadId = this.ChatThread.ChatId;
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources);
}
else
{
this.currentChatThreadId = Guid.Empty;
this.currentWorkspaceId = Guid.Empty;
this.currentWorkspaceName = string.Empty;
this.WorkspaceName(this.currentWorkspaceName);
@@ -785,6 +833,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.isStreaming = false;
this.hasUnsavedChanges = false;
this.userInput = string.Empty;
this.currentChatThreadId = Guid.Empty;
this.currentWorkspaceId = Guid.Empty;
this.currentWorkspaceName = string.Empty;
@@ -4,6 +4,7 @@ public enum TreeItemType
{
NONE,
LOADING,
CHAT,
WORKSPACE,
}
@@ -1,93 +1,114 @@
@inherits MSGComponentBase
<MudTreeView T="ITreeItem" Items="@this.treeItems" SelectionMode="SelectionMode.SingleSelection" Hover="@true" ExpandOnClick="@true" Class="ma-3">
<ItemTemplate Context="item">
@switch (item.Value)
@if (this.isInitialLoading)
{
<MudStack Class="ma-3" Spacing="1">
<MudSkeleton Width="40%" Height="30px"/>
@for (var i = 0; i < 10; i++)
{
case TreeDivider:
<li style="min-height: 1em;">
<MudDivider Style="margin-top: 1em; width: 90%; border-width: 3pt;"/>
</li>
break;
case TreeItemData treeItem:
@if (treeItem.Type is TreeItemType.CHAT)
{
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@treeItem.Children" OnClick="@(() => this.LoadChat(treeItem.Path, true))">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;">
@if (string.IsNullOrWhiteSpace(treeItem.Text))
{
@T("Empty chat")
}
else
{
@treeItem.ShortenedText
}
</MudText>
<div style="justify-self: end;">
<MudTooltip Text="@T("Move to workspace")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.MoveChat(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Rename")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.RenameChat(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Delete")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Error" OnClick="@(() => this.DeleteChat(treeItem.Path))"/>
</MudTooltip>
</div>
</div>
</BodyContent>
</MudTreeViewItem>
}
else if (treeItem.Type is TreeItemType.WORKSPACE)
{
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@treeItem.Children">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;">
@treeItem.Text
</MudText>
<div style="justify-self: end;">
<MudTooltip Text="@T("Rename")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.RenameWorkspace(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Delete")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Error" OnClick="@(() => this.DeleteWorkspace(treeItem.Path))"/>
</MudTooltip>
</div>
</div>
</BodyContent>
</MudTreeViewItem>
}
else
{
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@treeItem.Children">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;">
@treeItem.Text
</MudText>
</div>
</BodyContent>
</MudTreeViewItem>
}
break;
case TreeButton treeButton:
<li>
<div class="mud-treeview-item-content" style="background-color: unset;">
<div class="mud-treeview-item-arrow"></div>
<MudButton StartIcon="@treeButton.Icon" Variant="Variant.Filled" OnClick="@treeButton.Action">
@treeButton.Text
</MudButton>
</div>
</li>
break;
<MudSkeleton Width="95%" Height="26px"/>
}
</ItemTemplate>
</MudTreeView>
</MudStack>
}
else
{
<MudTreeView T="ITreeItem" Items="@this.treeItems" SelectionMode="SelectionMode.SingleSelection" Hover="@true" ExpandOnClick="@true" Class="ma-3">
<ItemTemplate Context="item">
@switch (item.Value)
{
case TreeDivider:
<li style="min-height: 1em;">
<MudDivider Style="margin-top: 1em; width: 90%; border-width: 3pt;"/>
</li>
break;
case TreeItemData treeItem:
@if (treeItem.Type is TreeItemType.LOADING)
{
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@false" Items="@treeItem.Children">
<BodyContent>
<MudSkeleton Width="85%" Height="22px"/>
</BodyContent>
</MudTreeViewItem>
}
else if (treeItem.Type is TreeItemType.CHAT)
{
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@treeItem.Children" OnClick="@(() => this.LoadChatAsync(treeItem.Path, true))">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;">
@if (string.IsNullOrWhiteSpace(treeItem.Text))
{
@T("Empty chat")
}
else
{
@treeItem.ShortenedText
}
</MudText>
<div style="justify-self: end;">
<MudTooltip Text="@T("Move to workspace")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.MoveChatAsync(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Rename")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.RenameChatAsync(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Delete")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Error" OnClick="@(() => this.DeleteChatAsync(treeItem.Path))"/>
</MudTooltip>
</div>
</div>
</BodyContent>
</MudTreeViewItem>
}
else if (treeItem.Type is TreeItemType.WORKSPACE)
{
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@treeItem.Children" OnClick="@(() => this.OnWorkspaceClicked(treeItem))">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;">
@treeItem.Text
</MudText>
<div style="justify-self: end;">
<MudTooltip Text="@T("Rename")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.RenameWorkspaceAsync(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Delete")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Error" OnClick="@(() => this.DeleteWorkspaceAsync(treeItem.Path))"/>
</MudTooltip>
</div>
</div>
</BodyContent>
</MudTreeViewItem>
}
else
{
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@treeItem.Children">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;">
@treeItem.Text
</MudText>
</div>
</BodyContent>
</MudTreeViewItem>
}
break;
case TreeButton treeButton:
<li>
<div class="mud-treeview-item-content" style="background-color: unset;">
<div class="mud-treeview-item-arrow"></div>
<MudButton StartIcon="@treeButton.Icon" Variant="Variant.Filled" OnClick="@treeButton.Action">
@treeButton.Text
</MudButton>
</div>
</li>
break;
}
</ItemTemplate>
</MudTreeView>
}
@@ -1,4 +1,4 @@
using System.Text;
using System.Text;
using System.Text.Json;
using AIStudio.Chat;
@@ -29,31 +29,64 @@ public partial class Workspaces : MSGComponentBase
public bool ExpandRootNodes { get; set; } = true;
private const Placement WORKSPACE_ITEM_TOOLTIP_PLACEMENT = Placement.Bottom;
private readonly SemaphoreSlim treeLoadingSemaphore = new(1, 1);
private readonly List<TreeItemData<ITreeItem>> treeItems = [];
private readonly HashSet<Guid> loadingWorkspaceChatLists = [];
private readonly List<TreeItemData<ITreeItem>> treeItems = new();
private CancellationTokenSource? prefetchCancellationTokenSource;
private bool isInitialLoading = true;
private bool isDisposed;
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
await base.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
//
// We won't await the loading of the tree items here,
// to avoid blocking the UI thread:
_ = this.LoadTreeItems();
_ = this.LoadTreeItemsAsync(startPrefetch: true);
}
#endregion
private async Task LoadTreeItems()
private async Task LoadTreeItemsAsync(bool startPrefetch = true, bool forceReload = false)
{
await this.treeLoadingSemaphore.WaitAsync();
try
{
if (this.isDisposed)
return;
if (forceReload)
await WorkspaceBehaviour.ForceReloadWorkspaceTreeAsync();
var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
this.BuildTreeItems(snapshot);
this.isInitialLoading = false;
}
finally
{
this.treeLoadingSemaphore.Release();
}
await this.SafeStateHasChanged();
if (startPrefetch)
await this.StartPrefetchAsync();
}
private void BuildTreeItems(WorkspaceTreeCacheSnapshot snapshot)
{
this.treeItems.Clear();
var workspaceChildren = new List<TreeItemData<ITreeItem>>();
foreach (var workspace in snapshot.Workspaces)
workspaceChildren.Add(this.CreateWorkspaceTreeItem(workspace));
workspaceChildren.Add(new TreeItemData<ITreeItem>
{
Expandable = false,
Value = new TreeButton(WorkspaceBranch.WORKSPACES, 1, T("Add workspace"), Icons.Material.Filled.LibraryAdd, this.AddWorkspaceAsync),
});
this.treeItems.Add(new TreeItemData<ITreeItem>
{
Expanded = this.ExpandRootNodes,
@@ -66,7 +99,7 @@ public partial class Workspaces : MSGComponentBase
Icon = Icons.Material.Filled.Folder,
Expandable = true,
Path = "root",
Children = await this.LoadWorkspaces(),
Children = workspaceChildren,
},
});
@@ -76,7 +109,10 @@ public partial class Workspaces : MSGComponentBase
Value = new TreeDivider(),
});
await this.InvokeAsync(this.StateHasChanged);
var temporaryChatsChildren = new List<TreeItemData<ITreeItem>>();
foreach (var temporaryChat in snapshot.TemporaryChats.OrderByDescending(x => x.LastEditTime))
temporaryChatsChildren.Add(CreateChatTreeItem(temporaryChat, WorkspaceBranch.TEMPORARY_CHATS, depth: 1, icon: Icons.Material.Filled.Timer));
this.treeItems.Add(new TreeItemData<ITreeItem>
{
Expanded = this.ExpandRootNodes,
@@ -89,234 +125,219 @@ public partial class Workspaces : MSGComponentBase
Icon = Icons.Material.Filled.Timer,
Expandable = true,
Path = "temp",
Children = await this.LoadTemporaryChats(),
Children = temporaryChatsChildren,
},
});
}
private TreeItemData<ITreeItem> CreateWorkspaceTreeItem(WorkspaceTreeWorkspace workspace)
{
var children = new List<TreeItemData<ITreeItem>>();
if (workspace.ChatsLoaded)
{
foreach (var workspaceChat in workspace.Chats.OrderByDescending(x => x.LastEditTime))
children.Add(CreateChatTreeItem(workspaceChat, WorkspaceBranch.WORKSPACES, depth: 2, icon: Icons.Material.Filled.Chat));
}
else if (this.loadingWorkspaceChatLists.Contains(workspace.WorkspaceId))
children.AddRange(this.CreateLoadingRows(workspace.WorkspacePath));
children.Add(new TreeItemData<ITreeItem>
{
Expandable = false,
Value = new TreeButton(WorkspaceBranch.WORKSPACES, 2, T("Add chat"), Icons.Material.Filled.AddComment, () => this.AddChatAsync(workspace.WorkspacePath)),
});
return new TreeItemData<ITreeItem>
{
Expandable = true,
Value = new TreeItemData
{
Type = TreeItemType.WORKSPACE,
Depth = 1,
Branch = WorkspaceBranch.WORKSPACES,
Text = workspace.Name,
Icon = Icons.Material.Filled.Description,
Expandable = true,
Path = workspace.WorkspacePath,
Children = children,
},
};
}
private IReadOnlyCollection<TreeItemData<ITreeItem>> CreateLoadingRows(string workspacePath)
{
return
[
this.CreateLoadingTreeItem(workspacePath, "loading_1"),
this.CreateLoadingTreeItem(workspacePath, "loading_2"),
this.CreateLoadingTreeItem(workspacePath, "loading_3"),
];
}
private TreeItemData<ITreeItem> CreateLoadingTreeItem(string workspacePath, string suffix)
{
return new TreeItemData<ITreeItem>
{
Expandable = false,
Value = new TreeItemData
{
Type = TreeItemType.LOADING,
Depth = 2,
Branch = WorkspaceBranch.WORKSPACES,
Text = T("Loading chats..."),
Icon = Icons.Material.Filled.HourglassTop,
Expandable = false,
Path = Path.Join(workspacePath, suffix),
},
};
}
private static TreeItemData<ITreeItem> CreateChatTreeItem(WorkspaceTreeChat chat, WorkspaceBranch branch, int depth, string icon)
{
return new TreeItemData<ITreeItem>
{
Expandable = false,
Value = new TreeItemData
{
Type = TreeItemType.CHAT,
Depth = depth,
Branch = branch,
Text = chat.Name,
Icon = icon,
Expandable = false,
Path = chat.ChatPath,
LastEditTime = chat.LastEditTime,
},
};
}
private async Task SafeStateHasChanged()
{
if (this.isDisposed)
return;
await this.InvokeAsync(this.StateHasChanged);
}
private async Task<IReadOnlyCollection<TreeItemData<ITreeItem>>> LoadTemporaryChats()
private async Task StartPrefetchAsync()
{
var tempChildren = new List<TreeItemData>();
// Get the temp root directory:
var temporaryDirectories = Path.Join(SettingsManager.DataDirectory, "tempChats");
// Ensure the directory exists:
Directory.CreateDirectory(temporaryDirectories);
// Enumerate the chat directories:
foreach (var tempChatDirPath in Directory.EnumerateDirectories(temporaryDirectories))
if (this.prefetchCancellationTokenSource is not null)
{
// Read or create the `name` file (self-heal):
var chatNamePath = Path.Join(tempChatDirPath, "name");
string chatName;
try
await this.prefetchCancellationTokenSource.CancelAsync();
this.prefetchCancellationTokenSource.Dispose();
}
this.prefetchCancellationTokenSource = new CancellationTokenSource();
await this.PrefetchWorkspaceChatsAsync(this.prefetchCancellationTokenSource.Token);
}
private async Task PrefetchWorkspaceChatsAsync(CancellationToken cancellationToken)
{
try
{
await WorkspaceBehaviour.TryPrefetchRemainingChatsAsync(async _ =>
{
if (!File.Exists(chatNamePath))
{
chatName = T("Unnamed chat");
await File.WriteAllTextAsync(chatNamePath, chatName, Encoding.UTF8);
}
else
{
chatName = await File.ReadAllTextAsync(chatNamePath, Encoding.UTF8);
if (string.IsNullOrWhiteSpace(chatName))
{
chatName = T("Unnamed chat");
await File.WriteAllTextAsync(chatNamePath, chatName, Encoding.UTF8);
}
}
}
catch
{
chatName = T("Unnamed chat");
}
// Read the last change time of the chat:
var chatThreadPath = Path.Join(tempChatDirPath, "thread.json");
var lastEditTime = File.GetLastWriteTimeUtc(chatThreadPath);
tempChildren.Add(new TreeItemData
{
Type = TreeItemType.CHAT,
Depth = 1,
Branch = WorkspaceBranch.TEMPORARY_CHATS,
Text = chatName,
Icon = Icons.Material.Filled.Timer,
Expandable = false,
Path = tempChatDirPath,
LastEditTime = lastEditTime,
});
if (this.isDisposed || cancellationToken.IsCancellationRequested)
return;
await this.LoadTreeItemsAsync(startPrefetch: false);
}, cancellationToken);
}
catch (OperationCanceledException)
{
// Expected when the component is hidden or disposed.
}
catch (Exception ex)
{
this.Logger.LogWarning(ex, "Failed while prefetching workspace chats.");
}
}
private async Task OnWorkspaceClicked(TreeItemData treeItem)
{
if (treeItem.Type is not TreeItemType.WORKSPACE)
return;
if (!Guid.TryParse(Path.GetFileName(treeItem.Path), out var workspaceId))
return;
await this.EnsureWorkspaceChatsLoadedAsync(workspaceId);
}
private async Task EnsureWorkspaceChatsLoadedAsync(Guid workspaceId)
{
var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
var hasWorkspace = false;
var chatsLoaded = false;
foreach (var workspace in snapshot.Workspaces)
{
if (workspace.WorkspaceId != workspaceId)
continue;
hasWorkspace = true;
chatsLoaded = workspace.ChatsLoaded;
break;
}
if (!hasWorkspace || chatsLoaded || !this.loadingWorkspaceChatLists.Add(workspaceId))
return;
await this.LoadTreeItemsAsync(startPrefetch: false);
try
{
await WorkspaceBehaviour.GetWorkspaceChatsAsync(workspaceId);
}
finally
{
this.loadingWorkspaceChatLists.Remove(workspaceId);
}
await this.LoadTreeItemsAsync(startPrefetch: false);
}
public async Task ForceRefreshFromDiskAsync()
{
if (this.prefetchCancellationTokenSource is not null)
{
await this.prefetchCancellationTokenSource.CancelAsync();
this.prefetchCancellationTokenSource.Dispose();
this.prefetchCancellationTokenSource = null;
}
var result = new List<TreeItemData<ITreeItem>>(tempChildren.OrderByDescending(n => n.LastEditTime).Select(n => new TreeItemData<ITreeItem>
{
Expandable = false,
Value = n,
}));
return result;
}
private async Task<IReadOnlyCollection<TreeItemData<ITreeItem>>> LoadWorkspaces()
{
var workspaces = new List<TreeItemData<ITreeItem>>();
this.loadingWorkspaceChatLists.Clear();
this.isInitialLoading = true;
//
// 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 or create the `name` file (self-heal):
var workspaceNamePath = Path.Join(workspaceDirPath, "name");
string workspaceName;
try
{
if (!File.Exists(workspaceNamePath))
{
workspaceName = T("Unnamed workspace");
await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8);
}
else
{
workspaceName = await File.ReadAllTextAsync(workspaceNamePath, Encoding.UTF8);
if (string.IsNullOrWhiteSpace(workspaceName))
{
workspaceName = T("Unnamed workspace");
await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8);
}
}
}
catch
{
workspaceName = T("Unnamed workspace");
}
workspaces.Add(new TreeItemData<ITreeItem>
{
Expandable = true,
Value = 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 TreeItemData<ITreeItem>
{
Expandable = false,
Value = new TreeButton(WorkspaceBranch.WORKSPACES, 1, T("Add workspace"),Icons.Material.Filled.LibraryAdd, this.AddWorkspace),
});
return workspaces;
await this.SafeStateHasChanged();
await this.LoadTreeItemsAsync(startPrefetch: true, forceReload: true);
}
private async Task<IReadOnlyCollection<TreeItemData<ITreeItem>>> LoadWorkspaceChats(string workspacePath)
public async Task StoreChatAsync(ChatThread chat, bool reloadTreeItems = false)
{
var workspaceChats = new List<TreeItemData>();
await WorkspaceBehaviour.StoreChatAsync(chat);
// Enumerate the workspace directory:
foreach (var chatPath in Directory.EnumerateDirectories(workspacePath))
{
// Read or create the `name` file (self-heal):
var chatNamePath = Path.Join(chatPath, "name");
string chatName;
try
{
if (!File.Exists(chatNamePath))
{
chatName = T("Unnamed chat");
await File.WriteAllTextAsync(chatNamePath, chatName, Encoding.UTF8);
}
else
{
chatName = await File.ReadAllTextAsync(chatNamePath, Encoding.UTF8);
if (string.IsNullOrWhiteSpace(chatName))
{
chatName = T("Unnamed chat");
await File.WriteAllTextAsync(chatNamePath, chatName, Encoding.UTF8);
}
}
}
catch
{
chatName = T("Unnamed chat");
}
// Read the last change time of the chat:
var chatThreadPath = Path.Join(chatPath, "thread.json");
var lastEditTime = File.GetLastWriteTimeUtc(chatThreadPath);
workspaceChats.Add(new TreeItemData
{
Type = TreeItemType.CHAT,
Depth = 2,
Branch = WorkspaceBranch.WORKSPACES,
Text = chatName,
Icon = Icons.Material.Filled.Chat,
Expandable = false,
Path = chatPath,
LastEditTime = lastEditTime,
});
}
if (reloadTreeItems)
this.loadingWorkspaceChatLists.Clear();
var result = new List<TreeItemData<ITreeItem>>(workspaceChats.OrderByDescending(n => n.LastEditTime).Select(n => new TreeItemData<ITreeItem>
{
Expandable = false,
Value = n,
}));
result.Add(new()
{
Expandable = false,
Value = new TreeButton(WorkspaceBranch.WORKSPACES, 2, T("Add chat"),Icons.Material.Filled.AddComment, () => this.AddChat(workspacePath)),
});
return result;
await this.LoadTreeItemsAsync(startPrefetch: false);
}
public async Task StoreChat(ChatThread chat, bool reloadTreeItems = true)
private async Task<ChatThread?> LoadChatAsync(string? chatPath, bool switchToChat)
{
await WorkspaceBehaviour.StoreChat(chat);
// Reload the tree items:
if(reloadTreeItems)
await this.LoadTreeItems();
this.StateHasChanged();
}
private async Task<ChatThread?> LoadChat(string? chatPath, bool switchToChat)
{
if(string.IsNullOrWhiteSpace(chatPath))
if (string.IsNullOrWhiteSpace(chatPath))
return null;
if(!Directory.Exists(chatPath))
if (!Directory.Exists(chatPath))
return null;
// Check if the chat has unsaved changes:
if (switchToChat && await MessageBus.INSTANCE.SendMessageUseFirstResult<bool, bool>(this, Event.HAS_CHAT_UNSAVED_CHANGES))
{
var dialogParameters = new DialogParameters<ConfirmDialog>
{
{ x => x.Message, T("Are you sure you want to load another chat? All unsaved changes will be lost.") },
};
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Load Chat"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
@@ -344,15 +365,15 @@ public partial class Workspaces : MSGComponentBase
return null;
}
public async Task DeleteChat(string? chatPath, bool askForConfirmation = true, bool unloadChat = true)
public async Task DeleteChatAsync(string? chatPath, bool askForConfirmation = true, bool unloadChat = true)
{
var chat = await this.LoadChat(chatPath, false);
var chat = await this.LoadChatAsync(chatPath, false);
if (chat is null)
return;
if (askForConfirmation)
{
var workspaceName = await WorkspaceBehaviour.LoadWorkspaceName(chat.WorkspaceId);
var workspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(chat.WorkspaceId);
var dialogParameters = new DialogParameters<ConfirmDialog>
{
{
@@ -370,16 +391,10 @@ public partial class Workspaces : MSGComponentBase
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();
await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, chat.WorkspaceId, chat.ChatId, askForConfirmation: false);
await this.LoadTreeItemsAsync(startPrefetch: false);
if(unloadChat && this.CurrentChatThread?.ChatId == chat.ChatId)
if (unloadChat && this.CurrentChatThread?.ChatId == chat.ChatId)
{
this.CurrentChatThread = null;
await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
@@ -387,9 +402,9 @@ public partial class Workspaces : MSGComponentBase
}
}
private async Task RenameChat(string? chatPath)
private async Task RenameChatAsync(string? chatPath)
{
var chat = await this.LoadChat(chatPath, false);
var chat = await this.LoadChatAsync(chatPath, false);
if (chat is null)
return;
@@ -410,24 +425,24 @@ public partial class Workspaces : MSGComponentBase
return;
chat.Name = (dialogResult.Data as string)!;
if(this.CurrentChatThread?.ChatId == chat.ChatId)
if (this.CurrentChatThread?.ChatId == chat.ChatId)
{
this.CurrentChatThread.Name = chat.Name;
await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
await MessageBus.INSTANCE.SendMessage<bool>(this, Event.WORKSPACE_LOADED_CHAT_CHANGED);
}
await this.StoreChat(chat);
await this.LoadTreeItems();
await WorkspaceBehaviour.StoreChatAsync(chat);
await this.LoadTreeItemsAsync(startPrefetch: false);
}
private async Task RenameWorkspace(string? workspacePath)
private async Task RenameWorkspaceAsync(string? workspacePath)
{
if(workspacePath is null)
if (workspacePath is null)
return;
var workspaceId = Guid.Parse(Path.GetFileName(workspacePath));
var workspaceName = await WorkspaceBehaviour.LoadWorkspaceName(workspaceId);
var workspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(workspaceId);
var dialogParameters = new DialogParameters<SingleInputDialog>
{
{ x => x.Message, string.Format(T("Please enter a new or edit the name for your workspace '{0}':"), workspaceName) },
@@ -447,10 +462,11 @@ public partial class Workspaces : MSGComponentBase
var alteredWorkspaceName = (dialogResult.Data as string)!;
var workspaceNamePath = Path.Join(workspacePath, "name");
await File.WriteAllTextAsync(workspaceNamePath, alteredWorkspaceName, Encoding.UTF8);
await this.LoadTreeItems();
await WorkspaceBehaviour.UpdateWorkspaceNameInCacheAsync(workspaceId, alteredWorkspaceName);
await this.LoadTreeItemsAsync(startPrefetch: false);
}
private async Task AddWorkspace()
private async Task AddWorkspaceAsync()
{
var dialogParameters = new DialogParameters<SingleInputDialog>
{
@@ -472,23 +488,23 @@ public partial class Workspaces : MSGComponentBase
var workspacePath = Path.Join(SettingsManager.DataDirectory, "workspaces", workspaceId.ToString());
Directory.CreateDirectory(workspacePath);
var workspaceName = (dialogResult.Data as string)!;
var workspaceNamePath = Path.Join(workspacePath, "name");
await File.WriteAllTextAsync(workspaceNamePath, (dialogResult.Data as string)!, Encoding.UTF8);
await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8);
await WorkspaceBehaviour.AddWorkspaceToCacheAsync(workspaceId, workspacePath, workspaceName);
await this.LoadTreeItems();
await this.LoadTreeItemsAsync(startPrefetch: false);
}
private async Task DeleteWorkspace(string? workspacePath)
private async Task DeleteWorkspaceAsync(string? workspacePath)
{
if(workspacePath is null)
if (workspacePath is null)
return;
var workspaceId = Guid.Parse(Path.GetFileName(workspacePath));
var workspaceName = await WorkspaceBehaviour.LoadWorkspaceName(workspaceId);
var workspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(workspaceId);
// Determine how many chats are in the workspace:
var chatCount = Directory.EnumerateDirectories(workspacePath).Count();
var dialogParameters = new DialogParameters<ConfirmDialog>
{
{ x => x.Message, string.Format(T("Are you sure you want to delete the workspace '{0}'? This will also delete {1} chat(s) in this workspace."), workspaceName, chatCount) },
@@ -500,12 +516,13 @@ public partial class Workspaces : MSGComponentBase
return;
Directory.Delete(workspacePath, true);
await this.LoadTreeItems();
await WorkspaceBehaviour.RemoveWorkspaceFromCacheAsync(workspaceId);
await this.LoadTreeItemsAsync(startPrefetch: false);
}
private async Task MoveChat(string? chatPath)
private async Task MoveChatAsync(string? chatPath)
{
var chat = await this.LoadChat(chatPath, false);
var chat = await this.LoadChatAsync(chatPath, false);
if (chat is null)
return;
@@ -525,22 +542,9 @@ public partial class Workspaces : MSGComponentBase
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);
}
await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, chat.WorkspaceId, chat.ChatId, askForConfirmation: 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;
@@ -548,12 +552,12 @@ public partial class Workspaces : MSGComponentBase
await MessageBus.INSTANCE.SendMessage<bool>(this, Event.WORKSPACE_LOADED_CHAT_CHANGED);
}
await this.StoreChat(chat);
await WorkspaceBehaviour.StoreChatAsync(chat);
await this.LoadTreeItemsAsync(startPrefetch: false);
}
private async Task AddChat(string workspacePath)
private async Task AddChatAsync(string workspacePath)
{
// Check if the chat has unsaved changes:
if (await MessageBus.INSTANCE.SendMessageUseFirstResult<bool, bool>(this, Event.HAS_CHAT_UNSAVED_CHANGES))
{
var dialogParameters = new DialogParameters<ConfirmDialog>
@@ -579,9 +583,9 @@ public partial class Workspaces : MSGComponentBase
var chatPath = Path.Join(workspacePath, chat.ChatId.ToString());
await this.StoreChat(chat);
await this.LoadChat(chatPath, switchToChat: true);
await this.LoadTreeItems();
await WorkspaceBehaviour.StoreChatAsync(chat);
await this.LoadChatAsync(chatPath, switchToChat: true);
await this.LoadTreeItemsAsync(startPrefetch: false);
}
#region Overrides of MSGComponentBase
@@ -591,11 +595,20 @@ public partial class Workspaces : MSGComponentBase
switch (triggeredEvent)
{
case Event.PLUGINS_RELOADED:
await this.LoadTreeItems();
await this.InvokeAsync(this.StateHasChanged);
await this.ForceRefreshFromDiskAsync();
break;
}
}
protected override void DisposeResources()
{
this.isDisposed = true;
this.prefetchCancellationTokenSource?.Cancel();
this.prefetchCancellationTokenSource?.Dispose();
this.prefetchCancellationTokenSource = null;
base.DisposeResources();
}
#endregion
}