2025-05-18 21:31:15 +02:00
using AIStudio.Components ;
2026-09-09 18:43:37 +02:00
using AIStudio.Provider ;
2025-01-13 19:51:26 +01:00
using AIStudio.Settings ;
using AIStudio.Settings.DataModel ;
2026-09-09 18:43:37 +02:00
using AIStudio.Tools.Services ;
2025-01-13 19:51:26 +01:00
using AIStudio.Tools.Validation ;
using Microsoft.AspNetCore.Components ;
2026-10-04 12:26:26 +02:00
using MudBlazor.Interfaces ;
using MudBlazor.Utilities ;
2025-01-13 19:51:26 +01:00
namespace AIStudio.Dialogs ;
2025-05-18 21:31:15 +02:00
public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
2025-01-13 19:51:26 +01:00
{
[CascadingParameter]
2025-03-12 19:12:56 +01:00
private IMudDialogInstance MudDialog { get ; set ; } = null !;
2025-01-13 19:51:26 +01:00
[Parameter]
public bool IsEditing { get ; set ; }
[Parameter]
public DataSourceLocalDirectory DataSource { get ; set ; }
2026-09-19 10:12:06 +02:00
[Inject]
private IDialogService DialogService { get ; init ; } = null !;
[Inject]
private DataSourceEmbeddingService DataSourceEmbeddingService { get ; init ; } = null !;
2026-10-18 12:06:15 +02:00
[Inject]
private MediaTranscriptionService MediaTranscriptionService { get ; init ; } = null !;
2025-01-13 19:51:26 +01:00
private static readonly Dictionary < string , object? > SPELLCHECK_ATTRIBUTES = new ();
private readonly DataSourceValidation dataSourceValidation ;
/// <summary>
/// The list of used data source names. We need this to check for uniqueness.
/// </summary>
private List < string > UsedDataSourcesNames { get ; set ; } = [];
private bool dataIsValid ;
private string [] dataIssues = [];
private string dataEditingPreviousInstanceName = string . Empty ;
private uint dataNum ;
private string dataId = Guid . NewGuid (). ToString ();
private string dataName = string . Empty ;
2025-12-08 20:53:46 +01:00
private string dataDescription = string . Empty ;
2026-10-18 12:06:15 +02:00
private CloudProcessing acknowledgedCloudProcessing ;
2025-01-13 19:51:26 +01:00
private string dataEmbeddingId = string . Empty ;
private string dataPath = string . Empty ;
2026-10-17 19:12:43 +02:00
private string dataFilePatterns = DataSourceLocalDirectory . DEFAULT_FILE_PATTERNS ;
private string dataExcludePatterns = FileExclusions . DEFAULT_PATTERNS ;
private int dataMaxFileSizeMegabytes = FolderFileSelection . DEFAULT_MAX_FILE_SIZE_MEGABYTES ;
private bool dataIncludeSubdirectories = DataSourceLocalDirectory . DEFAULT_INCLUDE_SUBDIRECTORIES ;
private bool dataIncludeHiddenFilesAndFolders = DataSourceLocalDirectory . DEFAULT_INCLUDE_HIDDEN_FILES_AND_FOLDERS ;
2026-09-09 18:43:37 +02:00
private int dataMaxChunkTokenLength ;
private int dataChunkOverlapTokenLength = DataSourceEmbeddingService . DEFAULT_CHUNK_OVERLAP_TOKEN_LENGTH ;
2025-05-26 19:53:31 +02:00
private ushort dataMaxMatches = 10 ;
2026-09-09 18:43:37 +02:00
private bool showExpertSettings ;
2026-10-04 12:26:26 +02:00
private bool revalidateAfterRender ;
2026-09-09 18:43:37 +02:00
private ConfidenceLevel dataConfidenceLevel = ConfidenceLevel . UNKNOWN ;
2026-10-23 09:27:22 +02:00
private OutboundDataRestriction dataOutboundDataRestriction = DataSourceOutboundDataExtensions . DEFAULT_OUTBOUND_DATA_RESTRICTION ;
2025-01-13 19:51:26 +01:00
// We get the form reference from Blazor code to validate it manually:
private MudForm form = null !;
2026-10-04 12:26:26 +02:00
// 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 !;
2025-01-13 19:51:26 +01:00
public DataSourceLocalDirectoryDialog ()
{
this . dataSourceValidation = new ()
{
GetSelectedCloudEmbedding = () => this . SelectedCloudEmbedding ,
2026-10-18 12:06:15 +02:00
GetSelectedCloudTranscription = () => this . SelectedCloudTranscription ,
2026-09-09 18:43:37 +02:00
GetSelectedEmbeddingProvider = () => this . SelectedEmbedding ,
GetConfidenceLevel = () => this . dataConfidenceLevel ,
GetSettingsManager = () => this . SettingsManager ,
2025-01-13 19:51:26 +01:00
GetPreviousDataSourceName = () => this . dataEditingPreviousInstanceName ,
GetUsedDataSourceNames = () => this . UsedDataSourcesNames ,
};
}
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync ()
{
// Configure the spellchecking for the instance name input:
this . SettingsManager . InjectSpellchecking ( SPELLCHECK_ATTRIBUTES );
2026-10-04 12:26:26 +02:00
// 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 ();
2025-01-13 19:51:26 +01:00
// 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 ;
2025-12-08 20:53:46 +01:00
this . dataDescription = this . DataSource . Description ;
2025-01-13 19:51:26 +01:00
this . dataEmbeddingId = this . DataSource . EmbeddingId ;
this . dataPath = this . DataSource . Path ;
2026-10-17 19:12:43 +02:00
this . dataFilePatterns = this . DataSource . EffectiveFilePatterns ;
this . dataExcludePatterns = this . DataSource . EffectiveExcludePatterns ;
this . dataMaxFileSizeMegabytes = this . DataSource . EffectiveMaxFileSizeMegabytes ;
this . dataIncludeSubdirectories = this . DataSource . IncludeSubdirectories ;
this . dataIncludeHiddenFilesAndFolders = this . DataSource . IncludeHiddenFilesAndFolders ;
2026-09-09 18:43:37 +02:00
this . dataMaxChunkTokenLength = this . DataSource . MaxChunkTokenLength ;
this . dataChunkOverlapTokenLength = this . DataSource . ChunkOverlapTokenLength ;
this . dataConfidenceLevel = this . DataSource . ConfidenceLevel ;
2026-10-23 09:27:22 +02:00
this . dataOutboundDataRestriction = this . DataSource . OutboundDataRestriction ;
2025-05-26 19:53:31 +02:00
this . dataMaxMatches = this . DataSource . MaxMatches ;
2025-01-13 19:51:26 +01:00
}
await base . OnInitializedAsync ();
2026-10-18 16:50:21 +02:00
// The embedding providers may change while the dialog is open, e.g., through a configuration plugin:
this . ApplyFilters ([], [ Event . CONFIGURATION_CHANGED ]);
2025-01-13 19:51:26 +01:00
}
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 ();
2026-10-04 12:26:26 +02:00
// 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 );
}
2025-01-13 19:51:26 +01:00
await base . OnAfterRenderAsync ( firstRender );
}
#endregion
2026-08-25 12:46:10 +02:00
2026-10-18 16:50:21 +02:00
#region Overrides of MSGComponentBase
protected override Task ProcessIncomingMessage < T >( ComponentBase ? sendingComponent , Event triggeredEvent , T ? data ) where T : default
2026-08-25 12:46:10 +02:00
{
2026-10-18 16:50:21 +02:00
switch ( triggeredEvent )
{
case Event . CONFIGURATION_CHANGED :
case Event . PLUGINS_RELOADED :
// The embedding providers are listed anew, and the fields checked so far get a fresh verdict once they hold the new values:
this . revalidateAfterRender = true ;
this . StateHasChanged ();
break ;
}
return base . ProcessIncomingMessage ( sendingComponent , triggeredEvent , data );
2026-08-25 12:46:10 +02:00
}
2026-10-18 16:50:21 +02:00
#endregion
2026-09-09 18:43:37 +02:00
private EmbeddingProvider ? SelectedEmbedding => this . SettingsManager . ConfigurationData . EmbeddingProviders
. FirstOrDefault ( x => x . Id == this . dataEmbeddingId );
private bool SelectedCloudEmbedding => this . SelectedEmbedding is { IsSelfHosted : false };
2026-10-18 12:06:15 +02:00
/// <summary>
/// Whether the file patterns may select audio or video files, which the data source transcribes.
/// </summary>
private bool SelectsMedia => FileNamePatterns . MaySelectMedia ( FileNamePatterns . Parse ( this . dataFilePatterns ));
/// <summary>
/// The transcription provider which may hear the audio and video files of this data source, or null.
/// </summary>
private TranscriptionProvider ? AllowedTranscriptionProvider => this . MediaTranscriptionService . GetUsableProvider ( this . CreateDataSource ());
private bool SelectedCloudTranscription => this . SelectsMedia && this . AllowedTranscriptionProvider is { IsSelfHosted : false };
private string CloudTranscriptionProviderName => this . SelectedCloudTranscription ? this . AllowedTranscriptionProvider ?. Name ?? string . Empty : string . Empty ;
private CloudProcessing CurrentCloudProcessing => ( this . SelectedCloudEmbedding ? CloudProcessing . EMBEDDING : CloudProcessing . NONE ) | ( this . SelectedCloudTranscription ? CloudProcessing . TRANSCRIPTION : CloudProcessing . NONE );
/// <remarks>
/// The acknowledgment holds for the providers it was given for, cf. CloudProcessingExtensions.Covers.
/// </remarks>
private bool UserAcknowledgedCloudProcessing
{
get => this . acknowledgedCloudProcessing . Covers ( this . CurrentCloudProcessing );
set => this . acknowledgedCloudProcessing = value ? this . CurrentCloudProcessing : CloudProcessing . NONE ;
}
/// <summary>
/// Why audio and video files which the patterns select would not be indexed, or null when they would.
/// </summary>
private string? MediaTranscriptionHint => ! this . SelectsMedia
? null
: this . MediaTranscriptionService . GetUsableProvider () is null
? T ( "Audio and video files which this data source selects are skipped until you choose a transcription provider in the settings." )
: this . AllowedTranscriptionProvider is null
? T ( "Audio and video files which this data source selects are skipped, because your transcription provider does not meet the confidence level this data source requires." )
: null ;
2026-09-09 18:43:37 +02:00
private IEnumerable < ConfigurationSelectData < ConfidenceLevel >> ConfidenceLevels => ConfigurationSelectDataFactory . GetDataSourceConfidenceLevelsData ();
2026-10-23 09:27:22 +02:00
private OutboundDataRestriction OrganizationMinimumOutboundDataRestriction => this . SettingsManager . ConfigurationData . DataSourceSecurity . MinimumOutboundDataRestriction ;
/// <summary>
/// The level the data source gets: the one chosen, as far as the organization allows it.
/// </summary>
/// <remarks>
/// The organization may rule out a level while the dialog is open, or after the data source was
/// stored with it. The choice itself stays, so the dialog shows it again once the organization
/// allows it again.
/// </remarks>
private OutboundDataRestriction SelectedOutboundDataRestriction
{
get => this . dataOutboundDataRestriction . StricterOf ( this . OrganizationMinimumOutboundDataRestriction );
set => this . dataOutboundDataRestriction = value ;
}
2026-09-09 18:43:37 +02:00
private string SelectedEmbeddingTokenizerText => this . SelectedEmbedding is null
? T ( "No embedding selected" )
: string . IsNullOrWhiteSpace ( this . SelectedEmbedding . TokenizerPath )
? T ( "Default tokenizer" )
: Path . GetFileName ( this . SelectedEmbedding . TokenizerPath );
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 );
2025-01-13 19:51:26 +01:00
private DataSourceLocalDirectory CreateDataSource () => new ()
{
Id = this . dataId ,
Num = this . dataNum ,
Name = this . dataName ,
2025-12-08 20:53:46 +01:00
Description = this . dataDescription ,
2025-01-13 19:51:26 +01:00
Type = DataSourceType . LOCAL_DIRECTORY ,
2026-09-19 10:12:06 +02:00
EmbeddingId = this . dataEmbeddingId ,
2026-10-20 18:50:09 +02:00
Path = this . dataPath ,
2026-10-17 19:12:43 +02:00
FilePatterns = this . NormalizeFilePatterns (),
// Kept as typed, since the line breaks group the entries for the eye:
ExcludePatterns = this . dataExcludePatterns ,
MaxFileSizeMegabytes = this . dataMaxFileSizeMegabytes ,
IncludeSubdirectories = this . dataIncludeSubdirectories ,
IncludeHiddenFilesAndFolders = this . dataIncludeHiddenFilesAndFolders ,
2026-09-09 18:43:37 +02:00
MaxChunkTokenLength = this . dataMaxChunkTokenLength ,
ChunkOverlapTokenLength = this . dataChunkOverlapTokenLength ,
ConfidenceLevel = this . dataConfidenceLevel ,
2026-10-23 09:27:22 +02:00
OutboundDataRestriction = this . SelectedOutboundDataRestriction ,
2025-05-26 19:53:31 +02:00
MaxMatches = this . dataMaxMatches ,
2025-01-13 19:51:26 +01:00
};
private async Task Store ()
{
await this . form . Validate ();
// When the data is not valid, we don't store it:
if (! this . dataIsValid )
return ;
2026-09-19 10:12:06 +02:00
2026-10-20 18:50:09 +02:00
await this . StoreValidatedDataSourceAsync ();
}
/// <summary>
/// Continues Save after form validation, asking before the draft is returned to its caller.
/// </summary>
internal async Task StoreValidatedDataSourceAsync ()
{
2025-01-13 19:51:26 +01:00
var addedDataSource = this . CreateDataSource ();
2026-09-19 10:12:06 +02:00
//
// Ask while the dialog is still open, so a token limit which would have cost the prepared
// documents can be corrected right away. Asking in DataSourceManagement instead would have
// to be written once per data source kind, and by then the numbers are out of reach.
//
// Only when editing: while adding, DataSource is still default -- both local data sources
// are record structs -- and nothing has been prepared for a source which does not exist yet.
//
if ( this . IsEditing && ! await DataSourceReindexWarning . ConfirmDataSourceChangeAsync ( this . DialogService , this . SettingsManager , this . DataSourceEmbeddingService , this . DataSource , addedDataSource ))
return ;
2025-01-13 19:51:26 +01:00
this . MudDialog . Close ( DialogResult . Ok ( addedDataSource ));
}
private void Cancel () => this . MudDialog . Cancel ();
2026-09-09 18:43:37 +02:00
2026-10-17 19:12:43 +02:00
private string NormalizeFilePatterns () => FileNamePatterns . Normalize ( this . dataFilePatterns );
private void RestoreDefaultExcludePatterns () => this . dataExcludePatterns = FileExclusions . DEFAULT_PATTERNS ;
2026-10-04 12:26:26 +02:00
/// <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 );
2026-09-09 18:43:37 +02:00
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 . showExpertSettings && 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 ;
2026-10-04 12:26:26 +02:00
// 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 ;
2026-09-09 18:43:37 +02:00
}
2026-10-18 16:50:21 +02:00
/// <summary>
/// Selects the embedding provider the user just added from within this dialog, so that what they
/// entered so far can be saved right away.
/// </summary>
/// <remarks>
/// Only when no existing provider is selected, which includes the provider of a data source that
/// was deleted since. Like the token limit in ToggleExpertSettings, the field learns its new value
/// only with the next render, so it is checked after that.
/// </remarks>
private void SelectAddedEmbeddingProvider ( EmbeddingProvider provider )
{
if ( this . SelectedEmbedding is null )
this . dataEmbeddingId = provider . Id ;
this . revalidateAfterRender = true ;
}
2026-09-09 18:43:37 +02:00
private string GetExpertStyles => this . showExpertSettings ? "border-2 border-dashed rounded pa-2" : string . Empty ;
2026-08-25 12:46:10 +02:00
}