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

@@ -45,6 +45,7 @@ public partial class ConfigurationPluginDeleteDialog : MSGComponentBase
Add(summary.DocumentAnalysisPolicies, this.T("{0} document analysis policy"), this.T("{0} document analysis policies"));
Add(summary.MandatoryInfos, this.T("{0} mandatory information"), this.T("{0} mandatory informations"));
Add(summary.Introductions, this.T("{0} introduction on the welcome page"), this.T("{0} introductions on the welcome page"));
Add(summary.MailboxProviders, this.T("{0} mail server offered for new mailboxes"), this.T("{0} mail servers offered for new mailboxes"));
// Data sources are called out separately: removing them also deletes their credentials from
// the operating system's keychain, which the user cannot undo by reinstalling the plugin.
@@ -25,7 +25,7 @@ public partial class DataSourceERI_V1Dialog : MSGComponentBase, ISecretId
public DataSourceERI_V1 DataSource { get; set; }
[Inject]
private ILogger<ProviderDialog> Logger { get; init; } = null!;
private ILogger<DataSourceERI_V1Dialog> Logger { get; init; } = null!;
[Inject]
private RustService RustService { get; init; } = null!;
@@ -87,8 +87,10 @@ public partial class DataSourceERI_V1Dialog : MSGComponentBase, ISecretId
// Configure the spellchecking for the instance name input:
this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
// Load the used instance names:
this.UsedDataSourcesNames = this.SettingsManager.ConfigurationData.DataSources.Select(x => x.Name.ToLowerInvariant()).ToList();
// Load the used instance names, those of the mailboxes included:
this.UsedDataSourcesNames = this.SettingsManager.ConfigurationData.DataSources.Select(x => x.Name.ToLowerInvariant())
.Concat(this.SettingsManager.ConfigurationData.Mailboxes.Select(x => x.Name.ToLowerInvariant()))
.ToList();
// When editing, we need to load the data:
if(this.IsEditing)
@@ -331,6 +333,19 @@ public partial class DataSourceERI_V1Dialog : MSGComponentBase, ISecretId
await this.form.Validate();
return;
}
//
// The OS keyring stores the secret under the name of the data source, so a renamed one
// got a new entry just now, and the old one goes once the new one is in place. A name
// which differs only in case is no rename here: some keyrings ignore case, so both
// names lead to the same entry, and deleting the old one would delete the secret.
//
if (this.IsEditing && !string.Equals(this.DataSource.Name, addedDataSource.Name, StringComparison.OrdinalIgnoreCase))
{
var deleteResponse = await this.RustService.DeleteSecret(this.DataSource, SecretStoreType.DATA_SOURCE);
if (!deleteResponse.Success)
this.Logger.LogWarning($"Failed to delete the secret of the data source '{this.dataId}' stored under its previous name: {deleteResponse.Issue}");
}
}
this.MudDialog.Close(DialogResult.Ok(addedDataSource));
@@ -7,7 +7,7 @@
<DialogContent>
@* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@
<PathDropZone IsArea="@true">
<MudForm @ref="@this.form" @bind-IsValid="@this.dataIsValid" @bind-Errors="@this.dataIssues">
<MudForm @ref="@this.form" @bind-IsValid="@this.dataIsValid" @bind-Errors="@this.dataIssues" FieldChanged="@this.RevalidateAfterFieldChange">
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@@ -48,7 +48,7 @@
@if (!this.CanChangeSource)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
@T("The documents of this data source are already prepared, so its folder cannot be changed. Another folder holds other documents, which makes it another data source: please add one for it. The embedding method below can be changed.")
@T("The documents of this data source are already prepared, so its folder cannot be changed. Another folder holds other documents, which makes it another data source: please add one for it. The embedding provider below can be changed.")
</MudAlert>
}
@if (this.CanChangeSource)
@@ -72,9 +72,9 @@
}
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding method.")
@T("In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider.")
</MudJustifiedText>
<MudSelect @bind-Value="@this.dataEmbeddingId" Label="@T("Embedding")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateEmbeddingProviderAccess">
<MudSelect @ref="@this.embeddingSelect" @bind-Value="@this.dataEmbeddingId" Label="@T("Embedding")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateEmbeddingProviderAccess">
@foreach (var embedding in this.AvailableEmbeddings)
{
<MudSelectItem Value="@embedding.Value">
@@ -106,7 +106,7 @@
<ManagePandocDependency IntroText="@T("For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc.")"/>
<MudSelect @bind-Value="@this.dataConfidenceLevel" Text="@this.dataConfidenceLevel.GetName()" Label="@T("Required provider confidence level")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateDataSourceConfidenceLevel">
<MudSelect @ref="@this.confidenceLevelSelect" @bind-Value="@this.dataConfidenceLevel" Text="@this.dataConfidenceLevel.GetName()" Label="@T("Required provider confidence level")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateDataSourceConfidenceLevel">
@foreach (var level in this.ConfidenceLevels)
{
<MudSelectItem Value="@level.Value">
@@ -138,6 +138,7 @@
@T("Optional expert settings for how this data source is split before embedding.")
</MudJustifiedText>
<MudNumericField
@ref="@this.maxChunkTokenLengthField"
T="int"
@bind-Value="@this.dataMaxChunkTokenLength"
Label="@T("Token limit")"
@@ -151,6 +152,7 @@
AdornmentIcon="@Icons.Material.Filled.FormatListNumbered"
AdornmentColor="Color.Info"/>
<MudNumericField
@ref="@this.chunkOverlapTokenLengthField"
T="int"
@bind-Value="@this.dataChunkOverlapTokenLength"
Label="@T("Token overlap")"
@@ -7,6 +7,9 @@ using AIStudio.Tools.Validation;
using Microsoft.AspNetCore.Components;
using MudBlazor.Interfaces;
using MudBlazor.Utilities;
namespace AIStudio.Dialogs;
public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
@@ -63,11 +66,18 @@ public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
private int dataChunkOverlapTokenLength = DataSourceEmbeddingService.DEFAULT_CHUNK_OVERLAP_TOKEN_LENGTH;
private ushort dataMaxMatches = 10;
private bool showExpertSettings;
private bool revalidateAfterRender;
private ConfidenceLevel dataConfidenceLevel = ConfidenceLevel.UNKNOWN;
// We get the form reference from Blazor code to validate it manually:
private MudForm form = null!;
// The fields whose rules read other fields, see RevalidateDependentFields:
private MudSelect<string> embeddingSelect = null!;
private MudSelect<ConfidenceLevel> confidenceLevelSelect = null!;
private MudNumericField<int> maxChunkTokenLengthField = null!;
private MudNumericField<int> chunkOverlapTokenLengthField = null!;
public DataSourceLocalDirectoryDialog()
{
this.dataSourceValidation = new()
@@ -88,8 +98,10 @@ public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
// Configure the spellchecking for the instance name input:
this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
// Load the used instance names:
this.UsedDataSourcesNames = this.SettingsManager.ConfigurationData.DataSources.Select(x => x.Name.ToLowerInvariant()).ToList();
// Load the used instance names, those of the mailboxes included:
this.UsedDataSourcesNames = this.SettingsManager.ConfigurationData.DataSources.Select(x => x.Name.ToLowerInvariant())
.Concat(this.SettingsManager.ConfigurationData.Mailboxes.Select(x => x.Name.ToLowerInvariant()))
.ToList();
// When editing, we need to load the data:
if(this.IsEditing)
@@ -116,7 +128,14 @@ public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
// We don't want to show validation errors when the user opens the dialog.
if(!this.IsEditing && firstRender)
this.form.ResetValidation();
// A check asked for in code waits until the fields hold their new values, cf. ToggleExpertSettings:
if (this.revalidateAfterRender)
{
this.revalidateAfterRender = false;
await this.RevalidateDependentFields(changedField: null);
}
await base.OnAfterRenderAsync(firstRender);
}
@@ -197,6 +216,14 @@ public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
private void Cancel() => this.MudDialog.Cancel();
/// <summary>
/// Gives the fields which are checked against each other a fresh verdict: the embedding provider
/// and the required confidence level, and the token limits, which depend on the embedding provider.
/// </summary>
private Task RevalidateDependentFields(IFormComponent? changedField) => DependentFieldValidation.RevalidateAsync(changedField, this.embeddingSelect, this.confidenceLevelSelect, this.maxChunkTokenLengthField, this.chunkOverlapTokenLengthField);
private Task RevalidateAfterFieldChange(FormFieldChangedEventArgs change) => this.RevalidateDependentFields(change.Field);
private string? ValidateMaxChunkTokenLength(int maxChunkTokenLength)
{
if (!this.showExpertSettings)
@@ -234,6 +261,10 @@ public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
this.showExpertSettings = !this.showExpertSettings;
if (this.showExpertSettings && this.dataMaxChunkTokenLength < 1)
this.dataMaxChunkTokenLength = this.ProviderMaxChunkTokenLength;
// The token limits are only checked while they are shown. The field learns the limit set
// above only with the next render, so it is checked after that:
this.revalidateAfterRender = true;
}
private string GetExpertStyles => this.showExpertSettings ? "border-2 border-dashed rounded pa-2" : string.Empty;
@@ -7,7 +7,7 @@
<DialogContent>
@* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@
<PathDropZone IsArea="@true">
<MudForm @ref="@this.form" @bind-IsValid="@this.dataIsValid" @bind-Errors="@this.dataIssues">
<MudForm @ref="@this.form" @bind-IsValid="@this.dataIsValid" @bind-Errors="@this.dataIssues" FieldChanged="@this.RevalidateAfterFieldChange">
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@@ -48,7 +48,7 @@
@if (!this.CanChangeSource)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
@T("The documents of this data source are already prepared, so its file cannot be changed. Another file holds other content, which makes it another data source: please add one for it. The embedding method below can be changed.")
@T("The documents of this data source are already prepared, so its file cannot be changed. Another file holds other content, which makes it another data source: please add one for it. The embedding provider below can be changed.")
</MudAlert>
}
@if (this.CanChangeSource)
@@ -72,9 +72,9 @@
}
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding method.")
@T("In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider.")
</MudJustifiedText>
<MudSelect @bind-Value="@this.dataEmbeddingId" Label="@T("Embedding")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateEmbeddingProviderAccess">
<MudSelect @ref="@this.embeddingSelect" @bind-Value="@this.dataEmbeddingId" Label="@T("Embedding")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateEmbeddingProviderAccess">
@foreach (var embedding in this.AvailableEmbeddings)
{
<MudSelectItem Value="@embedding.Value">
@@ -106,7 +106,7 @@
<ManagePandocDependency IntroText="@T("For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc.")"/>
<MudSelect @bind-Value="@this.dataConfidenceLevel" Text="@this.dataConfidenceLevel.GetName()" Label="@T("Required provider confidence level")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateDataSourceConfidenceLevel">
<MudSelect @ref="@this.confidenceLevelSelect" @bind-Value="@this.dataConfidenceLevel" Text="@this.dataConfidenceLevel.GetName()" Label="@T("Required provider confidence level")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateDataSourceConfidenceLevel">
@foreach (var level in this.ConfidenceLevels)
{
<MudSelectItem Value="@level.Value">
@@ -138,6 +138,7 @@
@T("Optional expert settings for how this data source is split before embedding.")
</MudJustifiedText>
<MudNumericField
@ref="@this.maxChunkTokenLengthField"
T="int"
@bind-Value="@this.dataMaxChunkTokenLength"
Label="@T("Token limit")"
@@ -151,6 +152,7 @@
AdornmentIcon="@Icons.Material.Filled.FormatListNumbered"
AdornmentColor="Color.Info"/>
<MudNumericField
@ref="@this.chunkOverlapTokenLengthField"
T="int"
@bind-Value="@this.dataChunkOverlapTokenLength"
Label="@T("Token overlap")"
@@ -7,6 +7,9 @@ using AIStudio.Tools.Validation;
using Microsoft.AspNetCore.Components;
using MudBlazor.Interfaces;
using MudBlazor.Utilities;
namespace AIStudio.Dialogs;
public partial class DataSourceLocalFileDialog : MSGComponentBase
@@ -63,11 +66,18 @@ public partial class DataSourceLocalFileDialog : MSGComponentBase
private int dataChunkOverlapTokenLength = DataSourceEmbeddingService.DEFAULT_CHUNK_OVERLAP_TOKEN_LENGTH;
private ushort dataMaxMatches = 10;
private bool showExpertSettings;
private bool revalidateAfterRender;
private ConfidenceLevel dataConfidenceLevel = ConfidenceLevel.UNKNOWN;
// We get the form reference from Blazor code to validate it manually:
private MudForm form = null!;
// The fields whose rules read other fields, see RevalidateDependentFields:
private MudSelect<string> embeddingSelect = null!;
private MudSelect<ConfidenceLevel> confidenceLevelSelect = null!;
private MudNumericField<int> maxChunkTokenLengthField = null!;
private MudNumericField<int> chunkOverlapTokenLengthField = null!;
public DataSourceLocalFileDialog()
{
this.dataSourceValidation = new()
@@ -88,8 +98,10 @@ public partial class DataSourceLocalFileDialog : MSGComponentBase
// Configure the spellchecking for the instance name input:
this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
// Load the used instance names:
this.UsedDataSourcesNames = this.SettingsManager.ConfigurationData.DataSources.Select(x => x.Name.ToLowerInvariant()).ToList();
// Load the used instance names, those of the mailboxes included:
this.UsedDataSourcesNames = this.SettingsManager.ConfigurationData.DataSources.Select(x => x.Name.ToLowerInvariant())
.Concat(this.SettingsManager.ConfigurationData.Mailboxes.Select(x => x.Name.ToLowerInvariant()))
.ToList();
// When editing, we need to load the data:
if(this.IsEditing)
@@ -116,7 +128,14 @@ public partial class DataSourceLocalFileDialog : MSGComponentBase
// We don't want to show validation errors when the user opens the dialog.
if(!this.IsEditing && firstRender)
this.form.ResetValidation();
// A check asked for in code waits until the fields hold their new values, cf. ToggleExpertSettings:
if (this.revalidateAfterRender)
{
this.revalidateAfterRender = false;
await this.RevalidateDependentFields(changedField: null);
}
await base.OnAfterRenderAsync(firstRender);
}
@@ -197,6 +216,14 @@ public partial class DataSourceLocalFileDialog : MSGComponentBase
private void Cancel() => this.MudDialog.Cancel();
/// <summary>
/// Gives the fields which are checked against each other a fresh verdict: the embedding provider
/// and the required confidence level, and the token limits, which depend on the embedding provider.
/// </summary>
private Task RevalidateDependentFields(IFormComponent? changedField) => DependentFieldValidation.RevalidateAsync(changedField, this.embeddingSelect, this.confidenceLevelSelect, this.maxChunkTokenLengthField, this.chunkOverlapTokenLengthField);
private Task RevalidateAfterFieldChange(FormFieldChangedEventArgs change) => this.RevalidateDependentFields(change.Field);
private string? ValidateMaxChunkTokenLength(int maxChunkTokenLength)
{
if (!this.showExpertSettings)
@@ -234,6 +261,10 @@ public partial class DataSourceLocalFileDialog : MSGComponentBase
this.showExpertSettings = !this.showExpertSettings;
if (this.showExpertSettings && this.dataMaxChunkTokenLength < 1)
this.dataMaxChunkTokenLength = this.ProviderMaxChunkTokenLength;
// The token limits are only checked while they are shown. The field learns the limit set
// above only with the next render, so it is checked after that:
this.revalidateAfterRender = true;
}
private string GetExpertStyles => this.showExpertSettings ? "border-2 border-dashed rounded pa-2" : string.Empty;
@@ -0,0 +1,452 @@
@using AIStudio.Settings
@using AIStudio.Settings.DataModel
@using AIStudio.Tools.Mail
@using AIStudio.Tools.Validation
@using AIStudio.Provider
@inherits MSGComponentBase
<MudDialog>
<DialogContent>
<MudForm @ref="@this.form" @bind-IsValid="@this.dataIsValid" @bind-Errors="@this.dataIssues" FieldChanged="@this.RevalidateAfterFieldChange">
@if (this.IsAddingNotAllowed)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined" Class="mb-6">
<MudJustifiedText Typo="Typo.body1">
@T("Your organization no longer allows adding mailboxes, so this one cannot be added.")
</MudJustifiedText>
</MudAlert>
}
@if (this.IsOnServerNotAllowed)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined" Class="mb-6">
<MudJustifiedText Typo="Typo.body1">
@MailboxConnectionFailure.SERVER_NOT_ALLOWED.GetDescription()
</MudJustifiedText>
</MudAlert>
}
@if (this.authFailure is not null)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined" Class="mb-6">
<MudJustifiedText Typo="Typo.body1">
@this.AuthFailureText
</MudJustifiedText>
@if (!string.IsNullOrWhiteSpace(this.authFailure.FailureMessage))
{
<MudJustifiedText Typo="Typo.body2" Class="mt-2">
@string.Format(T("The server answered: {0}"), this.authFailure.FailureMessage)
</MudJustifiedText>
}
</MudAlert>
}
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.dataName"
Label="@T("Data Source Name")"
Class="mb-6"
MaxLength="@DataSourceValidation.MAX_NAME_LENGTH"
Counter="@DataSourceValidation.MAX_NAME_LENGTH"
Immediate="@true"
Validation="@this.dataSourceValidation.ValidatingName"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Lightbulb"
AdornmentColor="Color.Info"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Variant="Variant.Outlined"
/>
@if (this.CanChangeSource)
{
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@if (this.AllowsOnlyOrganizationMailServers)
{
@T("Your organization allows mailboxes only on its own mail servers. Choose one of them here, and AI Studio fills in the server settings for you.")
}
else if (this.HasOrganizationProviders)
{
@T("Choose your mail provider here, and AI Studio fills in the server settings for you. The mail servers of your organization come first, marked with a building. Alternatively, you can enter the technical details yourself.")
}
else
{
@T("Choose your mail provider here, and AI Studio fills in the server settings for you. Alternatively, e.g., for a mail server of your organization, you can enter the technical details yourself.")
}
</MudJustifiedText>
@if (this.AllowsOnlyOrganizationMailServers && !this.HasOrganizationProviders)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="@true" Class="mb-3">
@T("Your organization has not set up any of its mail servers here yet. Please ask your IT department.")
</MudAlert>
}
@* No adornment: the field shows the chosen entry together with its icon, see the entries below. They use the size and spacing of ProviderLabel, so a logo and a Material icon line up. *@
<MudSelect @ref="@this.providerSelect" T="string" Value="@this.SelectedProviderKey" ValueChanged="@this.SelectProvider" Text="@this.SelectedProviderText" Placeholder="@T("Please choose a mail server")" Label="@T("Provider")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" Validation="@this.ValidateOrganizationProvider">
@if (!this.AllowsOnlyOrganizationMailServers)
{
<MudSelectItem T="string" Value="@string.Empty">
<MudStack Row="@true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.Edit" Color="Color.Info"/>
<span>@T("Another provider")</span>
</MudStack>
</MudSelectItem>
}
@foreach (var organizationProvider in this.organizationProviders)
{
<MudSelectItem T="string" Value="@GetProviderKey(organizationProvider)">
<MudStack Row="@true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.Business" Color="Color.Info"/>
<span>@organizationProvider.Name</span>
</MudStack>
</MudSelectItem>
}
@if (!this.AllowsOnlyOrganizationMailServers)
{
foreach (var template in MailboxProviderTemplates.ALL)
{
<MudSelectItem T="string" Value="@GetProviderKey(template)">
<MudStack Row="@true" AlignItems="AlignItems.Center" Spacing="1">
@if (string.IsNullOrEmpty(template.IconUrl))
{
<MudIcon Icon="@Icons.Material.Filled.Email" Color="Color.Info"/>
}
else
{
<img src="@template.IconUrl" alt="" aria-hidden="true" draggable="false" class="provider-icon"/>
}
<span>@template.Name</span>
</MudStack>
</MudSelectItem>
}
}
</MudSelect>
}
@if (this.selectedOrganizationProvider is { } chosenOrganizationProvider)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="@true" Class="mb-6">
<MudJustifiedText Typo="Typo.body2" Class="mb-1">
@T("Your organization offers this mail server. Sign in with your own username and password.")
</MudJustifiedText>
@if (!string.IsNullOrEmpty(chosenOrganizationProvider.UsernameHint))
{
<MudJustifiedText Typo="Typo.body2" Class="mb-1">
@string.Format(T("Username: {0}"), chosenOrganizationProvider.UsernameHint)
</MudJustifiedText>
}
@if (!string.IsNullOrEmpty(chosenOrganizationProvider.HelpUrl))
{
<MudLink Href="@chosenOrganizationProvider.HelpUrl" Target="_blank" Typo="Typo.body2">
@string.Format(T("Instructions of {0}"), chosenOrganizationProvider.Name)
</MudLink>
}
</MudAlert>
}
else if (this.selectedTemplate is not null)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="@true" Class="mb-6">
@foreach (var requirement in this.selectedTemplate.Requirements.GetDescriptions())
{
<MudJustifiedText Typo="Typo.body2" Class="mb-1">
@requirement
</MudJustifiedText>
}
<MudJustifiedText Typo="Typo.body2" Class="mb-1">
@string.Format(T("Username: {0}"), this.selectedTemplate.UsernameFormat.GetDescription())
</MudJustifiedText>
<MudLink Href="@this.selectedTemplate.HelpUrl" Target="_blank" Typo="Typo.body2">
@string.Format(T("Instructions of {0}"), this.selectedTemplate.Name)
</MudLink>
</MudAlert>
}
@* Settings AI Studio fills in wait behind a button, so the sign-in follows the choice of the provider directly: *@
@if (this.AreServerSettingsFilledIn)
{
<MudButton OnClick="@this.ToggleServerDetails" Variant="Variant.Text" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Dns" Class="@this.ServerDetailsButtonClass">
@(this.showServerDetails ? T("Hide Server Details") : T("Show Server Details"))
</MudButton>
}
else
{
<MudText Typo="Typo.h6" Class="mb-1">
@T("Server")
</MudText>
}
<MudCollapse Expanded="@this.AreServerDetailsShown">
<div class="border-dashed border rounded-lg pa-3 mb-6">
<MudStack Row="@true">
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.dataHost"
Label="@T("IMAP server host")"
Immediate="@true"
ReadOnly="@(!this.CanChangeSource || this.AllowsOnlyOrganizationMailServers)"
Validation="@this.ValidateHost"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Dns"
AdornmentColor="Color.Info"
Variant="Variant.Outlined"
UserAttributes="@SPELLCHECK_ATTRIBUTES"/>
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudNumericField
T="int"
@bind-Value="@this.dataPort"
Label="@T("Port")"
Immediate="@true"
ReadOnly="@this.AllowsOnlyOrganizationMailServers"
Min="1" Max="65535"
Validation="@this.dataSourceValidation.ValidatePort"
Variant="Variant.Outlined"/>
</MudStack>
<MudSelect T="MailboxTransportSecurity" Value="@this.dataTransportSecurity" ValueChanged="@this.SelectTransportSecurity" Text="@this.dataTransportSecurity.GetName()" Label="@T("Encryption")" Class="mt-3" ReadOnly="@this.AllowsOnlyOrganizationMailServers" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Lock" Validation="@DataSourceValidation.ValidateMailboxTransportSecurity">
@foreach (var transportSecurity in ConfigurationSelectDataFactory.GetMailboxTransportSecurityData())
{
<MudSelectItem T="MailboxTransportSecurity" Value="@transportSecurity.Value">
@transportSecurity.Name
</MudSelectItem>
}
</MudSelect>
</div>
</MudCollapse>
<MudText Typo="Typo.h6" Class="mb-1">
@T("Sign-in")
</MudText>
<div class="@this.SignInGroupClass">
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.dataUsername"
Label="@T("Username")"
Class="mb-3"
Immediate="@true"
ReadOnly="@(!this.CanChangeSource)"
Validation="@DataSourceValidation.ValidateMailboxUsername"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Person2"
AdornmentColor="Color.Info"
Variant="Variant.Outlined"
UserAttributes="@SPELLCHECK_ATTRIBUTES"/>
<SecretInputField @bind-Secret="@this.dataPassword" Label="@T("Password")" Validation="@this.dataSourceValidation.ValidateMailboxPassword" Class="mb-3"/>
<MudStack Row="@true" AlignItems="AlignItems.Center">
<MudButton Variant="Variant.Filled" Color="@this.TestResultColor" StartIcon="@this.TestResultIcon" Disabled="@(!this.CanTestConnection)" OnClick="@this.TestConnection">
@T("Test connection & load folders")
</MudButton>
@if (this.isTestingConnection)
{
<MudProgressCircular Size="Size.Small" Indeterminate="@true"/>
}
<MudText Typo="Typo.body1">
@this.TestResultText
</MudText>
</MudStack>
</div>
<MudText Typo="Typo.h6" Class="mb-1">
@T("Folder")
</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("Select the folder AI Studio indexes, together with its subfolders. The AI only reads from this folder. Without a folder, AI Studio indexes the whole mailbox, apart from the trash and the junk folder.")
</MudJustifiedText>
@if (!this.CanChangeSource)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
@T("The mails of this mailbox are already prepared, so its server, its username, and its folder cannot be changed. Another account or folder holds other mails, which makes it another mailbox: please add one for it. The embedding provider below can be changed.")
</MudAlert>
}
@if (this.CanChangeSource && this.ConnectionTestSucceeded)
{
<MailFolderPicker Folders="@this.serverFolders" @bind-SelectedFolder="@this.dataRootFolder" CreateFolder="@this.CreateFolder"/>
}
else
{
<MudTextField
T="string"
Text="@this.RootFolderText"
Label="@T("Selected folder")"
Class="mb-3"
ReadOnly="@true"
HelperText="@(this.CanChangeSource ? T("Test the connection to select another folder.") : string.Empty)"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Folder"
Variant="Variant.Outlined"/>
}
@if (!string.IsNullOrWhiteSpace(this.folderIssue))
{
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined" Dense="@true" Class="mb-3">
@this.folderIssue
</MudAlert>
}
<MudSelect T="MailboxMaxAge" @bind-Value="@this.dataMaxAge" Text="@this.dataMaxAge.GetName()" Label="@T("Period")" Class="mb-1 mt-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.DateRange">
@foreach (var maxAge in ConfigurationSelectDataFactory.GetMailboxMaxAgeData())
{
<MudSelectItem T="MailboxMaxAge" Value="@maxAge.Value">
@maxAge.Name
</MudSelectItem>
}
</MudSelect>
<MudJustifiedText Typo="Typo.body2" Class="mb-6">
@T("AI Studio indexes the newest mails first. Flagged mails are always indexed, however old they are.")
</MudJustifiedText>
<MudTextSwitch @bind-Value="@this.dataIndexAttachments" Color="Color.Primary" Label="@T("Index the text of attachments?")" LabelOn="@T("Yes, index the text of attached documents")" LabelOff="@T("No, only list the names of attachments")"/>
@if (this.dataIndexAttachments)
{
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudNumericField
T="int"
@bind-Value="@this.dataMaxAttachmentSizeMegabytes"
Label="@T("Largest attachment to index, in MB")"
Class="mb-6"
Min="@DataSourceValidation.MIN_ATTACHMENT_SIZE_MEGABYTES"
Max="@DataSourceValidation.MAX_ATTACHMENT_SIZE_MEGABYTES"
Immediate="@true"
Validation="@DataSourceValidation.ValidateMailboxMaxAttachmentSize"
HelperText="@T("Of a larger attachment, only the name is indexed.")"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.AttachFile"
AdornmentColor="Color.Info"
Variant="Variant.Outlined"/>
}
<MudJustifiedText Typo="Typo.body1" Class="mb-3 mt-3">
@T("In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider.")
</MudJustifiedText>
<MudSelect @ref="@this.embeddingSelect" @bind-Value="@this.dataEmbeddingId" Label="@T("Embedding")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateEmbeddingProviderAccess">
@foreach (var embedding in this.AvailableEmbeddings)
{
<MudSelectItem Value="@embedding.Value">
@if (this.GetEmbeddingProvider(embedding.Value) is { } provider)
{
<ProviderLabel ProviderType="@provider.UsedLLMProvider" CustomIconDataUrl="@provider.CustomIconDataUrl" Text="@embedding.Name" />
}
else
{
@embedding.Name
}
</MudSelectItem>
}
</MudSelect>
@if (!string.IsNullOrWhiteSpace(this.dataEmbeddingId))
{
if (this.SelectedCloudEmbedding)
{
<DataSourceCloudEmbeddingWarning DataSourceType="DataSourceType.MAILBOX" SourcePath="@this.dataUsername" @bind-UserAcknowledged="@this.dataUserAcknowledgedCloudEmbedding" Validation="@this.dataSourceValidation.ValidateUserAcknowledgedCloudEmbedding"/>
}
else
{
<MudJustifiedText Typo="Typo.body1" Color="Color.Tertiary" Class="mb-3">
@T("The embedding you selected runs locally or in your organization. Your data is not sent to the cloud.")
</MudJustifiedText>
}
}
<MudSelect @ref="@this.confidenceLevelSelect" T="ConfidenceLevel" @bind-Value="@this.dataConfidenceLevel" Text="@this.ConfidenceLevelText" Placeholder="@T("Please select a level")" Label="@T("Required provider confidence level")" Class="mb-1 mt-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateMailboxConfidenceLevel">
@foreach (var level in ConfigurationSelectDataFactory.GetMailboxConfidenceLevelsData())
{
<MudSelectItem T="ConfidenceLevel" Value="@level.Value">
@level.Name
</MudSelectItem>
}
</MudSelect>
<MudJustifiedText Typo="Typo.body2" Class="mb-6">
@T("Only providers with at least this confidence level see the content of your mails: the chat provider, the embedding provider, and every provider which checks a mail for you.")
</MudJustifiedText>
<MudSelect T="OutboundDataRestriction" @bind-Value="@this.dataOutboundDataRestriction" Text="@this.dataOutboundDataRestriction.GetName()" Label="@T("Where a chat may send data after reading mails")" Class="mb-1" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Shield">
@foreach (var restriction in ConfigurationSelectDataFactory.GetOutboundDataRestrictionData(this.MinimumOutboundDataRestriction))
{
<MudSelectItem T="OutboundDataRestriction" Value="@restriction.Value">
@restriction.Name
</MudSelectItem>
}
</MudSelect>
<MudJustifiedText Typo="Typo.body2" Class="mb-1">
@this.dataOutboundDataRestriction.GetDescription()
</MudJustifiedText>
@if (this.IsOutboundDataRestrictionLimited)
{
<MudJustifiedText Typo="Typo.body2" Class="mb-1">
@T("Your organization does not allow less strict settings than the ones offered here.")
</MudJustifiedText>
}
<MudJustifiedText Typo="Typo.body2" Class="mb-6">
@T("Mails come from strangers and may contain instructions meant for the AI. This setting decides where a chat may still send data once it has read from this mailbox. Mind that opening a link can be enough to set something off, e.g., to confirm a subscription, an order, or a payment.")
</MudJustifiedText>
<MudStack Class="mb-3">
<MudButton OnClick="@this.ToggleExpertSettings" Variant="Variant.Text" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Tune">
@(this.showExpertSettings ? T("Hide Expert Settings") : T("Show Expert Settings"))
</MudButton>
<MudDivider/>
<MudCollapse Expanded="@this.showExpertSettings" Class="@this.GetExpertStyles">
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("Optional expert settings for how this data source is split before embedding.")
</MudJustifiedText>
<MudNumericField
@ref="@this.maxChunkTokenLengthField"
T="int"
@bind-Value="@this.dataMaxChunkTokenLength"
Label="@T("Token limit")"
Class="mb-3"
Min="1"
Immediate="@true"
Validation="@this.ValidateMaxChunkTokenLength"
HelperText="@this.MaxChunkTokenLengthHelperText"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.FormatListNumbered"
AdornmentColor="Color.Info"/>
<MudNumericField
@ref="@this.chunkOverlapTokenLengthField"
T="int"
@bind-Value="@this.dataChunkOverlapTokenLength"
Label="@T("Token overlap")"
Class="mb-3"
Min="0"
Immediate="@true"
Validation="@this.ValidateChunkOverlapTokenLength"
HelperText="@this.ChunkOverlapTokenLengthHelperText"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.CompareArrows"
AdornmentColor="Color.Info"/>
<MudNumericField
T="ushort"
Min="10"
@bind-Value="@this.dataMaxMatches"
Label="@T("How many mails do you want at most per search?")"
Variant="Variant.Outlined"
Step="10"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
AdornmentColor="Color.Info"/>
</MudCollapse>
</MudStack>
</MudForm>
<Issues IssuesData="@this.dataIssues"/>
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">
@T("Cancel")
</MudButton>
<MudButton OnClick="@this.Store" Variant="Variant.Filled" Color="Color.Primary" Disabled="@this.IsAddingNotAllowed">
@if(this.IsEditing)
{
@T("Update")
}
else
{
@T("Add")
}
</MudButton>
</DialogActions>
</MudDialog>
@@ -0,0 +1,743 @@
using AIStudio.Components;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Databases;
using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.Mail;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
using AIStudio.Tools.Validation;
using Microsoft.AspNetCore.Components;
using MudBlazor.Interfaces;
using MudBlazor.Utilities;
namespace AIStudio.Dialogs;
/// <summary>
/// Adds or edits a mailbox.
/// </summary>
/// <remarks>
/// Every connection test is exactly one sign-in, started by the user. Its outcome only touches the
/// recorded sign-in failure of the mailbox when the test used the settings and the password stored
/// for it: a failure with a password the user is still typing says nothing about the stored one,
/// and neither does a success.
/// </remarks>
public partial class DataSourceMailboxDialog : MSGComponentBase
{
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public bool IsEditing { get; set; }
[Parameter]
public DataSourceMailbox DataSource { get; set; }
/// <summary>
/// Whether the server, the username, and the folder of this mailbox must stay as they are.
/// </summary>
/// <remarks>
/// Set once the index holds something for this mailbox. The embedding is not locked along with
/// them: it can be changed, and DataSourceReindexWarning asks what that costs.
/// </remarks>
[Parameter]
public bool LockSource { get; set; }
[Parameter]
public IReadOnlyList<ConfigurationSelectData<string>> AvailableEmbeddings { get; set; } = [];
[Inject]
private IDialogService DialogService { get; init; } = null!;
[Inject]
private DataSourceEmbeddingService DataSourceEmbeddingService { get; init; } = null!;
[Inject]
private DatabaseClientProvider DatabaseClientProvider { get; init; } = null!;
[Inject]
private RustService RustService { get; init; } = null!;
[Inject]
private ILogger<DataSourceMailboxDialog> Logger { get; init; } = null!;
/// <summary>
/// How long a connection test may take, signing in and listing the folders included.
/// </summary>
private static readonly TimeSpan CONNECTION_TEST_TIMEOUT = TimeSpan.FromMinutes(2);
private static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new();
private readonly DataSourceValidation dataSourceValidation;
/// <summary>
/// The names of all data sources and mailboxes. A mailbox needs a name of its own among both.
/// </summary>
private List<string> UsedDataSourcesNames { get; set; } = [];
private bool dataIsValid;
private string[] dataIssues = [];
private string dataSecretStorageIssue = string.Empty;
private string dataEditingPreviousInstanceName = string.Empty;
private uint dataNum;
private string dataId = Guid.NewGuid().ToString();
private string dataName = string.Empty;
private string dataHost = string.Empty;
private int dataPort = MailboxTransportSecurityExtensions.SSL_ON_CONNECT_PORT;
private MailboxTransportSecurity dataTransportSecurity = MailboxTransportSecurity.SSL_ON_CONNECT;
private string dataUsername = string.Empty;
private string dataPassword = string.Empty;
private string dataRootFolder = string.Empty;
private MailboxMaxAge dataMaxAge = MailboxMaxAge.LAST_12_MONTHS;
private bool dataIndexAttachments = true;
private int dataMaxAttachmentSizeMegabytes = 10;
private OutboundDataRestriction dataOutboundDataRestriction = OutboundDataRestriction.ONLY_CONFIGURED_SERVICES;
private bool dataUserAcknowledgedCloudEmbedding;
private string dataEmbeddingId = string.Empty;
private int dataMaxChunkTokenLength;
private int dataChunkOverlapTokenLength = DataSourceEmbeddingService.DEFAULT_CHUNK_OVERLAP_TOKEN_LENGTH;
private ushort dataMaxMatches = 10;
private ConfidenceLevel dataConfidenceLevel = ConfidenceLevel.NONE;
private bool showExpertSettings;
private bool showServerDetails;
private bool revalidateAfterRender;
private IReadOnlyList<DataMailboxProvider> organizationProviders = [];
private MailServerPolicy mailServerPolicy = MailServerPolicy.ANY_SERVER;
private DataMailboxProvider? selectedOrganizationProvider;
private MailboxProviderTemplate? selectedTemplate;
private MailboxAuthFailure? authFailure;
private string storedPassword = string.Empty;
private ConnectionSettings? testedSettings;
private MailboxConnectionFailure? testFailure;
private bool isTestingConnection;
private IReadOnlyList<MailServerFolder> serverFolders = [];
private string folderIssue = string.Empty;
// We get the form reference from Blazor code to validate it manually:
private MudForm form = null!;
// The fields whose rules read other fields, see RevalidateDependentFields. The provider is only
// rendered while the source can be changed:
private MudSelect<string>? providerSelect;
private MudSelect<string> embeddingSelect = null!;
private MudSelect<ConfidenceLevel> confidenceLevelSelect = null!;
private MudNumericField<int> maxChunkTokenLengthField = null!;
private MudNumericField<int> chunkOverlapTokenLengthField = null!;
public DataSourceMailboxDialog()
{
this.dataSourceValidation = new()
{
GetSelectedCloudEmbedding = () => this.SelectedCloudEmbedding,
GetSelectedEmbeddingProvider = () => this.SelectedEmbedding,
GetConfidenceLevel = () => this.dataConfidenceLevel,
GetSettingsManager = () => this.SettingsManager,
GetPreviousDataSourceName = () => this.dataEditingPreviousInstanceName,
GetUsedDataSourceNames = () => this.UsedDataSourcesNames,
GetSecretStorageIssue = () => this.dataSecretStorageIssue,
GetTestedConnection = () => this.testedSettings == this.CurrentSettings,
GetTestedConnectionResult = () => this.ConnectionTestSucceeded,
};
}
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
// Configure the spellchecking for the instance name input:
this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
// A mailbox and a data source must not share a name:
this.UsedDataSourcesNames = this.SettingsManager.ConfigurationData.DataSources.Select(x => x.Name.ToLowerInvariant())
.Concat(this.SettingsManager.ConfigurationData.Mailboxes.Select(x => x.Name.ToLowerInvariant()))
.ToList();
// When editing, we need to load the data:
if (this.IsEditing)
{
this.dataEditingPreviousInstanceName = this.DataSource.Name.ToLowerInvariant();
this.dataNum = this.DataSource.Num;
this.dataId = this.DataSource.Id;
this.dataName = this.DataSource.Name;
this.dataHost = this.DataSource.Host;
this.dataPort = this.DataSource.Port;
this.dataTransportSecurity = this.DataSource.TransportSecurity;
this.dataUsername = this.DataSource.Username;
this.dataRootFolder = this.DataSource.RootFolder;
this.dataMaxAge = this.DataSource.MaxAge;
this.dataIndexAttachments = this.DataSource.IndexAttachments;
this.dataMaxAttachmentSizeMegabytes = this.DataSource.MaxAttachmentSizeMegabytes;
this.dataOutboundDataRestriction = this.DataSource.OutboundDataRestriction;
this.dataEmbeddingId = this.DataSource.EmbeddingId;
this.dataMaxChunkTokenLength = this.DataSource.MaxChunkTokenLength;
this.dataChunkOverlapTokenLength = this.DataSource.ChunkOverlapTokenLength;
this.dataMaxMatches = this.DataSource.MaxMatches;
this.dataConfidenceLevel = this.DataSource.ConfidenceLevel;
// A mail server of the organization goes first, see FitProviderSelection:
this.selectedTemplate = MailboxProviderTemplates.ALL.FirstOrDefault(template => template.Host.Length > 0 && template.Host.Equals(this.DataSource.Host.Trim(), StringComparison.OrdinalIgnoreCase));
var requestedSecret = await this.RustService.GetSecret(this.DataSource, SecretStoreType.DATA_SOURCE, isTrying: true);
if (requestedSecret.Success)
{
this.storedPassword = await requestedSecret.Secret.Decrypt(Program.ENCRYPTION);
this.dataPassword = this.storedPassword;
}
else
this.dataSecretStorageIssue = string.Format(T("Failed to load the password from the operating system. The message was: {0}. You might ignore this message and provide the password again."), requestedSecret.Issue);
var indexStore = await this.DatabaseClientProvider.GetIndexStoreAsync();
this.authFailure = await indexStore.GetMailboxAuthFailureAsync(this.dataId, CancellationToken.None);
}
this.ApplyOrganizationRules();
await base.OnInitializedAsync();
// A configuration plugin may change what the organization allows while the dialog is open:
this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED ]);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// Reset the validation when not editing and on the first render.
// We don't want to show validation errors when the user opens the dialog.
if (!this.IsEditing && firstRender)
this.form.ResetValidation();
// A check asked for in code waits until the fields hold their new values, cf. ToggleExpertSettings:
if (this.revalidateAfterRender)
{
this.revalidateAfterRender = false;
await this.RevalidateDependentFields(changedField: null);
}
await base.OnAfterRenderAsync(firstRender);
}
#endregion
#region Overrides of MSGComponentBase
protected override Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
switch (triggeredEvent)
{
case Event.CONFIGURATION_CHANGED:
case Event.PLUGINS_RELOADED:
this.ApplyOrganizationRules();
// A choice the organization does not offer anymore shows its error, once the fields hold the new values:
this.revalidateAfterRender = true;
this.StateHasChanged();
break;
}
return base.ProcessIncomingMessage(sendingComponent, triggeredEvent, data);
}
#endregion
/// <summary>
/// What a connection test depends on. A test only counts for the settings it was made with.
/// </summary>
private readonly record struct ConnectionSettings(string Host, int Port, MailboxTransportSecurity TransportSecurity, string Username, string Password);
private ConnectionSettings CurrentSettings => new(this.dataHost.Trim(), this.dataPort, this.dataTransportSecurity, this.dataUsername.Trim(), this.dataPassword);
private ConnectionSettings StoredSettings => new(this.DataSource.Host.Trim(), this.DataSource.Port, this.DataSource.TransportSecurity, this.DataSource.Username.Trim(), this.storedPassword);
/// <summary>
/// Whether the settings to connect with are the ones stored for this mailbox.
/// </summary>
private bool UsesStoredSettings(ConnectionSettings settings) => this.IsEditing && settings == this.StoredSettings;
/// <summary>
/// A new mailbox is only added once a sign-in worked, and an edited one once more when its connection changed.
/// </summary>
private bool RequiresConnectionTest => !this.UsesStoredSettings(this.CurrentSettings);
private bool ConnectionTestSucceeded => this.testedSettings == this.CurrentSettings && this.testFailure is null;
private bool CanChangeSource => !this.IsEditing || !this.LockSource;
private bool CanTestConnection => !this.isTestingConnection
&& DataSourceValidation.ValidateMailboxHost(this.dataHost) is null
&& this.dataSourceValidation.ValidatePort(this.dataPort) is null
&& DataSourceValidation.ValidateMailboxTransportSecurity(this.dataTransportSecurity) is null
&& DataSourceValidation.ValidateMailboxUsername(this.dataUsername) is null
&& !string.IsNullOrEmpty(this.dataPassword);
/// <summary>
/// The key of the chosen provider, see GetProviderKey, or empty for another provider.
/// </summary>
private string SelectedProviderKey => this.selectedOrganizationProvider is { } organizationProvider
? GetProviderKey(organizationProvider)
: this.selectedTemplate is { } template ? GetProviderKey(template) : string.Empty;
private string SelectedProviderText => this.selectedOrganizationProvider?.Name
?? (this.AllowsOnlyOrganizationMailServers ? string.Empty : this.selectedTemplate?.Name ?? T("Another provider"));
/// <summary>
/// Whether the organization offers any mail servers of its own.
/// </summary>
private bool HasOrganizationProviders => this.organizationProviders.Count > 0;
/// <summary>
/// Whether the organization allows mailboxes only on its own mail servers. The user then picks
/// one of them and enters nothing about the server.
/// </summary>
private bool AllowsOnlyOrganizationMailServers => this.mailServerPolicy.AllowsOnlyOrganizationMailServers;
/// <summary>
/// Whether the edited mailbox is on a server the organization does not allow.
/// </summary>
private bool IsOnServerNotAllowed => this.IsEditing && !this.mailServerPolicy.IsAllowed(this.DataSource.Host);
/// <summary>
/// Whether the organization stopped allowing new mailboxes while the dialog was open to add one.
/// </summary>
private bool IsAddingNotAllowed => !this.IsEditing && !this.SettingsManager.ConfigurationData.App.AllowUserToAddMailbox;
private string? ValidateOrganizationProvider(string providerKey) => this.AllowsOnlyOrganizationMailServers && this.organizationProviders.All(provider => GetProviderKey(provider) != providerKey)
? T("Please choose one of the mail servers of your organization.")
: null;
/// <summary>
/// Checks the host, unless it comes from the organization: then the choice of its mail server is
/// checked instead, and nobody is told about a host they cannot enter.
/// </summary>
private string? ValidateHost(string host) => this.AllowsOnlyOrganizationMailServers ? null : DataSourceValidation.ValidateMailboxHost(host);
/// <summary>
/// Whether AI Studio fills in the server settings, from a mail server of the organization or
/// from a public template which knows the host. They are hidden then, until the user asks for them.
/// </summary>
private bool AreServerSettingsFilledIn => this.AllowsOnlyOrganizationMailServers || this.selectedOrganizationProvider is not null || this.selectedTemplate is { Host.Length: > 0 };
private bool AreServerDetailsShown => !this.AreServerSettingsFilledIn || this.showServerDetails;
// While the details are hidden, the button stands in for the whole server section and keeps its distance:
private string ServerDetailsButtonClass => this.showServerDetails ? "mb-3" : "mb-6";
private void ToggleServerDetails() => this.showServerDetails = !this.showServerDetails;
private string SignInGroupClass => this.authFailure is null
? "border-dashed border rounded-lg pa-3 mb-6"
: "border-dashed border-2 mud-border-error rounded-lg pa-3 mb-6";
private string AuthFailureText => this.authFailure is null
? string.Empty
: string.Format(T("Signing in to this mailbox failed on {0}. Presumably your password changed. AI Studio does not try again on its own, so that your account is not locked. Enter your new password below and save it, or test the connection."), this.authFailure.FailedAtUtc.ToLocalTime().ToString("g", I18N.I.Culture));
private string RootFolderText => string.IsNullOrEmpty(this.dataRootFolder) ? T("Whole mailbox") : this.dataRootFolder;
private string ConfidenceLevelText => this.dataConfidenceLevel is ConfidenceLevel.NONE ? string.Empty : this.dataConfidenceLevel.GetName();
private OutboundDataRestriction MinimumOutboundDataRestriction => this.SettingsManager.ConfigurationData.MailboxSettings.MinimumOutboundDataRestriction;
private bool IsOutboundDataRestrictionLimited => this.MinimumOutboundDataRestriction is not OutboundDataRestriction.UNRESTRICTED;
private string TestResultText
{
get
{
if (this.isTestingConnection)
return T("Testing the connection ...");
if (this.testedSettings != this.CurrentSettings)
return this.RequiresConnectionTest ? T("Not tested yet.") : string.Empty;
return this.testFailure is { } failure ? failure.GetDescription() : T("Connection successful.");
}
}
private Color TestResultColor => this.testedSettings != this.CurrentSettings
? Color.Default
: this.testFailure is null ? Color.Success : Color.Error;
private string TestResultIcon => this.testedSettings != this.CurrentSettings
? Icons.Material.Outlined.HourglassEmpty
: this.testFailure is null ? Icons.Material.Outlined.CheckCircle : Icons.Material.Outlined.Error;
private EmbeddingProvider? GetEmbeddingProvider(string providerId)
{
var provider = this.SettingsManager.GetEmbeddingProviderById(providerId);
return provider == EmbeddingProvider.NONE ? null : provider;
}
private EmbeddingProvider? SelectedEmbedding => this.SettingsManager.ConfigurationData.EmbeddingProviders
.FirstOrDefault(x => x.Id == this.dataEmbeddingId);
private bool SelectedCloudEmbedding => this.SelectedEmbedding is { IsSelfHosted: false };
private int ProviderMaxChunkTokenLength => this.SelectedEmbedding?.EffectiveTokenLimit ?? EmbeddingProvider.DEFAULT_TOKEN_LIMIT;
private string MaxChunkTokenLengthHelperText => string.Format(
T("Maximum number of tokens per chunk for this data source. The embedding provider default is {0} tokens."),
this.ProviderMaxChunkTokenLength);
private string ChunkOverlapTokenLengthHelperText => string.Format(
T("Number of tokens repeated at the start of the next chunk. The default overlap is {0} tokens."),
DataSourceEmbeddingService.DEFAULT_CHUNK_OVERLAP_TOKEN_LENGTH);
private DataSourceMailbox CreateDataSource() => new()
{
Id = this.dataId,
Num = this.dataNum,
Name = this.dataName,
Type = DataSourceType.MAILBOX,
EmbeddingId = this.dataEmbeddingId,
MaxChunkTokenLength = this.dataMaxChunkTokenLength,
ChunkOverlapTokenLength = this.dataChunkOverlapTokenLength,
ConfidenceLevel = this.dataConfidenceLevel,
// Kept out of reach of the form while the source is locked, so a stale field cannot point an
// indexed mailbox somewhere else:
Host = this.CanChangeSource ? this.dataHost.Trim() : this.DataSource.Host,
Username = this.CanChangeSource ? this.dataUsername.Trim() : this.DataSource.Username,
RootFolder = this.CanChangeSource ? this.dataRootFolder : this.DataSource.RootFolder,
Port = this.dataPort,
TransportSecurity = this.dataTransportSecurity,
AuthMethod = MailboxAuthMethod.PASSWORD,
MaxAge = this.dataMaxAge,
IndexAttachments = this.dataIndexAttachments,
MaxAttachmentSizeMegabytes = this.dataMaxAttachmentSizeMegabytes,
OutboundDataRestriction = this.dataOutboundDataRestriction,
MaxMatches = this.dataMaxMatches,
};
//
// A key per choice rather than the name: an organization may well call its mail server like
// one of the well-known providers, e.g., "Microsoft Exchange Server".
//
private static string GetProviderKey(DataMailboxProvider organizationProvider) => $"organization:{organizationProvider.Id}";
private static string GetProviderKey(MailboxProviderTemplate template) => $"template:{template.Name}";
/// <summary>
/// Reads what the organization decides about mailboxes and fits the dialog to it.
/// </summary>
/// <remarks>
/// Runs when the dialog opens and again whenever a configuration plugin changes while it is
/// open. Either way, the dialog offers what it would offer when opened anew, and keeps what the
/// user entered as far as the organization allows it.
/// </remarks>
private void ApplyOrganizationRules()
{
// The mail servers of the organization come from the running configuration plugins:
this.organizationProviders = PluginFactory.GetMailboxProviders();
this.mailServerPolicy = MailServerPolicy.Read(this.SettingsManager);
this.FitProviderSelection();
// A level the organization ruled out is not offered anymore, so the dialog switches to the
// one which applies anyway, see MailToolResults.GetRequirements:
this.dataOutboundDataRestriction = this.dataOutboundDataRestriction.StricterOf(this.MinimumOutboundDataRestriction);
}
/// <summary>
/// Fits the chosen provider to the mail servers the organization offers right now.
/// </summary>
private void FitProviderSelection()
{
//
// While the source can be changed, a chosen mail server of the organization is followed by
// its id: when the organization changed how to reach it, its new settings apply. A locked
// source keeps its host, so only the host tells which server of the organization it is on:
//
if (this.selectedOrganizationProvider is { } chosenOrganizationProvider && this.CanChangeSource)
{
var currentOrganizationProvider = this.organizationProviders.FirstOrDefault(provider => provider.Id == chosenOrganizationProvider.Id);
if (currentOrganizationProvider is not null && (currentOrganizationProvider.Host != chosenOrganizationProvider.Host || currentOrganizationProvider.Port != chosenOrganizationProvider.Port || currentOrganizationProvider.TransportSecurity != chosenOrganizationProvider.TransportSecurity))
this.SelectProvider(GetProviderKey(currentOrganizationProvider));
else
this.selectedOrganizationProvider = currentOrganizationProvider;
}
else
this.selectedOrganizationProvider = null;
this.selectedOrganizationProvider ??= this.organizationProviders.FirstOrDefault(provider => MailServerHosts.AreSame(provider.Host, this.dataHost));
// The mail servers of the organization go before the public templates, which are not offered
// at all while the organization allows its own mail servers only:
if (this.selectedOrganizationProvider is not null || this.AllowsOnlyOrganizationMailServers)
this.selectedTemplate = null;
// A new mailbox with a single mail server to choose from has nothing to choose:
if (!this.IsEditing && this.AllowsOnlyOrganizationMailServers && this.selectedOrganizationProvider is null && this.organizationProviders.Count == 1)
this.SelectProvider(GetProviderKey(this.organizationProviders[0]));
}
private void SelectProvider(string providerKey)
{
this.selectedOrganizationProvider = this.organizationProviders.FirstOrDefault(candidate => GetProviderKey(candidate) == providerKey);
this.selectedTemplate = MailboxProviderTemplates.ALL.FirstOrDefault(candidate => GetProviderKey(candidate) == providerKey);
if (this.selectedOrganizationProvider is { } organizationProvider)
{
this.dataHost = organizationProvider.Host;
this.dataPort = organizationProvider.Port;
this.dataTransportSecurity = organizationProvider.TransportSecurity;
}
else if (this.selectedTemplate is { } template)
{
this.dataHost = template.Host;
this.dataPort = template.Port;
this.dataTransportSecurity = template.TransportSecurity;
}
}
private void SelectTransportSecurity(MailboxTransportSecurity transportSecurity)
{
//
// Who switches the encryption usually means the port that comes with it, unless they
// entered a port of their own:
//
if (this.dataPort == this.dataTransportSecurity.GetUsualPort() && transportSecurity.GetUsualPort() is { } usualPort)
this.dataPort = usualPort;
this.dataTransportSecurity = transportSecurity;
}
private async Task TestConnection()
{
if (!this.CanTestConnection)
return;
var settings = this.CurrentSettings;
var serverAnswer = string.Empty;
this.isTestingConnection = true;
this.folderIssue = string.Empty;
try
{
using var timeout = new CancellationTokenSource(CONNECTION_TEST_TIMEOUT);
await using var connector = new ImapMailboxConnector();
await connector.ConnectAsync(this.CreateDataSource(), settings.Password, MailServerPolicy.Read(this.SettingsManager), timeout.Token);
this.serverFolders = await connector.GetFoldersAsync(timeout.Token);
this.testFailure = null;
this.Logger.LogInformation($"Tested the connection of the mailbox '{this.dataId}' successfully.");
}
catch (MailboxConnectionException e)
{
this.serverFolders = [];
this.testFailure = e.Failure;
serverAnswer = e.InnerException?.Message ?? string.Empty;
this.Logger.LogWarning($"Testing the connection of the mailbox '{this.dataId}' failed: {e.Failure} ({e.InnerException?.GetType().Name ?? "no inner exception"}).");
}
catch (OperationCanceledException)
{
this.serverFolders = [];
this.testFailure = MailboxConnectionFailure.NETWORK_UNAVAILABLE;
this.Logger.LogWarning($"Testing the connection of the mailbox '{this.dataId}' took too long.");
}
finally
{
this.testedSettings = settings;
this.isTestingConnection = false;
}
await this.RecordSignInOutcomeAsync(settings, this.testFailure, serverAnswer);
await this.form.Validate();
}
private async Task<MailServerFolder?> CreateFolder(string parentFullName, string name)
{
var settings = this.CurrentSettings;
var serverAnswer = string.Empty;
this.folderIssue = string.Empty;
MailboxConnectionFailure? failure = null;
try
{
using var timeout = new CancellationTokenSource(CONNECTION_TEST_TIMEOUT);
await using var connector = new ImapMailboxConnector();
await connector.ConnectAsync(this.CreateDataSource(), settings.Password, MailServerPolicy.Read(this.SettingsManager), timeout.Token);
var createdFolder = await connector.CreateFolderAsync(parentFullName, name, timeout.Token);
this.serverFolders = [..this.serverFolders, createdFolder];
this.Logger.LogInformation($"Created a folder in the mailbox '{this.dataId}'.");
return createdFolder;
}
catch (MailboxConnectionException e)
{
failure = e.Failure;
serverAnswer = e.InnerException?.Message ?? string.Empty;
this.folderIssue = e.Failure is MailboxConnectionFailure.SERVER_ERROR
? T("The server did not create the folder. Perhaps a folder of this name exists already.")
: e.Failure.GetDescription();
this.Logger.LogWarning($"Creating a folder in the mailbox '{this.dataId}' failed: {e.Failure} ({e.InnerException?.GetType().Name ?? "no inner exception"}).");
return null;
}
catch (ArgumentException)
{
this.folderIssue = T("This name is too long, or it contains a character the server reserves for folder paths.");
return null;
}
catch (OperationCanceledException)
{
this.folderIssue = MailboxConnectionFailure.NETWORK_UNAVAILABLE.GetDescription();
return null;
}
finally
{
if (failure is MailboxConnectionFailure.AUTHENTICATION_FAILED)
await this.RecordSignInOutcomeAsync(settings, failure, serverAnswer);
}
}
/// <summary>
/// Records what a sign-in with the stored settings showed, cf. the remarks of this dialog.
/// </summary>
/// <param name="settings">The settings the sign-in used.</param>
/// <param name="failure">Why the sign-in failed, or null when it worked.</param>
/// <param name="serverAnswer">What the server answered to a failed sign-in, which often says why, e.g., that it requires an app password.</param>
private async Task RecordSignInOutcomeAsync(ConnectionSettings settings, MailboxConnectionFailure? failure, string serverAnswer)
{
if (!this.UsesStoredSettings(settings))
return;
var indexStore = await this.DatabaseClientProvider.GetIndexStoreAsync();
switch (failure)
{
case null:
await indexStore.ClearMailboxAuthFailureAsync(this.dataId, CancellationToken.None);
this.authFailure = null;
break;
case MailboxConnectionFailure.AUTHENTICATION_FAILED:
this.authFailure = MailboxAuthFailure.FromServerAnswer(serverAnswer);
await indexStore.UpsertMailboxAuthFailureAsync(this.dataId, this.authFailure, CancellationToken.None);
break;
}
}
private async Task Store()
{
if (this.IsAddingNotAllowed)
return;
await this.form.Validate();
if (this.RequiresConnectionTest && this.dataSourceValidation.ValidateTestedConnection() is { } testIssue)
{
Array.Resize(ref this.dataIssues, this.dataIssues.Length + 1);
this.dataIssues[^1] = testIssue;
this.dataIsValid = false;
}
this.dataSecretStorageIssue = string.Empty;
// When the data is not valid, we don't store it:
if (!this.dataIsValid)
return;
var mailbox = this.CreateDataSource();
//
// Only when editing: while adding, DataSource is still default, and nothing has been
// prepared for a mailbox which does not exist yet.
//
if (this.IsEditing && !await DataSourceReindexWarning.ConfirmDataSourceChangeAsync(this.DialogService, this.SettingsManager, this.DataSourceEmbeddingService, this.DataSource, mailbox))
return;
//
// The OS keyring stores the password under the name of the mailbox, so a renamed mailbox
// gets a new entry, and the old one goes once the new one is in place:
//
var isRenamed = this.IsEditing && !string.Equals(this.DataSource.Name, mailbox.Name, StringComparison.Ordinal);
var isNewPassword = !this.IsEditing || !string.Equals(this.dataPassword, this.storedPassword, StringComparison.Ordinal);
if (isNewPassword || isRenamed)
{
var storeResponse = await this.RustService.SetSecret(mailbox, this.dataPassword, SecretStoreType.DATA_SOURCE);
if (!storeResponse.Success)
{
this.dataSecretStorageIssue = string.Format(T("Failed to store the password in the operating system. The message was: {0}. Please try again."), storeResponse.Issue);
await this.form.Validate();
return;
}
}
//
// A name which differs only in case keeps the old entry: some keyrings ignore case, so both
// names lead to the same entry, and deleting the old one would delete the password:
//
if (isRenamed && !string.Equals(this.DataSource.Name, mailbox.Name, StringComparison.OrdinalIgnoreCase))
{
var deleteResponse = await this.RustService.DeleteSecret(this.DataSource, SecretStoreType.DATA_SOURCE);
if (!deleteResponse.Success)
this.Logger.LogWarning($"Failed to delete the password of the mailbox '{this.dataId}' stored under its previous name: {deleteResponse.Issue}");
}
// A new password earns the next synchronization one attempt to sign in:
if (this.IsEditing && isNewPassword)
{
var indexStore = await this.DatabaseClientProvider.GetIndexStoreAsync();
await indexStore.ClearMailboxAuthFailureAsync(this.dataId, CancellationToken.None);
}
this.MudDialog.Close(DialogResult.Ok(mailbox));
}
private void Cancel() => this.MudDialog.Cancel();
/// <summary>
/// Gives the fields which are checked against each other a fresh verdict: the embedding provider
/// and the required confidence level, and the token limits, which depend on the embedding provider.
/// The provider joins them, because its rule reads what the organization allows.
/// </summary>
private Task RevalidateDependentFields(IFormComponent? changedField) => DependentFieldValidation.RevalidateAsync(changedField, this.providerSelect, this.embeddingSelect, this.confidenceLevelSelect, this.maxChunkTokenLengthField, this.chunkOverlapTokenLengthField);
private Task RevalidateAfterFieldChange(FormFieldChangedEventArgs change) => this.RevalidateDependentFields(change.Field);
private string? ValidateMaxChunkTokenLength(int maxChunkTokenLength)
{
if (!this.showExpertSettings)
return null;
if (maxChunkTokenLength < 1)
return T("Please enter a token limit of at least 1.");
var providerMaxChunkTokenLength = this.ProviderMaxChunkTokenLength;
if (maxChunkTokenLength > providerMaxChunkTokenLength)
return string.Format(T("The data source token limit must not be larger than the embedding provider token limit ({0})."), providerMaxChunkTokenLength);
return null;
}
private string? ValidateChunkOverlapTokenLength(int chunkOverlapTokenLength)
{
if (!this.showExpertSettings)
return null;
if (chunkOverlapTokenLength < 0)
return T("Please enter 0 or a positive overlap length.");
var effectiveMaxChunkTokenLength = this.dataMaxChunkTokenLength > 0
? this.dataMaxChunkTokenLength
: this.ProviderMaxChunkTokenLength;
if (chunkOverlapTokenLength >= effectiveMaxChunkTokenLength)
return T("The overlap must be smaller than the effective token limit.");
return null;
}
private void ToggleExpertSettings()
{
this.showExpertSettings = !this.showExpertSettings;
if (this.showExpertSettings && this.dataMaxChunkTokenLength < 1)
this.dataMaxChunkTokenLength = this.ProviderMaxChunkTokenLength;
// The token limits are only checked while they are shown. The field learns the limit set
// above only with the next render, so it is checked after that:
this.revalidateAfterRender = true;
}
private string GetExpertStyles => this.showExpertSettings ? "border-2 border-dashed rounded pa-2" : string.Empty;
}
@@ -72,6 +72,7 @@ public partial class PluginImportDialog : MSGComponentBase
Add(summary.DocumentAnalysisPolicies, this.T("{0} document analysis policy"), this.T("{0} document analysis policies"));
Add(summary.MandatoryInfos, this.T("{0} mandatory information you have to accept before using AI Studio"), this.T("{0} mandatory information you have to accept before using AI Studio"));
Add(summary.Introductions, this.T("{0} introduction on the welcome page"), this.T("{0} introductions on the welcome page"));
Add(summary.MailboxProviders, this.T("{0} mail server offered for new mailboxes"), this.T("{0} mail servers offered for new mailboxes"));
return contents;
@@ -3,22 +3,22 @@
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6" Class="d-flex align-center">
<MudIcon Icon="@this.implementation?.Icon" Class="mr-2" />
@(this.implementation?.GetDisplayName() ?? T("Tool Settings"))
<MudIcon Icon="@this.item?.Icon" Class="mr-2" />
@(this.item?.DisplayName ?? T("Tool Settings"))
</MudText>
</TitleContent>
<DialogContent>
@if (this.toolDefinition is null)
@if (this.item is null)
{
<MudText Typo="Typo.body1">@T("The selected tool could not be loaded.")</MudText>
}
else
{
<MudJustifiedText Typo="Typo.body1" Class="mb-4">
@this.implementation?.GetDescription()
@this.item.Description
</MudJustifiedText>
@if (!this.SettingsManager.IsToolActive(this.toolDefinition.Id))
@if (!this.ToolRegistry.IsToolActive(this.item.Id))
{
<MudAlert Severity="Severity.Warning" Class="mb-4">@T("This tool has been disabled by your organization.")</MudAlert>
}
@@ -33,47 +33,59 @@
<MudAlert Severity="Severity.Warning" Class="mb-4">@warning</MudAlert>
}
@foreach (var group in this.BuildVisibleFieldGroups())
@foreach (var toolSection in this.sections)
{
<MudPaper Class="pa-3 mb-4 border-dashed border rounded-lg">
@if (this.ShowsGroupHeader(group))
var visibleGroups = BuildVisibleFieldGroups(toolSection);
@if (this.ShowsToolHeaders && visibleGroups.Count > 0)
{
<MudStack Row="@true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap" Class="mb-3">
<MudText Typo="Typo.subtitle2">@this.GetGroupLabel(group.Key)</MudText>
<MudStack Row="@true" AlignItems="AlignItems.Center" Spacing="2" Wrap="Wrap.Wrap">
@foreach (var link in this.GetGroupLinks(group.Key))
{
<MudButton Variant="Variant.Filled" Size="Size.Small" StartIcon="@link.Icon" Href="@link.Url" Target="_blank">
@link.Label
</MudButton>
}
<MudText Typo="Typo.subtitle1" Class="d-flex align-center mb-2">
<MudIcon Icon="@toolSection.Implementation.Icon" Class="mr-2" />
@toolSection.Implementation.GetDisplayName()
</MudText>
}
@foreach (var group in visibleGroups)
{
<MudPaper Class="pa-3 mb-4 border-dashed border rounded-lg">
@if (ShowsGroupHeader(toolSection, group))
{
<MudStack Row="@true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap" Class="mb-3">
<MudText Typo="Typo.subtitle2">@this.GetGroupLabel(toolSection, group.Key)</MudText>
<MudStack Row="@true" AlignItems="AlignItems.Center" Spacing="2" Wrap="Wrap.Wrap">
@foreach (var link in GetGroupLinks(toolSection, group.Key))
{
<MudButton Variant="Variant.Filled" Size="Size.Small" StartIcon="@link.Icon" Href="@link.Url" Target="_blank">
@link.Label
</MudButton>
}
</MudStack>
</MudStack>
</MudStack>
}
@foreach (var property in group.Fields)
{
var fieldName = property.Key;
var field = property.Value;
var fieldOptions = field.GetOptions();
if (fieldOptions.Count > 0)
{
<MudSelect T="string" Label="@this.GetFieldLabel(fieldName, field)" Value="@this.GetValue(fieldName)" ValueChanged="@(value => this.UpdateValue(fieldName, value))" Variant="Variant.Outlined" Margin="Margin.Dense" HelperText="@this.GetFieldDescription(fieldName, field)" Placeholder="@this.GetFieldPlaceholder(fieldName, field)" Class="mb-3 configuration-help-justified" Disabled="@this.IsFieldDisabled(fieldName)">
@if (!this.toolDefinition.SettingsSchema.Required.Contains(fieldName))
{
<MudSelectItem T="string" Value="@string.Empty">@T("Not set")</MudSelectItem>
}
@foreach (var option in fieldOptions)
{
<MudSelectItem T="string" Value="@option.Value">@option.Label</MudSelectItem>
}
</MudSelect>
}
else
@foreach (var property in group.Fields)
{
<MudTextField T="string" Label="@this.GetFieldLabel(fieldName, field)" Value="@this.GetValue(fieldName)" ValueChanged="@(value => this.UpdateValue(fieldName, value))" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 configuration-help-justified" HelperText="@this.GetFieldDescription(fieldName, field)" Placeholder="@this.GetFieldPlaceholder(fieldName, field)" InputType="@(field.Secret ? InputType.Password : InputType.Text)" Disabled="@this.IsFieldDisabled(fieldName)" />
var fieldName = property.Key;
var field = property.Value;
var fieldOptions = field.GetOptions();
if (fieldOptions.Count > 0)
{
<MudSelect T="string" Label="@GetFieldLabel(toolSection, fieldName, field)" Value="@GetValue(toolSection, fieldName)" ValueChanged="@(value => this.UpdateValue(toolSection, fieldName, value))" Variant="Variant.Outlined" Margin="Margin.Dense" HelperText="@this.GetFieldDescription(toolSection, fieldName, field)" Placeholder="@GetFieldPlaceholder(toolSection, fieldName, field)" Class="mb-3 configuration-help-justified" Disabled="@this.IsFieldDisabled(toolSection, fieldName)">
@if (!toolSection.Definition.SettingsSchema.Required.Contains(fieldName))
{
<MudSelectItem T="string" Value="@string.Empty">@T("Not set")</MudSelectItem>
}
@foreach (var option in fieldOptions)
{
<MudSelectItem T="string" Value="@option.Value">@option.Label</MudSelectItem>
}
</MudSelect>
}
else
{
<MudTextField T="string" Label="@GetFieldLabel(toolSection, fieldName, field)" Value="@GetValue(toolSection, fieldName)" ValueChanged="@(value => this.UpdateValue(toolSection, fieldName, value))" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 configuration-help-justified" HelperText="@this.GetFieldDescription(toolSection, fieldName, field)" Placeholder="@GetFieldPlaceholder(toolSection, fieldName, field)" InputType="@(field.Secret ? InputType.Password : InputType.Text)" Disabled="@this.IsFieldDisabled(toolSection, fieldName)" />
}
}
</MudPaper>
}
</MudPaper>
}
}
</DialogContent>
@@ -81,8 +93,8 @@
<MudButton OnClick="@this.Close" Variant="Variant.Text">
@T("Cancel")
</MudButton>
<MudButton OnClick="@this.Save" Variant="Variant.Filled" Disabled="@(this.toolDefinition is null)">
<MudButton OnClick="@this.Save" Variant="Variant.Filled" Disabled="@(this.item is null)">
@T("Save")
</MudButton>
</DialogActions>
</MudDialog>
</MudDialog>
@@ -4,10 +4,22 @@ using Microsoft.AspNetCore.Components;
namespace AIStudio.Dialogs.Settings;
/// <summary>
/// Shows and saves the settings of one entry of the tool catalog.
/// </summary>
/// <remarks>
/// An entry is a tool collection, see ToolCollectionDefinition. Its tools keep their settings to
/// themselves, so the dialog shows one section per tool, and an organization still addresses each
/// field as "toolId.fieldName". A tool which belongs to no declared collection forms an entry of its
/// own, and the dialog looks as it always did for it: one section without a heading.
/// </remarks>
public partial class ToolSettingsDialog : SettingsDialogBase
{
/// <summary>
/// The ID of the collection whose settings to show. The ID of one of its tools stands for the whole collection.
/// </summary>
[Parameter]
public string ToolId { get; set; } = string.Empty;
public string CollectionId { get; set; } = string.Empty;
[Inject]
private ToolRegistry ToolRegistry { get; init; } = null!;
@@ -15,25 +27,34 @@ public partial class ToolSettingsDialog : SettingsDialogBase
[Inject]
private ToolSettingsService ToolSettingsService { get; init; } = null!;
private ToolDefinition? toolDefinition;
private IToolImplementation? implementation;
private Dictionary<string, string> values = new(StringComparer.Ordinal);
private IReadOnlyList<FieldGroup> fieldGroups = [];
private ToolCatalogItem? item;
private IReadOnlyList<ToolSection> sections = [];
private string validationMessage = string.Empty;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
this.toolDefinition = this.ToolRegistry.GetDefinition(this.ToolId);
if (this.toolDefinition is not null)
{
this.implementation = this.ToolRegistry.GetImplementation(this.toolDefinition.ImplementationKey);
this.values = await this.ToolSettingsService.GetSettingsAsync(this.toolDefinition);
this.fieldGroups = BuildFieldGroups(this.toolDefinition);
}
this.item = await this.ToolRegistry.GetCatalogItemAsync(this.CollectionId);
if (this.item is null)
return;
var loadedSections = new List<ToolSection>(this.item.Tools.Count);
foreach (var tool in this.item.Tools)
loadedSections.Add(new(tool.Definition, tool.Implementation, await this.ToolSettingsService.GetSettingsAsync(tool.Definition), BuildFieldGroups(tool.Definition)));
this.sections = loadedSections;
}
private string GetValue(string fieldName) => this.values.GetValueOrDefault(fieldName, string.Empty);
/// <summary>
/// Whether each section is headed by the name of its tool.
/// </summary>
/// <remarks>
/// Only when the entry holds more than one tool. Otherwise the title of the dialog names the
/// tool already.
/// </remarks>
private bool ShowsToolHeaders => this.sections.Count > 1;
private static string GetValue(ToolSection section, string fieldName) => section.Values.GetValueOrDefault(fieldName, string.Empty);
/// <summary>
/// Splits the tool's settings fields into the groups the tool declared for them.
@@ -66,7 +87,7 @@ public partial class ToolSettingsDialog : SettingsDialogBase
}
/// <summary>
/// The groups as they are rendered right now, without the fields the tool is hiding.
/// The groups of one tool as they are rendered right now, without the fields the tool is hiding.
/// </summary>
/// <remarks>
/// Which fields make sense can depend on what is filled in, so this is built on every
@@ -76,15 +97,12 @@ public partial class ToolSettingsDialog : SettingsDialogBase
/// Cheap enough to be called more than once per render: a tool has a handful of settings,
/// and asking the tool about one of them costs a dictionary lookup or two.
/// </remarks>
private IReadOnlyList<FieldGroup> BuildVisibleFieldGroups()
private static IReadOnlyList<FieldGroup> BuildVisibleFieldGroups(ToolSection section)
{
if (this.implementation is null)
return this.fieldGroups;
var visibleGroups = new List<FieldGroup>();
foreach (var group in this.fieldGroups)
foreach (var group in section.FieldGroups)
{
var visibleFields = group.Fields.Where(field => this.implementation.IsSettingsFieldVisible(field.Key, this.values)).ToList();
var visibleFields = group.Fields.Where(field => section.Implementation.IsSettingsFieldVisible(field.Key, section.Values)).ToList();
if (visibleFields.Count > 0)
visibleGroups.Add(new FieldGroup(group.Key, visibleFields));
}
@@ -100,84 +118,98 @@ public partial class ToolSettingsDialog : SettingsDialogBase
/// heading above the only box would say nothing the dialog's title does not say already.
/// As soon as there is a second box, each of them has to state which one it is — the box
/// holding the fields that belong to no group in particular included.<br/><br/>
/// It counts the boxes that are actually rendered, so a group the tool hides entirely does
/// not leave the remaining box with a heading it does not need.
/// It counts the boxes of the tool that are actually rendered, so a group the tool hides
/// entirely does not leave the remaining box with a heading it does not need.
/// </remarks>
private bool ShowsGroupHeader(FieldGroup group) => this.BuildVisibleFieldGroups().Count > 1 || !string.IsNullOrEmpty(group.Key);
private static bool ShowsGroupHeader(ToolSection section, FieldGroup group) => BuildVisibleFieldGroups(section).Count > 1 || !string.IsNullOrEmpty(group.Key);
/// <remarks>
/// The ungrouped fields have no name of their own, so the label hook hands back their
/// empty group name. A tool may still name them through that same hook; when it does not,
/// they are simply what is left over next to the named groups.
/// </remarks>
private string GetGroupLabel(string groupKey)
private string GetGroupLabel(ToolSection section, string groupKey)
{
var label = this.implementation?.GetSettingsGroupLabel(groupKey) ?? groupKey;
var label = section.Implementation.GetSettingsGroupLabel(groupKey);
return string.IsNullOrEmpty(label) ? T("General") : label;
}
private IReadOnlyList<ToolSettingsGroupLink> GetGroupLinks(string groupKey) => this.implementation?.GetSettingsGroupLinks(groupKey) ?? [];
private static IReadOnlyList<ToolSettingsGroupLink> GetGroupLinks(ToolSection section, string groupKey) => section.Implementation.GetSettingsGroupLinks(groupKey);
/// <summary>
/// What the tool wants to say about the settings as they stand right now.
/// What the tools want to say about their settings as they stand right now.
/// </summary>
/// <remarks>
/// Asked on every render, so a warning follows the value it is about instead of waiting for
/// the next save. These are not errors: they describe settings that are allowed and do
/// something other than what they look like, and the dialog saves them either way.
/// </remarks>
private IReadOnlyList<string> GetSettingsWarnings() => this.implementation?.GetSettingsWarnings(this.values) ?? [];
private IEnumerable<string> GetSettingsWarnings() => this.sections.SelectMany(section => section.Implementation.GetSettingsWarnings(section.Values));
private string GetFieldLabel(string fieldName, ToolSettingsFieldDefinition fieldDefinition) =>
this.implementation?.GetSettingsFieldLabel(fieldName, fieldDefinition) ?? fieldDefinition.Title;
private static string GetFieldLabel(ToolSection section, string fieldName, ToolSettingsFieldDefinition fieldDefinition) =>
section.Implementation.GetSettingsFieldLabel(fieldName, fieldDefinition);
private string GetFieldDescription(string fieldName, ToolSettingsFieldDefinition fieldDefinition) =>
this.GetFieldDescriptionWithDefault(fieldName, fieldDefinition);
private static string GetFieldDefaultValue(ToolSection section, string fieldName, ToolSettingsFieldDefinition fieldDefinition) =>
section.Implementation.GetSettingsFieldDefaultValue(fieldName, fieldDefinition) ?? string.Empty;
private string GetFieldDefaultValue(string fieldName, ToolSettingsFieldDefinition fieldDefinition) =>
this.implementation?.GetSettingsFieldDefaultValue(fieldName, fieldDefinition) ?? string.Empty;
private string GetFieldDescriptionWithDefault(string fieldName, ToolSettingsFieldDefinition fieldDefinition)
private string GetFieldDescription(ToolSection section, string fieldName, ToolSettingsFieldDefinition fieldDefinition)
{
var description = this.implementation?.GetSettingsFieldDescription(fieldName, fieldDefinition) ?? fieldDefinition.Description;
var defaultValue = this.GetFieldDefaultValue(fieldName, fieldDefinition);
var description = section.Implementation.GetSettingsFieldDescription(fieldName, fieldDefinition);
var defaultValue = GetFieldDefaultValue(section, fieldName, fieldDefinition);
if (string.IsNullOrWhiteSpace(defaultValue))
return description;
return string.Format(T("{0} Default: {1}"), description, defaultValue);
}
private bool IsFieldDisabled(string fieldName) =>
this.toolDefinition is not null && this.ToolSettingsService.IsFieldLocked(this.toolDefinition, fieldName);
private bool IsFieldDisabled(ToolSection section, string fieldName) => this.ToolSettingsService.IsFieldLocked(section.Definition, fieldName);
private string GetFieldPlaceholder(string fieldName, ToolSettingsFieldDefinition fieldDefinition) =>
string.IsNullOrWhiteSpace(this.GetValue(fieldName)) ? this.GetFieldDefaultValue(fieldName, fieldDefinition) : string.Empty;
private static string GetFieldPlaceholder(ToolSection section, string fieldName, ToolSettingsFieldDefinition fieldDefinition) =>
string.IsNullOrWhiteSpace(GetValue(section, fieldName)) ? GetFieldDefaultValue(section, fieldName, fieldDefinition) : string.Empty;
private void UpdateValue(string fieldName, string? value)
private void UpdateValue(ToolSection section, string fieldName, string? value)
{
this.values[fieldName] = value ?? string.Empty;
section.Values[fieldName] = value ?? string.Empty;
this.validationMessage = string.Empty;
}
/// <summary>
/// Checks the settings of every tool, and saves those of the tools which have any.
/// </summary>
/// <remarks>
/// Nothing is saved while one tool is not satisfied, so a collection never ends up with some
/// of its tools configured and the others not, which would leave it unusable all the same.
/// </remarks>
private async Task Save()
{
if (this.toolDefinition is null)
if (this.item is null)
return;
var validationState = await this.ToolSettingsService.ValidateSettingsAsync(this.toolDefinition, this.values, this.implementation);
if (!validationState.IsConfigured)
foreach (var section in this.sections)
{
var validationState = await this.ToolSettingsService.ValidateSettingsAsync(section.Definition, section.Values, section.Implementation);
if (validationState.IsConfigured)
continue;
this.validationMessage = !string.IsNullOrWhiteSpace(validationState.Message)
? validationState.Message
: string.Format(T("Please configure the required settings: {0}"), string.Join(", ", validationState.MissingRequiredFields));
return;
}
await this.ToolSettingsService.SaveSettingsAsync(this.toolDefinition, this.values);
foreach (var section in this.sections.Where(section => section.Definition.SettingsSchema.Properties.Count > 0))
await this.ToolSettingsService.SaveSettingsAsync(section.Definition, section.Values);
this.MudDialog.Close();
}
/// <param name="Definition">The tool.</param>
/// <param name="Implementation">The implementation of the tool, which labels and checks its fields.</param>
/// <param name="Values">The values as they are being edited.</param>
/// <param name="FieldGroups">The groups of its fields, in the order the tool declared them.</param>
private sealed record ToolSection(ToolDefinition Definition, IToolImplementation Implementation, Dictionary<string, string> Values, IReadOnlyList<FieldGroup> FieldGroups);
/// <param name="Key">The group's name from the schema, or empty for the ungrouped fields.</param>
/// <param name="Fields">The fields of this group, in the order the tool declared them.</param>
private sealed record FieldGroup(string Key, List<KeyValuePair<string, ToolSettingsFieldDefinition>> Fields);
}
}
@@ -22,7 +22,7 @@
<MudProgressCircular Indeterminate="@true" Size="Size.Small" />
<MudText>@T("Loading tool configuration...")</MudText>
}
else if (this.toolDefinition is null || this.implementation is null)
else if (this.item is null)
{
@if (string.IsNullOrWhiteSpace(this.message))
{
@@ -31,7 +31,7 @@
}
else
{
<MudText Typo="Typo.subtitle1" Class="mb-2">@this.implementation.GetDisplayName()</MudText>
<MudText Typo="Typo.subtitle1" Class="mb-2">@this.item.DisplayName</MudText>
<MudText Typo="Typo.body2" Class="mb-4">
@T("Export saved settings as Lua code for your configuration plugin. You can combine exports and adapt the code before deploying it.")
</MudText>
@@ -8,8 +8,11 @@ namespace AIStudio.Dialogs.Settings;
public partial class ToolSettingsExportDialog : SettingsDialogBase
{
/// <summary>
/// The ID of the collection whose settings to export. The ID of one of its tools stands for the whole collection.
/// </summary>
[Parameter]
public string ToolId { get; set; } = string.Empty;
public string CollectionId { get; set; } = string.Empty;
[Inject]
private ToolRegistry ToolRegistry { get; init; } = null!;
@@ -20,12 +23,11 @@ public partial class ToolSettingsExportDialog : SettingsDialogBase
[Inject]
private ILogger<ToolSettingsExportDialog> Logger { get; init; } = null!;
private ToolDefinition? toolDefinition;
private IToolImplementation? implementation;
private IReadOnlyList<ExportableSettings> areas = [];
private ToolCatalogItem? item;
private IReadOnlyList<ToolSettingsExportArea> areas = [];
private HashSet<string> selectedAreaIds = new(StringComparer.Ordinal);
private HashSet<string> configuredSecretFields = new(StringComparer.Ordinal);
private HashSet<string> emptyFieldNames = new(StringComparer.Ordinal);
private readonly HashSet<ToolField> configuredSecretFields = [];
private readonly HashSet<ToolField> emptyFields = [];
private ToolSettingsExportMode mode = ToolSettingsExportMode.LOCKED;
private bool includeSecrets;
private bool includeMinimumProviderConfidence = true;
@@ -39,7 +41,7 @@ public partial class ToolSettingsExportDialog : SettingsDialogBase
private bool AllAreasSelected => this.areas.Count > 0 && this.areas.All(area => this.selectedAreaIds.Contains(area.Id));
private bool HasSelectedSecrets => this.areas.Any(area => this.selectedAreaIds.Contains(area.Id) && area.FieldNames.Any(this.configuredSecretFields.Contains));
private bool HasSelectedSecrets => this.SelectedFields.Any(this.configuredSecretFields.Contains);
private bool CanIncludeSecrets => this.HasSelectedSecrets && PluginFactory.EnterpriseEncryption?.IsAvailable is true;
@@ -51,16 +53,20 @@ public partial class ToolSettingsExportDialog : SettingsDialogBase
/// the administrator never filled in still exports. Locked, those empty values are what the
/// recipient is left with and cannot change, which is worth saying before the export.
/// </remarks>
private int EmptySelectedFieldCount => this.areas
private int EmptySelectedFieldCount => this.SelectedFields.Count(this.emptyFields.Contains);
/// <summary>
/// The fields of all selected areas, each once.
/// </summary>
private IEnumerable<ToolField> SelectedFields => this.areas
.Where(area => this.selectedAreaIds.Contains(area.Id))
.SelectMany(area => area.FieldNames)
.Distinct(StringComparer.Ordinal)
.Count(this.emptyFieldNames.Contains);
.SelectMany(area => area.Area.FieldNames.Select(fieldName => new ToolField(area.Tool.Definition.Id, fieldName)))
.Distinct();
private bool WarnAboutEmptyLockedSettings => this.mode is ToolSettingsExportMode.LOCKED && this.EmptySelectedFieldCount > 0;
private bool CanExport => this.IsAdmin && !this.isLoading && !this.isExporting && !this.isDisposed &&
this.toolDefinition is not null && this.implementation is not null && (this.selectedAreaIds.Count > 0 || this.includeMinimumProviderConfidence);
this.item is not null && (this.selectedAreaIds.Count > 0 || this.includeMinimumProviderConfidence);
protected override async Task OnInitializedAsync()
{
@@ -73,40 +79,38 @@ public partial class ToolSettingsExportDialog : SettingsDialogBase
try
{
this.toolDefinition = this.ToolRegistry.GetDefinition(this.ToolId);
if (this.toolDefinition is null)
this.item = await this.ToolRegistry.GetCatalogItemAsync(this.CollectionId);
if (this.item is null)
return;
this.implementation = this.ToolRegistry.GetImplementation(this.toolDefinition.ImplementationKey);
if (this.implementation is null)
return;
this.areas = this.implementation.GetExportableSettings(this.toolDefinition);
this.areas = ToolSettingsService.GetExportAreas(this.item.Tools);
this.selectedAreaIds = this.areas.Select(area => area.Id).ToHashSet(StringComparer.Ordinal);
// Retain only field names, never the values themselves, so no plaintext secret lives
// in this component. ExportAsync reads effective settings again when the
// administrator exports.
var values = await this.ToolSettingsService.GetSettingsAsync(this.toolDefinition);
this.configuredSecretFields = this.toolDefinition.SettingsSchema.Properties
.Where(property => property.Value.Secret && values.TryGetValue(property.Key, out var value) && !string.IsNullOrWhiteSpace(value))
.Select(property => property.Key)
.ToHashSet(StringComparer.Ordinal);
foreach (var tool in this.item.Tools)
{
var values = await this.ToolSettingsService.GetSettingsAsync(tool.Definition);
var properties = tool.Definition.SettingsSchema.Properties;
this.configuredSecretFields.UnionWith(properties
.Where(property => property.Value.Secret && values.TryGetValue(property.Key, out var value) && !string.IsNullOrWhiteSpace(value))
.Select(property => new ToolField(tool.Definition.Id, property.Key)));
// A field the export writes as an empty value: it has to be present, because a
// missing one is skipped rather than exported, and it has to be a non-secret,
// because an empty secret is skipped as well.
this.emptyFieldNames = this.toolDefinition.SettingsSchema.Properties
.Where(property => !property.Value.Secret && values.TryGetValue(property.Key, out var value) && string.IsNullOrWhiteSpace(value))
.Select(property => property.Key)
.ToHashSet(StringComparer.Ordinal);
// A field the export writes as an empty value: it has to be present, because a
// missing one is skipped rather than exported, and it has to be a non-secret,
// because an empty secret is skipped as well.
this.emptyFields.UnionWith(properties
.Where(property => !property.Value.Secret && values.TryGetValue(property.Key, out var value) && string.IsNullOrWhiteSpace(value))
.Select(property => new ToolField(tool.Definition.Id, property.Key)));
}
}
catch (Exception e)
{
// A runtime error may contain secret data, so it goes to the log for diagnosis but
// never into the dialog:
this.Logger.LogError(e, "Failed to load the configuration of the tool '{ToolId}' for export.", this.ToolId);
this.toolDefinition = null;
this.Logger.LogError(e, "Failed to load the configuration of the tool collection '{CollectionId}' for export.", this.CollectionId);
this.item = null;
this.message = T("The tool configuration could not be loaded. Please close this dialog and try again.");
}
finally
@@ -142,13 +146,13 @@ public partial class ToolSettingsExportDialog : SettingsDialogBase
private string GetMinimumProviderConfidenceName()
{
var confidence = this.toolDefinition is null ? ConfidenceLevel.NONE : this.ToolRegistry.GetMinimumProviderConfidence(this.toolDefinition);
var confidence = this.item is null ? ConfidenceLevel.NONE : this.ToolRegistry.GetMinimumProviderConfidence(this.item.Id);
return confidence is ConfidenceLevel.NONE ? T("No minimum confidence level chosen") : confidence.GetName();
}
private async Task Export()
{
if (!this.CanExport || this.toolDefinition is null || this.implementation is null)
if (!this.CanExport || this.item is null)
return;
this.isExporting = true;
@@ -164,7 +168,7 @@ public partial class ToolSettingsExportDialog : SettingsDialogBase
IncludeMinimumProviderConfidence = this.includeMinimumProviderConfidence,
};
var result = await this.ToolSettingsService.ExportAsync(this.toolDefinition, this.implementation, options);
var result = await this.ToolSettingsService.ExportAsync(this.item.Tools, options, this.item.Id, this.ToolRegistry.GetMinimumProviderConfidence(this.item.Id));
if (this.isDisposed || !this.IsAdmin)
return;
@@ -187,7 +191,7 @@ public partial class ToolSettingsExportDialog : SettingsDialogBase
}
catch (Exception e)
{
this.Logger.LogError(e, "Failed to export the configuration of the tool '{ToolId}'.", this.ToolId);
this.Logger.LogError(e, "Failed to export the configuration of the tool collection '{CollectionId}'.", this.CollectionId);
this.message = T("The tool configuration could not be exported. Please try again.");
}
finally
@@ -201,4 +205,9 @@ public partial class ToolSettingsExportDialog : SettingsDialogBase
this.isDisposed = true;
base.DisposeResources();
}
/// <summary>
/// One settings field of one tool. A collection may hold two tools whose fields share a name.
/// </summary>
private readonly record struct ToolField(string ToolId, string FieldName);
}