mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-12 12:52:12 +00:00
Added the option to search for chats in all workspaces
This commit is contained in:
parent
9fc7eaff99
commit
7417c650f5
@ -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"
|
||||
|
||||
|
||||
@ -11,6 +11,36 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (this.isSearchVisible)
|
||||
{
|
||||
<MudStack Class="mx-3 mt-2 mb-1" Spacing="1">
|
||||
<MudStack Row="@true" AlignItems="AlignItems.Center" Wrap="Wrap.NoWrap" Spacing="1">
|
||||
<MudTextField T="string"
|
||||
Text="@this.searchText"
|
||||
TextChanged="@this.OnSearchTextChanged"
|
||||
Placeholder="@T("Search chats")"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
Immediate="@true"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"/>
|
||||
<MudTooltip Text="@T("Clear search")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Clear" Size="Size.Medium" Color="Color.Inherit" Disabled="@(string.IsNullOrWhiteSpace(this.searchText))" OnClick="@this.ClearSearchAsync"/>
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
|
||||
<MudStack Row="@true" AlignItems="AlignItems.Center" Wrap="Wrap.NoWrap" Spacing="1">
|
||||
<MudSwitch T="bool" Value="@this.includeThreadContents" ValueChanged="@this.IncludeThreadContentsChanged" Color="Color.Primary">
|
||||
@T("Search chat contents")
|
||||
</MudSwitch>
|
||||
@if (this.isSearchRunning)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="@true"/>
|
||||
}
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
<MudTreeView T="ITreeItem" Items="@this.treeItems" SelectionMode="SelectionMode.SingleSelection" Hover="@true" ExpandOnClick="@true" Class="ma-3">
|
||||
<ItemTemplate Context="item">
|
||||
@switch (item.Value)
|
||||
@ -71,19 +101,22 @@ else
|
||||
<MudText Style="justify-self: start;">
|
||||
@treeItem.Text
|
||||
</MudText>
|
||||
<div style="justify-self: end;">
|
||||
<MudTooltip Text="@T("Add chat")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.AddComment" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.AddChatAsync(treeItem.Path))"/>
|
||||
</MudTooltip>
|
||||
@if (!this.HasSearchQuery)
|
||||
{
|
||||
<div style="justify-self: end;">
|
||||
<MudTooltip Text="@T("Add chat")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.AddComment" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.AddChatAsync(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.RenameWorkspaceAsync(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.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>
|
||||
<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>
|
||||
|
||||
@ -38,8 +38,14 @@ public partial class Workspaces : MSGComponentBase
|
||||
private readonly HashSet<Guid> 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<ITreeItem>
|
||||
{
|
||||
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<TreeItemData<ITreeItem>>();
|
||||
foreach (var workspace in snapshot.Workspaces)
|
||||
workspaceChildren.Add(this.CreateSearchWorkspaceTreeItem(workspace));
|
||||
|
||||
this.treeItems.Add(new TreeItemData<ITreeItem>
|
||||
{
|
||||
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<ITreeItem>
|
||||
{
|
||||
Expandable = false,
|
||||
Value = new TreeDivider(),
|
||||
});
|
||||
}
|
||||
|
||||
if (snapshot.TemporaryChats.Count > 0)
|
||||
{
|
||||
var temporaryChatsChildren = new List<TreeItemData<ITreeItem>>();
|
||||
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<ITreeItem>
|
||||
{
|
||||
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<ITreeItem> CreateSearchWorkspaceTreeItem(WorkspaceSearchWorkspace workspace)
|
||||
{
|
||||
var children = new List<TreeItemData<ITreeItem>>();
|
||||
foreach (var chat in workspace.Chats)
|
||||
children.Add(this.CreateChatTreeItem(chat.Chat, WorkspaceBranch.WORKSPACES, depth: 2, icon: Icons.Material.Filled.Chat));
|
||||
|
||||
return new TreeItemData<ITreeItem>
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
@ -51,6 +51,9 @@
|
||||
<MudTooltip Text="@T("Reload your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh" Size="Size.Medium" OnClick="@this.RefreshWorkspaces"/>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="@T("Search your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Search" Size="Size.Medium" OnClick="@this.ToggleWorkspaceSearch"/>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="@T("Hide your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Size="Size.Medium" Icon="@this.WorkspaceSidebarToggleIcon" Class="me-1" OnClick="@(() => this.ToggleWorkspaceSidebar())"/>
|
||||
</MudTooltip>
|
||||
@ -77,6 +80,9 @@
|
||||
<MudTooltip Text="@T("Reload your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh" Size="Size.Medium" OnClick="@this.RefreshWorkspaces"/>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="@T("Search your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Search" Size="Size.Medium" OnClick="@this.ToggleWorkspaceSearch"/>
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</HeaderContent>
|
||||
<ChildContent>
|
||||
@ -149,6 +155,9 @@
|
||||
<MudTooltip Text="@T("Reload your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh" Size="Size.Medium" OnClick="@this.RefreshWorkspaces"/>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="@T("Search your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Search" Size="Size.Medium" OnClick="@this.ToggleWorkspaceSearch"/>
|
||||
</MudTooltip>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Close" Color="Color.Error" Size="Size.Medium" OnClick="@(() => this.ToggleWorkspacesOverlay())"/>
|
||||
</MudStack>
|
||||
</MudDrawerHeader>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -230,6 +230,164 @@ public static class WorkspaceBehaviour
|
||||
chats.RemoveAt(existingIndex);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseSearchTerms(string searchText) => searchText
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(term => !string.IsNullOrWhiteSpace(term))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
private static IReadOnlyList<string> GetMissingTerms(string text, IReadOnlyList<string> 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<string> terms)
|
||||
{
|
||||
if (!TryGetAnyProperty(root, out var blocks, "blocks", "Blocks") || blocks.ValueKind is not JsonValueKind.Array)
|
||||
return false;
|
||||
|
||||
var matchedTerms = new HashSet<string>(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<bool> ThreadContainsTermsAsync(WorkspaceTreeChat chat, IReadOnlyList<string> 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<List<WorkspaceSearchResult>> SearchChatsAsync(IReadOnlyList<WorkspaceTreeChat> chats, IReadOnlyList<string> terms, bool includeThreadContents, CancellationToken token)
|
||||
{
|
||||
var results = new List<WorkspaceSearchResult>();
|
||||
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<WorkspaceSearchSnapshot> SearchWorkspaceChatsAsync(string searchText, bool includeThreadContents, CancellationToken token = default)
|
||||
{
|
||||
var terms = ParseSearchTerms(searchText);
|
||||
if (terms.Count == 0)
|
||||
return new([], []);
|
||||
|
||||
List<WorkspaceTreeWorkspace> workspaces;
|
||||
List<WorkspaceTreeChat> 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<WorkspaceSearchWorkspace>();
|
||||
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<Guid, Task>? onWorkspaceUpdated = null, CancellationToken token = default)
|
||||
{
|
||||
while (true)
|
||||
|
||||
3
app/MindWork AI Studio/Tools/WorkspaceSearchResult.cs
Normal file
3
app/MindWork AI Studio/Tools/WorkspaceSearchResult.cs
Normal file
@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public readonly record struct WorkspaceSearchResult(WorkspaceTreeChat Chat, bool NameMatched, bool ThreadMatched);
|
||||
3
app/MindWork AI Studio/Tools/WorkspaceSearchSnapshot.cs
Normal file
3
app/MindWork AI Studio/Tools/WorkspaceSearchSnapshot.cs
Normal file
@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public readonly record struct WorkspaceSearchSnapshot(IReadOnlyList<WorkspaceSearchWorkspace> Workspaces, IReadOnlyList<WorkspaceSearchResult> TemporaryChats);
|
||||
3
app/MindWork AI Studio/Tools/WorkspaceSearchWorkspace.cs
Normal file
3
app/MindWork AI Studio/Tools/WorkspaceSearchWorkspace.cs
Normal file
@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public readonly record struct WorkspaceSearchWorkspace(Guid WorkspaceId, string WorkspacePath, string Name, IReadOnlyList<WorkspaceSearchResult> Chats);
|
||||
@ -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.
|
||||
Loading…
Reference in New Issue
Block a user