diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 99257cc7..b4dbc6dc 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -3196,15 +3196,24 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1469573738"] = "Delete" -- Rename Workspace UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1474303418"] = "Rename Workspace" +-- Clear search +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1511254342"] = "Clear search" + -- Rename Chat UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T156144855"] = "Rename Chat" -- Add workspace UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1586005241"] = "Add workspace" +-- Search chats +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1615077202"] = "Search chats" + -- Add chat UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1874060138"] = "Add chat" +-- No chats found +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1886517101"] = "No chats found" + -- Create Chat UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1939006681"] = "Create Chat" @@ -3250,6 +3259,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Rename" -- Please enter a new or edit the name for your chat '{0}': UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3419791373"] = "Please enter a new or edit the name for your chat '{0}':" +-- Search chat contents +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3436662033"] = "Search chat contents" + -- Load Chat UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3555709365"] = "Load Chat" @@ -5968,6 +5980,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T2813205227"] = "Open Chat Options" -- Disappearing Chat UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T3046519404"] = "Disappearing Chat" +-- Search your workspaces +UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T3059773282"] = "Search your workspaces" + -- Configure your workspaces UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T3586092784"] = "Configure your workspaces" diff --git a/app/MindWork AI Studio/Components/Workspaces.razor b/app/MindWork AI Studio/Components/Workspaces.razor index 940d448d..b93ac244 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor +++ b/app/MindWork AI Studio/Components/Workspaces.razor @@ -11,6 +11,36 @@ } else { + @if (this.isSearchVisible) + { + + + + + + + + + + + @T("Search chat contents") + + @if (this.isSearchRunning) + { + + } + + + } + @switch (item.Value) @@ -71,19 +101,22 @@ else @treeItem.Text -
- - - + @if (!this.HasSearchQuery) + { +
+ + + - - - + + + - - - -
+ + + +
+ } diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs index ef9c15c8..29252601 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor.cs +++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs @@ -38,8 +38,14 @@ public partial class Workspaces : MSGComponentBase private readonly HashSet loadingWorkspaceChatLists = []; private CancellationTokenSource? prefetchCancellationTokenSource; + private CancellationTokenSource? searchCancellationTokenSource; private bool isInitialLoading = true; private bool isDisposed; + private bool isSearchVisible; + private bool includeThreadContents; + private bool isSearchRunning; + private string searchText = string.Empty; + private long searchRevision; #region Overrides of ComponentBase @@ -54,6 +60,7 @@ public partial class Workspaces : MSGComponentBase private async Task LoadTreeItemsAsync(bool startPrefetch = true, bool forceReload = false) { + var shouldRunSearch = false; await this.treeLoadingSemaphore.WaitAsync(); try { @@ -64,7 +71,11 @@ public partial class Workspaces : MSGComponentBase await WorkspaceBehaviour.ForceReloadWorkspaceTreeAsync(); var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync(); - this.BuildTreeItems(snapshot); + if (this.HasSearchQuery) + shouldRunSearch = true; + else + this.BuildTreeItems(snapshot); + this.isInitialLoading = false; } finally @@ -72,12 +83,17 @@ public partial class Workspaces : MSGComponentBase this.treeLoadingSemaphore.Release(); } - await this.SafeStateHasChanged(); + if (shouldRunSearch) + await this.SearchWorkspaceItemsAsync(); + else + await this.SafeStateHasChanged(); if (startPrefetch) await this.StartPrefetchAsync(); } + private bool HasSearchQuery => this.isSearchVisible && !string.IsNullOrWhiteSpace(this.searchText); + private void BuildTreeItems(WorkspaceTreeCacheSnapshot snapshot) { this.treeItems.Clear(); @@ -219,6 +235,109 @@ public partial class Workspaces : MSGComponentBase }; } + private void BuildSearchTreeItems(WorkspaceSearchSnapshot snapshot) + { + this.treeItems.Clear(); + + if (snapshot.Workspaces.Count == 0 && snapshot.TemporaryChats.Count == 0) + { + this.treeItems.Add(new TreeItemData + { + Expandable = false, + Value = new TreeItemData + { + Depth = 0, + Branch = WorkspaceBranch.NONE, + Text = T("No chats found"), + Icon = Icons.Material.Filled.Search, + Expandable = false, + Path = "search_empty", + }, + }); + + return; + } + + if (snapshot.Workspaces.Count > 0) + { + var workspaceChildren = new List>(); + foreach (var workspace in snapshot.Workspaces) + workspaceChildren.Add(this.CreateSearchWorkspaceTreeItem(workspace)); + + this.treeItems.Add(new TreeItemData + { + Expanded = true, + Expandable = true, + Value = new TreeItemData + { + Depth = 0, + Branch = WorkspaceBranch.WORKSPACES, + Text = T("Workspaces"), + Icon = Icons.Material.Filled.Folder, + Expandable = true, + Path = "search_workspaces", + Children = workspaceChildren, + }, + }); + } + + if (snapshot.Workspaces.Count > 0 && snapshot.TemporaryChats.Count > 0) + { + this.treeItems.Add(new TreeItemData + { + Expandable = false, + Value = new TreeDivider(), + }); + } + + if (snapshot.TemporaryChats.Count > 0) + { + var temporaryChatsChildren = new List>(); + foreach (var temporaryChat in snapshot.TemporaryChats) + temporaryChatsChildren.Add(this.CreateChatTreeItem(temporaryChat.Chat, WorkspaceBranch.TEMPORARY_CHATS, depth: 1, icon: Icons.Material.Filled.Timer)); + + this.treeItems.Add(new TreeItemData + { + Expanded = true, + Expandable = true, + Value = new TreeItemData + { + Depth = 0, + Branch = WorkspaceBranch.TEMPORARY_CHATS, + Text = T("Disappearing Chats"), + Icon = Icons.Material.Filled.Timer, + Expandable = true, + Path = "search_temp", + Children = temporaryChatsChildren, + }, + }); + } + } + + private TreeItemData CreateSearchWorkspaceTreeItem(WorkspaceSearchWorkspace workspace) + { + var children = new List>(); + foreach (var chat in workspace.Chats) + children.Add(this.CreateChatTreeItem(chat.Chat, WorkspaceBranch.WORKSPACES, depth: 2, icon: Icons.Material.Filled.Chat)); + + return new TreeItemData + { + Expanded = true, + 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 string GetTreeItemIcon(TreeItemData treeItem) { if (treeItem.Type is not TreeItemType.CHAT) @@ -289,6 +408,103 @@ public partial class Workspaces : MSGComponentBase } } + public async Task ToggleSearchAsync() + { + this.isSearchVisible = !this.isSearchVisible; + if (this.isSearchVisible) + { + await this.SafeStateHasChanged(); + return; + } + + await this.CancelSearchAsync(); + this.searchText = string.Empty; + this.isSearchRunning = false; + await this.LoadTreeItemsAsync(startPrefetch: false); + } + + private async Task CancelSearchAsync() + { + this.searchRevision++; + if (this.searchCancellationTokenSource is not null) + { + await this.searchCancellationTokenSource.CancelAsync(); + this.searchCancellationTokenSource.Dispose(); + this.searchCancellationTokenSource = null; + } + } + + private async Task OnSearchTextChanged(string value) + { + this.searchText = value; + if (string.IsNullOrWhiteSpace(this.searchText)) + { + await this.CancelSearchAsync(); + this.isSearchRunning = false; + await this.LoadTreeItemsAsync(startPrefetch: false); + return; + } + + await this.SearchWorkspaceItemsAsync(); + } + + private async Task IncludeThreadContentsChanged(bool value) + { + this.includeThreadContents = value; + if (this.HasSearchQuery) + await this.SearchWorkspaceItemsAsync(); + } + + private async Task ClearSearchAsync() + { + this.searchText = string.Empty; + await this.CancelSearchAsync(); + this.isSearchRunning = false; + await this.LoadTreeItemsAsync(startPrefetch: false); + } + + private async Task SearchWorkspaceItemsAsync() + { + await this.CancelSearchAsync(); + + var text = this.searchText; + if (string.IsNullOrWhiteSpace(text)) + return; + + this.searchCancellationTokenSource = new CancellationTokenSource(); + var token = this.searchCancellationTokenSource.Token; + var revision = ++this.searchRevision; + + this.isSearchRunning = true; + await this.SafeStateHasChanged(); + + try + { + var snapshot = await WorkspaceBehaviour.SearchWorkspaceChatsAsync(text, this.includeThreadContents, token); + if (this.isDisposed || token.IsCancellationRequested || revision != this.searchRevision) + return; + + this.BuildSearchTreeItems(snapshot); + } + catch (OperationCanceledException) + { + // Expected when the user keeps typing or hides the search row. + } + catch (Exception ex) + { + this.Logger.LogWarning(ex, "Failed while searching workspace chats."); + this.BuildSearchTreeItems(new([], [])); + } + finally + { + if (revision == this.searchRevision) + { + this.isSearchRunning = false; + await this.SafeStateHasChanged(); + } + } + } + private async Task OnWorkspaceClicked(TreeItemData treeItem) { if (treeItem.Type is not TreeItemType.WORKSPACE) @@ -656,6 +872,9 @@ public partial class Workspaces : MSGComponentBase this.prefetchCancellationTokenSource?.Cancel(); this.prefetchCancellationTokenSource?.Dispose(); this.prefetchCancellationTokenSource = null; + this.searchCancellationTokenSource?.Cancel(); + this.searchCancellationTokenSource?.Dispose(); + this.searchCancellationTokenSource = null; base.DisposeResources(); } diff --git a/app/MindWork AI Studio/Pages/Chat.razor b/app/MindWork AI Studio/Pages/Chat.razor index f35a00a6..8b0c92fd 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor +++ b/app/MindWork AI Studio/Pages/Chat.razor @@ -51,6 +51,9 @@ + + + @@ -77,6 +80,9 @@ + + + @@ -149,6 +155,9 @@ + + + diff --git a/app/MindWork AI Studio/Pages/Chat.razor.cs b/app/MindWork AI Studio/Pages/Chat.razor.cs index 0f271076..9ba92bc1 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor.cs +++ b/app/MindWork AI Studio/Pages/Chat.razor.cs @@ -107,6 +107,14 @@ public partial class Chat : MSGComponentBase await this.workspaces.ForceRefreshFromDiskAsync(); } + private async Task ToggleWorkspaceSearch() + { + if (this.workspaces is null) + return; + + await this.workspaces.ToggleSearchAsync(); + } + #region Overrides of MSGComponentBase protected override void DisposeResources() diff --git a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs index c03fccc8..d64649f4 100644 --- a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs +++ b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs @@ -230,6 +230,164 @@ public static class WorkspaceBehaviour chats.RemoveAt(existingIndex); } + private static IReadOnlyList ParseSearchTerms(string searchText) => searchText + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(term => !string.IsNullOrWhiteSpace(term)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + private static IReadOnlyList GetMissingTerms(string text, IReadOnlyList terms) => terms + .Where(term => text.IndexOf(term, StringComparison.OrdinalIgnoreCase) < 0) + .ToList(); + + private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement property) + { + if (element.ValueKind is JsonValueKind.Object) + { + foreach (var candidate in element.EnumerateObject()) + { + if (string.Equals(candidate.Name, propertyName, StringComparison.OrdinalIgnoreCase)) + { + property = candidate.Value; + return true; + } + } + } + + property = default; + return false; + } + + private static bool TryGetAnyProperty(JsonElement element, out JsonElement property, params string[] propertyNames) + { + foreach (var propertyName in propertyNames) + if (TryGetProperty(element, propertyName, out property)) + return true; + + property = default; + return false; + } + + private static bool IsHiddenFromUser(JsonElement block) + { + if (!TryGetAnyProperty(block, out var hideFromUser, "hide_from_user", "HideFromUser")) + return false; + + return hideFromUser.ValueKind is JsonValueKind.True || (hideFromUser.ValueKind is JsonValueKind.String && bool.TryParse(hideFromUser.GetString(), out var parsed) && parsed); + } + + private static bool TryReadTextContent(JsonElement block, out string text) + { + if (TryGetAnyProperty(block, out var contentType, "content_type", "ContentType") + && contentType.ValueKind is JsonValueKind.String + && !string.Equals(contentType.GetString(), "TEXT", StringComparison.OrdinalIgnoreCase)) + { + text = string.Empty; + return false; + } + + if (!TryGetAnyProperty(block, out var content, "content", "Content") || content.ValueKind is not JsonValueKind.Object) + { + text = string.Empty; + return false; + } + + if (TryGetAnyProperty(content, out var typeDiscriminator, "$type") + && typeDiscriminator.ValueKind is JsonValueKind.String + && !string.Equals(typeDiscriminator.GetString(), "text", StringComparison.OrdinalIgnoreCase)) + { + text = string.Empty; + return false; + } + + if (!TryGetAnyProperty(content, out var textElement, "text", "Text") || textElement.ValueKind is not JsonValueKind.String) + { + text = string.Empty; + return false; + } + + text = textElement.GetString() ?? string.Empty; + return !string.IsNullOrWhiteSpace(text); + } + + private static bool VisibleThreadTextContainsTerms(JsonElement root, IReadOnlyList terms) + { + if (!TryGetAnyProperty(root, out var blocks, "blocks", "Blocks") || blocks.ValueKind is not JsonValueKind.Array) + return false; + + var matchedTerms = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var block in blocks.EnumerateArray()) + { + if (IsHiddenFromUser(block) || !TryReadTextContent(block, out var text)) + continue; + + foreach (var term in terms) + if (text.IndexOf(term, StringComparison.OrdinalIgnoreCase) >= 0) + matchedTerms.Add(term); + + if (matchedTerms.Count == terms.Count) + return true; + } + + return false; + } + + private static async Task ThreadContainsTermsAsync(WorkspaceTreeChat chat, IReadOnlyList terms, CancellationToken token) + { + var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(chat.WorkspaceId, chat.ChatId, nameof(ThreadContainsTermsAsync)); + if (!acquired) + return false; + + try + { + var threadPath = Path.Join(chat.ChatPath, "thread.json"); + if (!File.Exists(threadPath)) + return false; + + await using var stream = File.OpenRead(threadPath); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: token); + return VisibleThreadTextContainsTerms(document.RootElement, terms); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + LOG.LogWarning(ex, "Failed to search chat thread for workspace '{WorkspaceId}', chat '{ChatId}'.", chat.WorkspaceId, chat.ChatId); + return false; + } + finally + { + semaphore.Release(); + } + } + + private static async Task> SearchChatsAsync(IReadOnlyList chats, IReadOnlyList terms, bool includeThreadContents, CancellationToken token) + { + var results = new List(); + foreach (var chat in chats) + { + token.ThrowIfCancellationRequested(); + + var missingTerms = GetMissingTerms(chat.Name, terms); + if (missingTerms.Count == 0) + { + results.Add(new(chat, NameMatched: true, ThreadMatched: false)); + continue; + } + + if (!includeThreadContents) + continue; + + var threadMatched = await ThreadContainsTermsAsync(chat, missingTerms, token); + if (threadMatched) + results.Add(new(chat, NameMatched: false, ThreadMatched: true)); + } + + return results; + } + private static async Task UpdateCacheAfterChatStored(Guid workspaceId, Guid chatId, string chatDirectory, string chatName, DateTimeOffset lastEditTime) { await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(); @@ -348,6 +506,55 @@ public static class WorkspaceBehaviour } } + public static async Task SearchWorkspaceChatsAsync(string searchText, bool includeThreadContents, CancellationToken token = default) + { + var terms = ParseSearchTerms(searchText); + if (terms.Count == 0) + return new([], []); + + List workspaces; + List temporaryChats; + + await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(token); + try + { + await EnsureTreeShellLoadedCoreAsync(); + workspaces = []; + foreach (var workspaceId in WORKSPACE_TREE_CACHE.WorkspaceOrder) + { + token.ThrowIfCancellationRequested(); + if (!WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace)) + continue; + + if (!workspace.ChatsLoaded) + { + workspace.Chats = await ReadWorkspaceChatsCoreAsync(workspaceId, workspace.WorkspacePath); + workspace.ChatsLoaded = true; + } + + workspaces.Add(ToPublicWorkspace(workspace)); + } + + temporaryChats = WORKSPACE_TREE_CACHE.TemporaryChats.Select(ToPublicChat).ToList(); + } + finally + { + WORKSPACE_TREE_CACHE_SEMAPHORE.Release(); + } + + var matchingWorkspaces = new List(); + foreach (var workspace in workspaces) + { + token.ThrowIfCancellationRequested(); + var matchingChats = await SearchChatsAsync(workspace.Chats, terms, includeThreadContents, token); + if (matchingChats.Count > 0) + matchingWorkspaces.Add(new(workspace.WorkspaceId, workspace.WorkspacePath, workspace.Name, matchingChats)); + } + + var matchingTemporaryChats = await SearchChatsAsync(temporaryChats, terms, includeThreadContents, token); + return new(matchingWorkspaces, matchingTemporaryChats); + } + public static async Task TryPrefetchRemainingChatsAsync(Func? onWorkspaceUpdated = null, CancellationToken token = default) { while (true) diff --git a/app/MindWork AI Studio/Tools/WorkspaceSearchResult.cs b/app/MindWork AI Studio/Tools/WorkspaceSearchResult.cs new file mode 100644 index 00000000..bd09ebff --- /dev/null +++ b/app/MindWork AI Studio/Tools/WorkspaceSearchResult.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Tools; + +public readonly record struct WorkspaceSearchResult(WorkspaceTreeChat Chat, bool NameMatched, bool ThreadMatched); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/WorkspaceSearchSnapshot.cs b/app/MindWork AI Studio/Tools/WorkspaceSearchSnapshot.cs new file mode 100644 index 00000000..9d1c2271 --- /dev/null +++ b/app/MindWork AI Studio/Tools/WorkspaceSearchSnapshot.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Tools; + +public readonly record struct WorkspaceSearchSnapshot(IReadOnlyList Workspaces, IReadOnlyList TemporaryChats); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/WorkspaceSearchWorkspace.cs b/app/MindWork AI Studio/Tools/WorkspaceSearchWorkspace.cs new file mode 100644 index 00000000..1c8ce2ac --- /dev/null +++ b/app/MindWork AI Studio/Tools/WorkspaceSearchWorkspace.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Tools; + +public readonly record struct WorkspaceSearchWorkspace(Guid WorkspaceId, string WorkspacePath, string Name, IReadOnlyList Chats); \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.6.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.6.1.md index ecdb4c5e..8cf1b5c9 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.6.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.6.1.md @@ -4,6 +4,7 @@ - Added support for managed custom root certificate bundles and host allowlists for external HTTPS requests, helping Flatpak deployments connect to organization-internal services with private root CAs while keeping built-in cloud provider endpoints on system trust. - Added support for reading enterprise policy files from a Flatpak provisioning extension. - Added startup path and Linux package type details to the information page to make support easier. +- Added the option to search for chats in all workspaces. - Improved workspaces by adding a shortcut to start a new chat directly from each workspace row. - Improved the enterprise configuration details on the information page by showing where each configuration comes from and which configuration slot was used. - Upgraded dependencies. \ No newline at end of file