Added mailboxes as local data sources (#1022)
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (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, nsis) (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,updater, appimage) (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,app,updater, dmg) (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, nsis) (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,updater, appimage) (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-10-04 12:26:26 +02:00
1 parent 93be539e50
commit c4400c0ff6
343 files changed
+26051 -3122

No files matched your search

@@ -18,6 +18,9 @@ public partial class AssistantPluginSecurityCard : MSGComponentBase
[Inject]
private IDialogService DialogService { get; init; } = null!;
[Inject]
private ToolRegistry ToolRegistry { get; init; } = null!;
private PluginAssistantSecurityState SecurityState => this.Plugin is null
? new PluginAssistantSecurityState()
: PluginAssistantSecurityResolver.Resolve(this.SettingsManager, this.Plugin);
@@ -29,9 +32,10 @@ public partial class AssistantPluginSecurityCard : MSGComponentBase
/// Tools are a capability, not a detail: an assistant allowed to search the web or read a page
/// can carry what a user typed out of the app. Whoever decides whether to enable this plugin
/// should see that beforehand, which is why the count sits in the header next to the audit
/// level and the tools themselves are named in the details.
/// level and the tools themselves are named in the details. A collection is named by its
/// tools, since each of them is a capability of its own, see ToolRegistry.ExpandSelection.
/// </remarks>
private IReadOnlyList<string> PluginToolIds => ToolSelectionRules.NormalizeSelection(this.Plugin?.AssistantToolIds ?? this.Plugin?.ChatLaunchConfiguration?.ToolIds ?? [])
private IReadOnlyList<string> PluginToolIds => this.ToolRegistry.ExpandSelection(this.Plugin?.AssistantToolIds ?? this.Plugin?.ChatLaunchConfiguration?.ToolIds ?? [])
.OrderBy(x => x, StringComparer.Ordinal)
.ToList();
@@ -136,7 +136,7 @@
@if (this.SettingsManager.AreToolsEnabled())
{
<ToolSelection Component="Components.CHAT" LLMProvider="@this.Provider" SelectedToolIds="@this.selectedToolIds" SelectedToolIdsChanged="@this.SelectedToolIdsChanged" Disabled="@this.IsCurrentChatStreaming" />
<ToolSelection Component="Components.CHAT" LLMProvider="@this.Provider" RequiredOutboundDataRestriction="@(this.ChatThread?.RequiredOutboundDataRestriction ?? OutboundDataRequirement.NONE)" SelectedToolIds="@this.selectedToolIds" SelectedToolIdsChanged="@this.SelectedToolIdsChanged" Disabled="@this.IsCurrentChatStreaming" />
}
@if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager))
@@ -729,7 +729,7 @@ public partial class ChatComponent : MSGComponentBase
{
if (this.currentChatTemplate.ToolIds is not { } templateToolIds)
{
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(this.SettingsManager.GetDefaultToolIds(Tools.Components.CHAT));
this.selectedToolIds = this.ToolRegistry.GetDefaultToolIds(Tools.Components.CHAT);
return;
}
@@ -1149,7 +1149,7 @@ public partial class ChatComponent : MSGComponentBase
this.StateHasChanged();
this.ChatThread!.RuntimeComponent = Tools.Components.CHAT;
this.ChatThread.SelectedToolIds = [..this.selectedToolIds];
this.ChatThread.RuntimeSelectedToolIds = this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds);
this.ChatThread.RuntimeSelectedToolIds = this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds, this.ChatThread.RequiredOutboundDataRestriction.Restriction);
await this.AIJobService.TryStartChatGenerationAsync(new ChatGenerationRequest
{
ChatThread = this.ChatThread,
@@ -1178,11 +1178,13 @@ public partial class ChatComponent : MSGComponentBase
/// the footer would keep showing the tools of the chat before it.
/// </remarks>
private void ApplyToolSelectionOfLoadedChat() =>
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(this.ChatThread?.SelectedToolIds ?? this.SettingsManager.GetDefaultToolIds(Tools.Components.CHAT));
this.selectedToolIds = this.ChatThread?.SelectedToolIds is { } storedToolIds
? this.ToolRegistry.NormalizeSelection(storedToolIds)
: this.ToolRegistry.GetDefaultToolIds(Tools.Components.CHAT);
private void SelectedToolIdsChanged(HashSet<string> updatedToolIds)
{
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(updatedToolIds);
this.selectedToolIds = this.ToolRegistry.NormalizeSelection(updatedToolIds);
//
// The thread keeps the selection so that reopening the chat tomorrow brings the same tools
@@ -1667,8 +1669,9 @@ public partial class ChatComponent : MSGComponentBase
/// The tools the next request would offer the model.
/// </summary>
/// <remarks>
/// Filtered for the provider the same way they are before sending, so that a tool the provider
/// is not trusted enough to receive does not count either.
/// Filtered for the provider and the chat the same way they are before sending, so that a tool
/// the provider is not trusted enough to receive does not count, and neither does one which a
/// mailbox the chat read from keeps back. A new chat read no mailbox, so nothing is kept back.
///
/// Asked for once and used twice: their policy goes into the system prompt, and their schemas
/// travel next to it in the request body. Both cost tokens, and both change the moment somebody
@@ -1679,14 +1682,18 @@ public partial class ChatComponent : MSGComponentBase
/// the request asks for.
///
/// Read Web Page likewise counts with its registered instructions, those of its default free
/// address choice. With the choice switched on, a request carries a shorter instruction, so the
/// count comes out a few tokens high.
/// address choice in a chat which read no mailbox. With the choice switched on, a request
/// carries a shorter instruction, and in a chat which read a mailbox a longer one, so the count
/// comes out a few tokens off. One case it gets wrong as a whole: a mailbox which allows the
/// configured services only, with no wiki configured, leaves Read Web Page nothing to offer, and
/// only the request finds that out, see ReadWebPageTool.ResolveFunctionAsync.
/// </remarks>
/// <param name="offersSemanticSearch">Whether the next request offers Semantic Search, see OffersSemanticSearchAsync.</param>
/// <returns>The definitions of the selected tools, and of Semantic Search when it is offered.</returns>
private IReadOnlyList<ToolDefinition> GetRunnableToolDefinitions(bool offersSemanticSearch)
{
var definitions = this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds)
var outboundDataRestriction = (this.ChatThread?.RequiredOutboundDataRestriction ?? OutboundDataRequirement.NONE).Restriction;
var definitions = this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds, outboundDataRestriction)
.Select(this.ToolRegistry.GetDefinition)
.Where(definition => definition is not null)
.Select(definition => definition!)
@@ -39,6 +39,7 @@ public partial class DataSourceCloudEmbeddingWarning : MSGComponentBase
{
DataSourceType.LOCAL_DIRECTORY => T("All files in this folder and its subfolders"),
DataSourceType.LOCAL_FILE => T("The selected file"),
DataSourceType.MAILBOX => T("Every mail of this mailbox in the selected folder and period, together with the text of its attachments,"),
_ => T("The selected data")
};
@@ -46,6 +47,7 @@ public partial class DataSourceCloudEmbeddingWarning : MSGComponentBase
{
DataSourceType.LOCAL_DIRECTORY => string.Format(T("All files in the folder '{0}' and its subfolders"), this.SourcePath),
DataSourceType.LOCAL_FILE => string.Format(T("The file '{0}'"), this.SourcePath),
DataSourceType.MAILBOX => string.Format(T("Every mail of the mailbox '{0}' in the selected folder and period, together with the text of its attachments,"), this.SourcePath),
_ => string.Format(T("The data source '{0}'"), this.SourcePath)
};
}
@@ -3,18 +3,25 @@
@inherits MSGComponentBase
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("You might configure different data sources. A data source can include one file, all files in a directory, or data from your company. Later, you can incorporate these data sources as needed when the AI requires this data to complete a certain task.")
@if (this.AreMailboxesEnabled)
{
@T("You might configure different data sources. A data source can include one file, all files in a directory, a mailbox, or data from your company. Later, you can incorporate these data sources as needed when the AI requires this data to complete a certain task.")
}
else
{
@T("You might configure different data sources. A data source can include one file, all files in a directory, or data from your company. Later, you can incorporate these data sources as needed when the AI requires this data to complete a certain task.")
}
</MudJustifiedText>
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-3" Wrap="Wrap.Wrap">
<MudTextSwitch Label="@T("Automatic local data source refresh")" Value="@this.SettingsManager.ConfigurationData.App.DataSourceIndexing.AutomaticRefresh" LabelOn="@T("Local data sources refresh when files change.")" LabelOff="@T("Local data sources refresh only when triggered manually.")" ValueChanged="@this.AutomaticRefreshChanged"/>
<MudTextSwitch Label="@T("Automatic local data source refresh")" Value="@this.SettingsManager.ConfigurationData.App.DataSourceIndexing.AutomaticRefresh" LabelOn="@(this.AreMailboxesEnabled ? T("Local data sources refresh when files change, mailboxes every 16 minutes.") : T("Local data sources refresh when files change."))" LabelOff="@T("Local data sources refresh only when triggered manually.")" ValueChanged="@this.AutomaticRefreshChanged"/>
<MudTooltip Text="@T("Refresh all")">
<MudIconButton Color="Color.Primary" Icon="@Icons.Material.Filled.Sync" Disabled="@(!this.HasRefreshableDataSources())" OnClick="@this.RefreshAllDataSources"/>
</MudTooltip>
</MudStack>
@{ var embeddingStatuses = this.DataSourceEmbeddingService.GetStatuses().ToDictionary(status => status.DataSourceId, StringComparer.OrdinalIgnoreCase); }
<MudTable Items="@this.SettingsManager.ConfigurationData.DataSources" Hover="@true" Class="border-dashed border rounded-lg">
<MudTable T="IDataSourceBase" Items="@this.ConfiguredDataSources" Hover="@true" Class="border-dashed border rounded-lg">
<ColGroup>
<col style="width: 3em;"/>
<col/>
@@ -28,7 +35,7 @@
<MudTh>@T("Name")</MudTh>
<MudTh>@T("Type")</MudTh>
<MudTh>@T("Embedding")</MudTh>
<MudTh>@T("Indexed files")</MudTh>
<MudTh>@T("Indexed")</MudTh>
<MudTh>@T("Actions")</MudTh>
</HeaderContent>
<RowTemplate>
@@ -38,12 +45,12 @@
<MudTd Style="white-space: nowrap;">@context.Type.GetDisplayName()</MudTd>
<MudTd Style="white-space: nowrap;">@this.GetEmbeddingName(context)</MudTd>
<MudTd>
@if (context is IInternalDataSource)
@if (context is IIndexedDataSource)
{
<MudTooltip Text="@this.GetIndexingStatusTooltip(embeddingStatus)">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.Circle" Color="@GetIndexingStatusColor(embeddingStatus)" Size="Size.Small" Style="width: 0.75rem; height: 0.75rem; flex-shrink: 0;"/>
<MudText Typo="Typo.body2">@(embeddingStatus is null ? T("Not available") : string.Format(T("{0} of {1}"), embeddingStatus.IndexedFiles, embeddingStatus.TotalFiles))</MudText>
<MudText Typo="Typo.body2">@(embeddingStatus is null ? T("Not available") : string.Format(T("{0} of {1}"), embeddingStatus.IndexedDocuments.CompactCount(), embeddingStatus.TotalDocuments.CompactCount()))</MudText>
</MudStack>
</MudTooltip>
}
@@ -56,9 +63,12 @@
<MudTd>
<MudStack Row="true" Class="mb-2 mt-2" Spacing="1" Wrap="Wrap.NoWrap">
<MudTooltip Text="@T("Information")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Outlined.Info" OnClick="@(() => this.ShowInformation(context))"/>
</MudTooltip>
@if (context is IDataSource dataSource)
{
<MudTooltip Text="@T("Information")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Outlined.Info" OnClick="@(() => this.ShowInformation(dataSource))"/>
</MudTooltip>
}
@*
Outside the two branches below on purpose: an index which cannot be read is a
matter of this machine, not of the configuration. Hiding the repair for a data
@@ -80,7 +90,7 @@
else
{
<MudTooltip Text="@T("Edit")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Edit" OnClick="@(() => this.EditDataSource(context))"/>
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Edit" OnClick="@(() => this.EditEntry(context))"/>
</MudTooltip>
<MudTooltip Text="@T("Refresh")">
<MudIconButton Color="Color.Primary" Icon="@Icons.Material.Filled.Sync" Disabled="@(!this.CanRefreshDataSource(context))" OnClick="@(() => this.RefreshDataSource(context))"/>
@@ -90,7 +100,7 @@
<AdminExportButton OnClick="@(() => this.ExportDataSource(context))" />
}
<MudTooltip Text="@T("Delete")">
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="@(() => this.DeleteDataSource(context))"/>
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="@(() => this.DeleteEntry(context))"/>
</MudTooltip>
}
</MudStack>
@@ -98,7 +108,7 @@
</RowTemplate>
</MudTable>
@if (this.SettingsManager.ConfigurationData.DataSources.Count == 0)
@if (!this.ConfiguredDataSources.Any())
{
<MudText Typo="Typo.h6" Class="mt-3">
@T("No data sources configured yet.")
@@ -115,4 +125,11 @@
<MudMenuItem OnClick="@(() => this.AddDataSource(DataSourceType.LOCAL_FILE))">
@T("Local File")
</MudMenuItem>
@if (PreviewFeatures.PRE_MAILBOXES_2026.IsEnabled(this.SettingsManager))
{
@* Shown with a lock rather than left out, like the locked buttons for adding providers: *@
<MudMenuItem OnClick="@this.AddMailbox" Disabled="@(!this.MayAddMailbox)" Icon="@(this.MayAddMailbox ? null : Icons.Material.Filled.Lock)">
@(this.MayAddMailbox ? T("Mailbox (IMAP)") : T("Mailbox (IMAP), not allowed by your organization"))
</MudMenuItem>
}
</MudMenu>
@@ -1,6 +1,7 @@
using AIStudio.Dialogs;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Databases;
using AIStudio.Tools.ERIClient.DataModel;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
@@ -26,8 +27,21 @@ public partial class DataSourceManagement : MSGComponentBase
[Inject]
private RustService RustService { get; init; } = null!;
[Inject]
private DatabaseClientProvider DatabaseClientProvider { get; init; } = null!;
private readonly List<ConfigurationSelectData<string>> availableEmbeddingProviders = new();
/// <summary>
/// The data sources and then the mailboxes, which the table shows together.
/// </summary>
private IEnumerable<IDataSourceBase> ConfiguredDataSources => this.SettingsManager.ConfigurationData.DataSources
.Concat(this.SettingsManager.ConfigurationData.Mailboxes.Cast<IDataSourceBase>());
private bool AreMailboxesEnabled => PreviewFeatures.PRE_MAILBOXES_2026.IsEnabled(this.SettingsManager);
private bool MayAddMailbox => this.SettingsManager.ConfigurationData.App.AllowUserToAddMailbox;
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
@@ -77,7 +91,7 @@ public partial class DataSourceManagement : MSGComponentBase
if (status is null || status.State is DataSourceEmbeddingState.IDLE or DataSourceEmbeddingState.QUEUED or DataSourceEmbeddingState.RUNNING)
return Color.Warning;
return status.State is DataSourceEmbeddingState.FAILED || status.FailedFiles > 0
return status.State is DataSourceEmbeddingState.FAILED || status.FailedDocuments > 0
? Color.Error
: Color.Success;
}
@@ -95,17 +109,18 @@ public partial class DataSourceManagement : MSGComponentBase
if (status is null)
return T("Waiting for indexing status");
if (status.PermanentlySkippedFiles == 0)
// Only files are skipped for want of readable text. A mail is read from the server, never from a file:
if (status.PermanentlySkippedDocuments == 0 || status.DataSourceType is DataSourceType.MAILBOX)
return status.StateLabel;
return $"{status.StateLabel} — {string.Format(T("{0} files were skipped because they contain no readable text. AI Studio reads them again once they change."), status.PermanentlySkippedFiles)}";
return $"{status.StateLabel} — {string.Format(T("{0} files were skipped because they contain no readable text. AI Studio reads them again once they change."), status.PermanentlySkippedDocuments)}";
}
private string GetEmbeddingName(IDataSource dataSource)
private string GetEmbeddingName(IDataSourceBase dataSource)
{
if(dataSource is IInternalDataSource internalDataSource)
if(dataSource is IIndexedDataSource indexedDataSource)
{
var matchedEmbedding = this.SettingsManager.ConfigurationData.EmbeddingProviders.FirstOrDefault(x => x.Id == internalDataSource.EmbeddingId);
var matchedEmbedding = this.SettingsManager.ConfigurationData.EmbeddingProviders.FirstOrDefault(x => x.Id == indexedDataSource.EmbeddingId);
if(matchedEmbedding == default)
return T("No valid embedding");
@@ -118,14 +133,14 @@ public partial class DataSourceManagement : MSGComponentBase
return T("Unknown");
}
private bool CanRefreshDataSource(IDataSource dataSource)
private bool CanRefreshDataSource(IDataSourceBase dataSource)
{
return this.DataSourceEmbeddingService.CanRefreshDataSource(dataSource);
}
private bool HasRefreshableDataSources()
{
return this.SettingsManager.ConfigurationData.DataSources.Any(this.CanRefreshDataSource);
return this.ConfiguredDataSources.Any(this.CanRefreshDataSource);
}
/// <remarks>
@@ -134,12 +149,12 @@ public partial class DataSourceManagement : MSGComponentBase
/// every internal data source regardless of state, and singling this one out would say more
/// about the state than that button ever has.
/// </remarks>
private bool CanRepairDataSource(IDataSource dataSource)
private bool CanRepairDataSource(IDataSourceBase dataSource)
{
return this.DataSourceEmbeddingService.NeedsIndexRepair(dataSource);
}
private async Task RepairDataSource(IDataSource dataSource)
private async Task RepairDataSource(IDataSourceBase dataSource)
{
if (!this.CanRepairDataSource(dataSource))
return;
@@ -161,7 +176,7 @@ public partial class DataSourceManagement : MSGComponentBase
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
private async Task RefreshDataSource(IDataSource dataSource)
private async Task RefreshDataSource(IDataSourceBase dataSource)
{
if (!this.CanRefreshDataSource(dataSource))
return;
@@ -235,7 +250,7 @@ public partial class DataSourceManagement : MSGComponentBase
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
private async Task ExportDataSource(IDataSource dataSource)
private async Task ExportDataSource(IDataSourceBase dataSource)
{
if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings)
return;
@@ -442,6 +457,112 @@ public partial class DataSourceManagement : MSGComponentBase
}
}
/// <summary>
/// Edits a row of the table, which is either a data source or a mailbox.
/// </summary>
private Task EditEntry(IDataSourceBase entry) => entry switch
{
DataSourceMailbox mailbox => this.EditMailbox(mailbox),
IDataSource dataSource => this.EditDataSource(dataSource),
_ => Task.CompletedTask,
};
/// <summary>
/// Deletes a row of the table, which is either a data source or a mailbox.
/// </summary>
private Task DeleteEntry(IDataSourceBase entry) => entry switch
{
DataSourceMailbox mailbox => this.DeleteMailbox(mailbox),
IDataSource dataSource => this.DeleteDataSource(dataSource),
_ => Task.CompletedTask,
};
private async Task AddMailbox()
{
if (!this.MayAddMailbox)
return;
var dialogParameters = new DialogParameters<DataSourceMailboxDialog>
{
{ x => x.IsEditing, false },
{ x => x.AvailableEmbeddings, this.availableEmbeddingProviders }
};
var dialogReference = await this.DialogService.ShowAsync<DataSourceMailboxDialog>(T("Add Mailbox"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
return;
var mailbox = (DataSourceMailbox)dialogResult.Data!;
//
// The dialog refuses to add a mailbox once the organization rules them out, but the rule
// may change while it stores the password. Then the password goes again, as far as possible:
//
if (!this.MayAddMailbox)
{
await this.RustService.DeleteSecret(mailbox, SecretStoreType.DATA_SOURCE);
return;
}
mailbox = mailbox with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ };
this.SettingsManager.ConfigurationData.Mailboxes.Add(mailbox);
await this.SettingsManager.StoreSettings();
await this.DataSourceEmbeddingService.QueueDataSourceAsync(mailbox);
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
private async Task EditMailbox(DataSourceMailbox mailbox)
{
if (await MailboxEditing.EditAsync(this.DialogService, this.SettingsManager, this.DataSourceEmbeddingService, mailbox.Id))
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
/// <summary>
/// Deletes a mailbox from AI Studio: its settings, its password, its index, and its recorded sign-in failure.
/// </summary>
/// <remarks>
/// The sign-in failure is deleted on its own, since it outlives the index on purpose: repairing
/// an index must not give the server a new attempt with a password it already refused.
/// </remarks>
private async Task DeleteMailbox(DataSourceMailbox mailbox)
{
if (mailbox.IsEnterpriseConfiguration)
return;
var dialogParameters = new DialogParameters<ConfirmDialog>
{
{ x => x.Message, string.Format(T("Are you sure you want to delete the mailbox '{0}'? Your mails stay on the server as they are. AI Studio only deletes its index of them and the stored password."), mailbox.Name) },
};
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Delete Mailbox"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
return;
var deleteSecretResponse = await this.RustService.DeleteSecret(mailbox, SecretStoreType.DATA_SOURCE);
if (!deleteSecretResponse.Success)
{
await this.DialogService.ShowMessageBox(
T("Delete Mailbox"),
string.Format(T("The password of this mailbox could not be deleted from the operating system, so the mailbox was kept. The issue was: {0}"), deleteSecretResponse.Issue),
T("Close"));
return;
}
this.SettingsManager.ConfigurationData.Mailboxes.Remove(mailbox);
await this.SettingsManager.StoreSettings();
await this.DataSourceEmbeddingService.RemoveDataSourceAsync(mailbox);
var indexStore = await this.DatabaseClientProvider.GetIndexStoreAsync();
await indexStore.ClearMailboxAuthFailureAsync(mailbox.Id, CancellationToken.None);
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
private async Task ShowInformation(IDataSource dataSource)
{
switch (dataSource)
@@ -0,0 +1,28 @@
@using AIStudio.Tools.Mail
@inherits MSGComponentBase
<MudTreeView T="string" Items="@this.treeItems" SelectionMode="SelectionMode.SingleSelection" SelectedValue="@this.SelectedFolder" SelectedValueChanged="@this.SelectFolder" Hover="@true" Dense="@true" Disabled="@this.Disabled" Class="mb-3">
<ItemTemplate Context="item">
<MudTreeViewItem T="string" Value="@item.Value" Text="@item.Text" Icon="@item.Icon" @bind-Expanded="@item.Expanded" CanExpand="@item.Expandable" Items="@item.Children" Disabled="@(!this.CanBeSelected(item.Value))"/>
</ItemTemplate>
</MudTreeView>
@if (this.CreateFolder is not null && !this.Disabled)
{
<MudStack Row="@true" AlignItems="AlignItems.Center" Class="mb-3">
<MudTextField
T="string"
@bind-Text="@this.newFolderName"
Label="@T("New folder")"
Immediate="@true"
MaxLength="@ImapMailboxConnector.MAX_FOLDER_NAME_LENGTH"
HelperText="@this.NewFolderHelperText"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.CreateNewFolder"
AdornmentColor="Color.Info"
Variant="Variant.Outlined"/>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.CreateNewFolder" Disabled="@(!this.CanCreateFolder)" OnClick="@this.CreateNewFolder">
@T("Create")
</MudButton>
</MudStack>
}
@@ -0,0 +1,234 @@
using AIStudio.Tools.Mail;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Shows the folders of a mailbox as the server lists them, picks one of them, and creates new ones.
/// </summary>
/// <remarks>
/// The whole mailbox is the root of the tree and stands for no folder at all, i.e., an empty full
/// name. The trash and the junk folder cannot be picked: the synchronization leaves them out, so a
/// mailbox restricted to one of them would never hold a mail.
/// </remarks>
public partial class MailFolderPicker : MSGComponentBase
{
/// <summary>
/// The folders of the mailbox, as the server lists them.
/// </summary>
/// <remarks>
/// The tree is built anew whenever another list arrives, so pass a new list instead of changing this one.
/// </remarks>
[Parameter]
public IReadOnlyList<MailServerFolder> Folders { get; set; } = [];
/// <summary>
/// The full name of the picked folder, or empty for the whole mailbox.
/// </summary>
[Parameter]
public string SelectedFolder { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> SelectedFolderChanged { get; set; }
[Parameter]
public bool Disabled { get; set; }
/// <summary>
/// Creates a folder on the server, from the full name of the folder to create it in (empty at
/// the top level) and the name of the new folder.
/// </summary>
/// <remarks>
/// Returns the new folder, or null when the server did not create it, in which case the caller
/// tells the user why. Without it, there is no way to create a folder here.
/// </remarks>
[Parameter]
public Func<string, string, Task<MailServerFolder?>>? CreateFolder { get; set; }
private IReadOnlyCollection<TreeItemData<string>> treeItems = [];
private IReadOnlyList<MailServerFolder>? treeFolders;
private Dictionary<string, MailServerFolder> foldersByName = new(StringComparer.Ordinal);
private string newFolderName = string.Empty;
private bool isCreatingFolder;
#region Overrides of ComponentBase
protected override void OnParametersSet()
{
if (!ReferenceEquals(this.treeFolders, this.Folders))
{
this.treeFolders = this.Folders;
this.foldersByName = IndexByName(this.Folders);
this.treeItems = BuildTree(this.Folders, this.SelectedFolder, T("Whole mailbox"));
}
base.OnParametersSet();
}
#endregion
private char NewFolderSeparator => this.foldersByName.TryGetValue(this.SelectedFolder, out var parent)
? parent.DirectorySeparator
: this.Folders.FirstOrDefault()?.DirectorySeparator ?? '\0';
private bool IsNewFolderNameValid => ImapMailboxConnector.IsValidFolderName(this.newFolderName.Trim(), this.NewFolderSeparator);
private bool CanCreateFolder => !this.isCreatingFolder && this.IsNewFolderNameValid;
private string NewFolderHelperText
{
get
{
if (!string.IsNullOrWhiteSpace(this.newFolderName) && !this.IsNewFolderNameValid)
return T("This name is too long, or it contains a character the server reserves for folder paths.");
return string.IsNullOrEmpty(this.SelectedFolder)
? T("Creates the folder on the server right away, at the top level of the mailbox.")
: string.Format(T("Creates the folder on the server right away, inside '{0}'."), this.SelectedFolder);
}
}
private bool CanBeSelected(string? fullName)
{
if (fullName is null)
return false;
if (fullName.Length == 0)
return true;
return this.foldersByName.TryGetValue(fullName, out var folder) && CanBeSelected(folder);
}
private async Task SelectFolder(string? fullName)
{
if (!this.CanBeSelected(fullName) || fullName == this.SelectedFolder)
return;
this.SelectedFolder = fullName!;
await this.SelectedFolderChanged.InvokeAsync(this.SelectedFolder);
}
private async Task CreateNewFolder()
{
if (this.CreateFolder is null || !this.CanCreateFolder)
return;
this.isCreatingFolder = true;
try
{
var createdFolder = await this.CreateFolder(this.SelectedFolder, this.newFolderName.Trim());
if (createdFolder is null)
return;
this.newFolderName = string.Empty;
this.SelectedFolder = createdFolder.FullName;
await this.SelectedFolderChanged.InvokeAsync(this.SelectedFolder);
}
finally
{
this.isCreatingFolder = false;
}
}
/// <summary>
/// Whether a folder can be picked, cf. the remarks of this component.
/// </summary>
internal static bool CanBeSelected(MailServerFolder folder) => folder.SpecialUse is not (MailFolderSpecialUse.TRASH or MailFolderSpecialUse.JUNK);
/// <summary>
/// Builds the tree of folders below the whole mailbox, with the way to the picked folder expanded.
/// </summary>
/// <remarks>
/// A folder whose parent the server did not list goes to the top level, so that nothing listed
/// gets lost. Each level starts with the inbox, and the other folders follow by name.
/// </remarks>
/// <param name="folders">The folders as the server lists them.</param>
/// <param name="selectedFolder">The full name of the picked folder, or empty for the whole mailbox.</param>
/// <param name="wholeMailboxText">The text of the root, which stands for the whole mailbox.</param>
/// <returns>The root of the tree, as the only item.</returns>
internal static List<TreeItemData<string>> BuildTree(IReadOnlyList<MailServerFolder> folders, string selectedFolder, string wholeMailboxText)
{
var foldersByName = IndexByName(folders);
var childrenByParent = foldersByName.Values
.ToLookup(folder => foldersByName.ContainsKey(folder.ParentFullName) && folder.ParentFullName != folder.FullName ? folder.ParentFullName : string.Empty, StringComparer.Ordinal);
//
// Every folder on the way to the picked one is expanded. Should a server list two folders as
// each other's parent, the way ends where it meets itself:
//
var expandedFolders = new HashSet<string>(StringComparer.Ordinal);
var current = selectedFolder;
while (foldersByName.TryGetValue(current, out var folder) && expandedFolders.Add(folder.ParentFullName))
current = folder.ParentFullName;
var topLevelFolders = BuildChildren(string.Empty);
return
[
new TreeItemData<string>
{
Value = string.Empty,
Text = wholeMailboxText,
Icon = Icons.Material.Filled.Mail,
Expanded = true,
Expandable = topLevelFolders.Count > 0,
Children = topLevelFolders,
},
];
List<TreeItemData<string>> BuildChildren(string parentFullName) => childrenByParent[parentFullName]
.OrderByDescending(folder => folder.IsInbox)
.ThenBy(folder => folder.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(folder => folder.FullName, StringComparer.Ordinal)
.Select(folder =>
{
var children = BuildChildren(folder.FullName);
return new TreeItemData<string>
{
Value = folder.FullName,
Text = folder.Name,
Icon = GetIcon(folder),
Expanded = expandedFolders.Contains(folder.FullName),
Expandable = children.Count > 0,
Children = children,
};
})
.ToList();
}
/// <remarks>
/// The empty full name belongs to the whole mailbox, so a folder listed under it would become
/// its own child.
/// </remarks>
private static Dictionary<string, MailServerFolder> IndexByName(IEnumerable<MailServerFolder> folders)
{
var foldersByName = new Dictionary<string, MailServerFolder>(StringComparer.Ordinal);
foreach (var folder in folders)
{
if (folder.FullName.Length > 0)
foldersByName.TryAdd(folder.FullName, folder);
}
return foldersByName;
}
private static string GetIcon(MailServerFolder folder)
{
if (folder.IsInbox)
return Icons.Material.Filled.Inbox;
return folder.SpecialUse switch
{
MailFolderSpecialUse.ALL => Icons.Material.Filled.AllInbox,
MailFolderSpecialUse.ARCHIVE => Icons.Material.Filled.Archive,
MailFolderSpecialUse.DRAFTS => Icons.Material.Filled.Drafts,
MailFolderSpecialUse.FLAGGED => Icons.Material.Filled.Flag,
MailFolderSpecialUse.IMPORTANT => Icons.Material.Filled.LabelImportant,
MailFolderSpecialUse.JUNK => Icons.Material.Filled.Report,
MailFolderSpecialUse.SENT => Icons.Material.Filled.Send,
MailFolderSpecialUse.TRASH => Icons.Material.Filled.Delete,
_ => Icons.Material.Filled.Folder,
};
}
}
@@ -57,9 +57,10 @@ public partial class ManagedToolsWarning : MSGComponentBase
if (this.ToolIds.Count is 0 || !this.SettingsManager.AreToolsEnabled())
return [];
var collectionIds = this.ToolRegistry.NormalizeSelection(this.ToolIds);
return this.availableTools
.Where(x => this.ToolIds.Contains(x.Definition.Id) && x.IsActive && !x.ConfigurationState.IsConfigured)
.Select(x => x.Implementation.GetDisplayName())
.Where(x => collectionIds.Contains(x.Id) && x.IsActive && !x.ConfigurationState.IsConfigured)
.Select(x => x.DisplayName)
.ToList();
}
}
@@ -82,10 +83,11 @@ public partial class ManagedToolsWarning : MSGComponentBase
? ConfidenceLevel.NONE
: this.ProviderSettings.UsedLLMProvider.GetConfidence(this.SettingsManager).Level;
var collectionIds = this.ToolRegistry.NormalizeSelection(this.ToolIds);
return this.availableTools
.Where(x => this.ToolIds.Contains(x.Definition.Id) && x.IsActive)
.Where(x => collectionIds.Contains(x.Id) && x.IsActive)
.Where(x => !ToolSelectionRules.IsProviderConfidenceAllowed(providerConfidence, x.MinimumProviderConfidence))
.Select(x => x.Implementation.GetDisplayName())
.Select(x => x.DisplayName)
.ToList();
}
}
@@ -16,13 +16,13 @@
</HeaderContent>
<RowTemplate>
<MudTd>
<MudIcon Icon="@context.Implementation.Icon" Color="Color.Info" />
<MudIcon Icon="@context.Icon" Color="Color.Info" />
</MudTd>
<MudTd>
<MudText Typo="Typo.body1">@context.Implementation.GetDisplayName()</MudText>
<MudText Typo="Typo.body1">@context.DisplayName</MudText>
</MudTd>
<MudTd>
<MudText Typo="Typo.body2">@context.Implementation.GetDescription()</MudText>
<MudText Typo="Typo.body2">@context.Description</MudText>
</MudTd>
<MudTd>
<MudMenu StartIcon="@Icons.Material.Filled.Security" EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Label="@this.GetCurrentConfidenceLevelName(context)" Variant="Variant.Filled" Style="@this.SetCurrentConfidenceLevelColorStyle(context)" Disabled="@this.IsToolConfidenceManaged()">
@@ -55,9 +55,9 @@
<MudTd>
<MudStack Row="true" Wrap="Wrap.NoWrap" AlignItems="AlignItems.Center" Spacing="0">
<MudTooltip Text="@T("Settings")">
<MudIconButton Icon="@Icons.Material.Filled.Settings" OnClick="@(async () => await this.OpenSettings(context.Definition.Id))" aria-label="@T("Settings")" />
<MudIconButton Icon="@Icons.Material.Filled.Settings" OnClick="@(async () => await this.OpenSettings(context))" aria-label="@T("Settings")" />
</MudTooltip>
<AdminExportButton OnClick="@(() => this.OpenExport(context.Definition.Id))" />
<AdminExportButton OnClick="@(() => this.OpenExport(context))" />
</MudStack>
</MudTd>
</RowTemplate>
@@ -21,11 +21,11 @@ public partial class SettingsPanelTools : SettingsPanelBase
await base.OnInitializedAsync();
}
private async Task OpenSettings(string toolId)
private async Task OpenSettings(ToolCatalogItem item)
{
var parameters = new DialogParameters<ToolSettingsDialog>
{
{ x => x.ToolId, toolId },
{ x => x.CollectionId, item.Id },
};
var dialog = await this.DialogService.ShowAsync<ToolSettingsDialog>(null, parameters, Dialogs.DialogOptions.FULLSCREEN);
@@ -34,14 +34,14 @@ public partial class SettingsPanelTools : SettingsPanelBase
this.StateHasChanged();
}
private async Task OpenExport(string toolId)
private async Task OpenExport(ToolCatalogItem item)
{
if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings)
return;
var parameters = new DialogParameters<ToolSettingsExportDialog>
{
{ x => x.ToolId, toolId },
{ x => x.CollectionId, item.Id },
};
await this.DialogService.ShowAsync<ToolSettingsExportDialog>(null, parameters, Dialogs.DialogOptions.FULLSCREEN);
@@ -54,13 +54,18 @@ public partial class SettingsPanelTools : SettingsPanelBase
_ => string.Format(this.T("Missing required settings: {0}"), string.Join(", ", item.ConfigurationState.MissingRequiredFields.Select(fieldName => this.GetFieldDisplayName(item, fieldName))))
};
/// <remarks>
/// The missing fields are those of the first tool whose settings are incomplete, see
/// ToolCatalogItem.ConfigurationState, so that tool names them.
/// </remarks>
private string GetFieldDisplayName(ToolCatalogItem item, string fieldName)
{
var fieldDefinition = item.Definition.SettingsSchema.Properties.GetValueOrDefault(fieldName);
if (fieldDefinition is null)
var tool = item.Tools.FirstOrDefault(tool => !tool.ConfigurationState.IsConfigured);
var fieldDefinition = tool?.Definition.SettingsSchema.Properties.GetValueOrDefault(fieldName);
if (tool is null || fieldDefinition is null)
return fieldName;
return item.Implementation.GetSettingsFieldLabel(fieldName, fieldDefinition);
return tool.Implementation.GetSettingsFieldLabel(fieldName, fieldDefinition);
}
private IEnumerable<ConfidenceLevel> GetSelectableConfidenceLevels() =>
@@ -85,7 +90,7 @@ public partial class SettingsPanelTools : SettingsPanelBase
private async Task ChangeMinimumProviderConfidence(ToolCatalogItem item, ConfidenceLevel confidenceLevel)
{
this.SettingsManager.SetMinimumProviderConfidenceForTool(item.Definition.Id, confidenceLevel, item.Definition.MinimumProviderConfidence);
this.ToolRegistry.SetMinimumProviderConfidence(item.Id, confidenceLevel);
await this.SettingsManager.StoreSettings();
this.items = await this.ToolRegistry.GetCatalogAsync(this.ToolRegistry.GetAllDefinitions());
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
@@ -26,6 +26,13 @@
<MudIconButton Icon="@Icons.Material.Filled.FolderOpen" Size="Size.Small" OnClick="@(() => this.ShowInFileManager(document))"/>
</MudTooltip>
}
else if (entry.IsMail)
{
@* Nothing opens a mail by its address yet, so a link would lead nowhere. *@
<MudText Typo="Typo.body1" Inline="true">
@entry.Title
</MudText>
}
else
{
<MudLink Href="@entry.Link" Target="_blank" Typo="Typo.body1">
@@ -79,7 +79,7 @@ public partial class SourcesList : MSGComponentBase
foreach (var numberedSource in group.Sources)
{
var document = numberedSource.Source.TryGetDocumentLocation(out var location) ? location : (SourceDocumentLocation?)null;
entries.Add(new(numberedSource.Number, numberedSource.Source.Title, numberedSource.Source.URL, document));
entries.Add(new(numberedSource.Number, numberedSource.Source.Title, numberedSource.Source.URL, document, numberedSource.Source.IsMailSource()));
}
this.groups.Add(new(group.Heading, entries));
@@ -160,5 +160,6 @@ public partial class SourcesList : MSGComponentBase
/// <param name="Title">The title of the source.</param>
/// <param name="Link">The address of the source, which a web source is opened by.</param>
/// <param name="Document">The document the source names, or null when it names none.</param>
private readonly record struct SourceEntry(int Number, string Title, string Link, SourceDocumentLocation? Document);
/// <param name="IsMail">Whether the source names a mail, which is shown as text.</param>
private readonly record struct SourceEntry(int Number, string Title, string Link, SourceDocumentLocation? Document, bool IsMail);
}
@@ -38,17 +38,17 @@ public partial class ToolDefaultsConfiguration : MSGComponentBase
this.Component is not AIStudio.Tools.Components.CHAT &&
!this.SettingsManager.IsToolSelectionVisible(this.Component);
private bool IsToolDisabled(string toolId) => !this.SettingsManager.IsToolActive(toolId);
private bool IsToolDisabled(string toolId) => !this.ToolRegistry.IsToolActive(toolId);
protected override async Task OnInitializedAsync()
{
this.availableTools = (await this.ToolRegistry.GetCatalogAsync(this.Component))
.Select(x => new ConfigurationSelectData<string>(x.Implementation.GetDisplayName(), x.Definition.Id))
.Select(x => new ConfigurationSelectData<string>(x.DisplayName, x.Id))
.ToList();
await base.OnInitializedAsync();
}
private HashSet<string> GetSelectedValues() => this.SettingsManager.GetDefaultToolIds(this.Component);
private HashSet<string> GetSelectedValues() => this.ToolRegistry.GetDefaultToolIds(this.Component);
private void UpdateSelection(HashSet<string> values) => this.SettingsManager.ConfigurationData.Tools.DefaultToolIdsByComponent[this.Component.ToString()] = [..ToolSelectionRules.NormalizeSelection(values)];
private void UpdateSelection(HashSet<string> values) => this.SettingsManager.ConfigurationData.Tools.DefaultToolIdsByComponent[this.Component.ToString()] = [..this.ToolRegistry.NormalizeSelection(values)];
}
@@ -43,7 +43,7 @@
<div class="tool-selection-rows">
@foreach (var item in this.catalog)
{
var isSelected = this.SelectedToolIds.Contains(item.Definition.Id);
var isSelected = this.SelectedToolIds.Contains(item.Id);
var warningText = this.GetWarningText(item);
<div class="tool-selection-row">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
@@ -66,19 +66,19 @@
box and would otherwise swallow the clicks landing in that strip.
*@
<MudCheckBox T="bool" Size="Size.Small" Dense="@true" Color="Color.Primary" Value="@isSelected" ReadOnly="@true" Disabled="@this.IsRowDisabled(item)" Style="pointer-events: none;" />
<MudIcon Icon="@item.Implementation.Icon" Color="Color.Info" />
<MudIcon Icon="@item.Icon" Color="Color.Info" />
@if (!item.IsActive)
{
<MudTooltip Text="@T("This tool has been disabled by your organization.")">
<MudIcon Icon="@Icons.Material.Filled.Lock" Color="Color.Error" Size="Size.Small" />
</MudTooltip>
}
<MudTooltip Text="@item.Implementation.GetDescription()">
<MudText Typo="Typo.body1">@item.Implementation.GetDisplayName()</MudText>
<MudTooltip Text="@item.Description">
<MudText Typo="Typo.body1">@item.DisplayName</MudText>
</MudTooltip>
</MudStack>
</MudButton>
<MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Small" OnClick="@(async () => await this.OpenSettings(item.Definition.Id))" />
<MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Small" OnClick="@(async () => await this.OpenSettings(item))" />
</MudStack>
@if (!string.IsNullOrWhiteSpace(warningText))
{
@@ -1,5 +1,6 @@
using AIStudio.Dialogs.Settings;
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.ToolCallingSystem;
using Microsoft.AspNetCore.Components;
@@ -14,6 +15,17 @@ public partial class ToolSelection : MSGComponentBase
[Parameter]
public required AIStudio.Settings.Provider LLMProvider { get; set; }
/// <summary>
/// Where the chat of this selection may still send data, because of the mailboxes it read from.
/// </summary>
/// <remarks>
/// The selection shows a tool this keeps back as unavailable, with the reason, the same way it
/// does for a provider with too little confidence. Keeping the tool out of the request is the
/// business of the tool registry and the tool executor; this only explains it.
/// </remarks>
[Parameter]
public required OutboundDataRequirement RequiredOutboundDataRestriction { get; set; }
[Parameter]
public HashSet<string> SelectedToolIds { get; set; } = [];
@@ -37,7 +49,7 @@ public partial class ToolSelection : MSGComponentBase
protected override void OnParametersSet()
{
this.SelectedToolIds = ToolSelectionRules.NormalizeSelection(this.SelectedToolIds);
this.SelectedToolIds = this.ToolRegistry.NormalizeSelection(this.SelectedToolIds);
base.OnParametersSet();
}
@@ -76,10 +88,11 @@ public partial class ToolSelection : MSGComponentBase
/// <remarks>
/// The switch and the row click share this, so both agree on when a tool is out of reach: the
/// organization disabled it, it is not configured, the provider lacks the confidence it needs,
/// a response is running, or the model cannot call tools in the first place.
/// the chat read a mailbox which keeps the tool back, a response is running, or the model
/// cannot call tools in the first place.
/// </remarks>
private bool IsRowDisabled(ToolCatalogItem item) => !item.IsActive || !item.ConfigurationState.IsConfigured || this.IsBlockedByProviderConfidence(item) ||
this.Disabled || !this.SupportsTools;
this.IsBlockedByOutboundDataRestriction(item) || this.Disabled || !this.SupportsTools;
/// <summary>
/// Switches a tool when the user clicks anywhere in its row.
@@ -94,21 +107,21 @@ public partial class ToolSelection : MSGComponentBase
if (this.IsRowDisabled(item))
return;
await this.ChangeSelection(item.Definition.Id, !this.SelectedToolIds.Contains(item.Definition.Id));
await this.ChangeSelection(item.Id, !this.SelectedToolIds.Contains(item.Id));
}
private async Task ChangeSelection(string toolId, bool isSelected)
private async Task ChangeSelection(string collectionId, bool isSelected)
{
if (isSelected && !this.SettingsManager.IsToolActive(toolId))
if (isSelected && !this.ToolRegistry.IsToolActive(collectionId))
return;
var updated = new HashSet<string>(this.SelectedToolIds, StringComparer.Ordinal);
if (isSelected)
updated.Add(toolId);
updated.Add(collectionId);
else
updated.Remove(toolId);
updated.Remove(collectionId);
updated = ToolSelectionRules.NormalizeSelection(updated);
updated = this.ToolRegistry.NormalizeSelection(updated);
this.SelectedToolIds = updated;
await this.SelectedToolIdsChanged.InvokeAsync(updated);
}
@@ -118,6 +131,15 @@ public partial class ToolSelection : MSGComponentBase
private bool IsBlockedByProviderConfidence(ToolCatalogItem item) => !ToolSelectionRules.IsProviderConfidenceAllowed(this.ProviderConfidence, GetMinimumProviderConfidence(item));
/// <summary>
/// Whether the mailboxes the chat read from keep every tool of this entry back.
/// </summary>
/// <remarks>
/// Only then is there nothing left to select. Should a mailbox keep back some tools of a
/// collection, the others still run, and the request leaves out the rest on its own.
/// </remarks>
private bool IsBlockedByOutboundDataRestriction(ToolCatalogItem item) => item.Tools.All(tool => !ToolSelectionRules.IsOutboundDataAllowed(this.RequiredOutboundDataRestriction.Restriction, tool.Implementation));
private string? GetProviderConfidenceHint(ToolCatalogItem item)
{
if (!this.IsBlockedByProviderConfidence(item))
@@ -138,7 +160,7 @@ public partial class ToolSelection : MSGComponentBase
/// </remarks>
private string GetWarningText(ToolCatalogItem item)
{
var warnings = new List<string>(3);
var warnings = new List<string>(4);
if (!item.ConfigurationState.IsConfigured)
warnings.Add(string.IsNullOrWhiteSpace(item.ConfigurationState.Message) ? T("Required settings are missing. Configure this tool before enabling it.") : item.ConfigurationState.Message);
@@ -149,14 +171,17 @@ public partial class ToolSelection : MSGComponentBase
if (!string.IsNullOrWhiteSpace(providerConfidenceHint))
warnings.Add(providerConfidenceHint);
if (this.IsBlockedByOutboundDataRestriction(item))
warnings.Add(this.RequiredOutboundDataRestriction.GetToolBlockedMessage(this.SettingsManager.ConfigurationData.Mailboxes));
return string.Join(' ', warnings);
}
private async Task OpenSettings(string toolId)
private async Task OpenSettings(ToolCatalogItem item)
{
var parameters = new DialogParameters<ToolSettingsDialog>
{
{ x => x.ToolId, toolId },
{ x => x.CollectionId, item.Id },
};
var dialog = await this.DialogService.ShowAsync<ToolSettingsDialog>(null, parameters, Dialogs.DialogOptions.FULLSCREEN);
@@ -51,27 +51,27 @@ public partial class ToolSelectionField : MSGComponentBase
protected override void OnParametersSet()
{
// Like ToolSelection, the field shows the tools which will actually run, also when the
// selection is read-only. See ToolSelectionRules.NormalizeSelection:
this.SelectedToolIds = ToolSelectionRules.NormalizeSelection(this.SelectedToolIds);
// Like ToolSelection, the field shows the collections which will actually run, also when the
// selection is read-only. See ToolRegistry.NormalizeSelection:
this.SelectedToolIds = this.ToolRegistry.NormalizeSelection(this.SelectedToolIds);
base.OnParametersSet();
}
protected override async Task OnInitializedAsync()
{
this.availableTools = (await this.ToolRegistry.GetCatalogAsync(this.Component))
.Select(x => new ConfigurationSelectData<string>(x.Implementation.GetDisplayName(), x.Definition.Id))
.Select(x => new ConfigurationSelectData<string>(x.DisplayName, x.Id))
.ToList();
this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED ]);
await base.OnInitializedAsync();
}
private bool IsToolLocked(string toolId) => !this.SettingsManager.IsToolActive(toolId);
private bool IsToolLocked(string toolId) => !this.ToolRegistry.IsToolActive(toolId);
private async Task OptionChangedAsync(HashSet<string> updatedToolIds)
{
this.SelectedToolIds = ToolSelectionRules.NormalizeSelection(updatedToolIds);
this.SelectedToolIds = this.ToolRegistry.NormalizeSelection(updatedToolIds);
await this.SelectedToolIdsChanged.InvokeAsync(this.SelectedToolIds);
}
@@ -81,7 +81,7 @@ public partial class ToolSelectionField : MSGComponentBase
{
case Event.CONFIGURATION_CHANGED:
this.availableTools = (await this.ToolRegistry.GetCatalogAsync(this.Component))
.Select(x => new ConfigurationSelectData<string>(x.Implementation.GetDisplayName(), x.Definition.Id))
.Select(x => new ConfigurationSelectData<string>(x.DisplayName, x.Id))
.ToList();
await this.InvokeAsync(this.StateHasChanged);