2026-11-09 20:00:01 +01:00
using System.Globalization ;
using AIStudio.Chat ;
using AIStudio.Components ;
using AIStudio.Dialogs ;
using AIStudio.Settings ;
using AIStudio.Tools.Rust ;
using AIStudio.Tools.Services ;
using Microsoft.AspNetCore.Components ;
using DialogOptions = AIStudio . Dialogs . DialogOptions ;
namespace AIStudio.Assistants.BeyondFactsLab ;
/// <summary>
/// The area "Material" beside the steps of a preparation, where the person adds documents, recordings, and images which the
/// chats of every step read, and removes them again.
/// </summary>
/// <remarks>
/// Reading a document or transcribing a recording runs in BflMaterialService, so it goes on while the person looks at
/// another step or page; this component shows the material and renders whenever the service says it changed.
/// </remarks>
public partial class BflMaterials : MSGComponentBase
{
/// <summary>
/// The kinds of files the lab reads: documents, audio or video recordings for their transcript, and the images
/// the runtime prepares for models.
/// </summary>
private static readonly FileTypeFilter [] ALLOWED_FILE_TYPES = [ FileTypes . DOCUMENT , FileTypes . AUDIO , FileTypes . VIDEO , FileTypes . MODEL_IMAGE ];
/// <summary>
/// Gets or sets the ID of the preparation whose material to show.
/// </summary>
[Parameter, EditorRequired]
public Guid ProjectId { get ; set ; }
/// <summary>
/// Gets or sets the step shown, whose prompt decides how much of the material fits.
/// </summary>
[Parameter, EditorRequired]
public BflStep Step { get ; set ; }
/// <summary>
/// Gets or sets the provider which answers in the chats of the preparation.
/// </summary>
[Parameter, EditorRequired]
public AIStudio . Settings . Provider Provider { get ; set ; } = Settings . Provider . NONE ;
/// <summary>
/// Gets or sets why the provider cannot answer, or null when it can.
/// </summary>
[Parameter]
public string? ProviderIssue { get ; set ; }
[Inject]
private BflMaterialService MaterialService { get ; init ; } = null !;
[Inject]
private BflConversationService ConversationService { get ; init ; } = null !;
[Inject]
private BeyondFactsLabStore Store { get ; init ; } = null !;
[Inject]
private PandocAvailabilityService PandocAvailability { get ; init ; } = null !;
[Inject]
private IDialogService DialogService { get ; init ; } = null !;
[Inject]
private ILogger < BflMaterials > Logger { get ; init ; } = null !;
/// <summary>
/// The documents the person just dropped or chose; they leave as soon as the lab begins to read them.
/// </summary>
private readonly HashSet < FileAttachment > attachments = [];
/// <summary>
/// The material shown.
/// </summary>
private IReadOnlyList < BflMaterialEntry > materials = [];
/// <summary>
/// How much of each material the prompt of the step shown carries, by the IDs of the materials; empty while no
/// provider can answer.
/// </summary>
private IReadOnlyDictionary < Guid , BflMaterialShare > shares = new Dictionary < Guid , BflMaterialShare >();
/// <summary>
/// The preparation whose material is shown, to notice when the parent shows another one.
/// </summary>
private Guid shownProjectId = Guid . Empty ;
/// <summary>
/// The step the shares were decided for, to decide them again for another step.
/// </summary>
private BflStep ? sharedStep ;
/// <summary>
/// The provider the shares were decided for, to decide them again for another provider.
/// </summary>
private string sharedProviderId = string . Empty ;
private static CultureInfo Culture => Tools . PluginSystem . I18N . I . Culture ;
/// <summary>
/// Gets the file the lab reads for the preparation right now, or null while it reads none.
/// </summary>
private BflMaterialInProgress ? MaterialInProgress => this . MaterialService . MaterialInProgress ( this . ProjectId );
/// <inheritdoc />
protected override async Task OnInitializedAsync ()
{
this . MaterialService . MaterialsChanged += this . OnMaterialsChangedAsync ;
await base . OnInitializedAsync ();
}
/// <inheritdoc />
protected override async Task OnParametersSetAsync ()
{
await base . OnParametersSetAsync ();
if ( this . shownProjectId != this . ProjectId )
{
this . shownProjectId = this . ProjectId ;
this . materials = [];
await this . ReloadAsync ();
}
else if ( this . sharedStep != this . Step || this . sharedProviderId != this . Provider . Id )
await this . ShareAsync ();
}
/// <inheritdoc />
protected override void DisposeResources ()
{
this . MaterialService . MaterialsChanged -= this . OnMaterialsChangedAsync ;
base . DisposeResources ();
}
/// <summary>
/// Shows the material again when the service changed it.
/// </summary>
/// <param name="projectId">The ID of the preparation whose material changed.</param>
private Task OnMaterialsChangedAsync ( Guid projectId )
{
if ( projectId != this . ProjectId )
return Task . CompletedTask ;
return this . InvokeAsync ( async () =>
{
await this . ReloadAsync ();
this . StateHasChanged ();
});
}
/// <summary>
/// Reads the list of the material of the preparation.
/// </summary>
private async Task ReloadAsync ()
{
try
{
this . materials = await this . Store . ListMaterialsAsync ( this . ProjectId );
}
catch ( Exception exception ) when ( exception is IOException or UnauthorizedAccessException )
{
this . Logger . LogError ( exception , "Could not list the material of a preparation of the BeyondFacts Lab." );
this . materials = [];
}
await this . ShareAsync ();
}
/// <summary>
/// Decides how much of each material the prompt of the step shown carries, for the provider chosen.
/// </summary>
private async Task ShareAsync ()
{
this . sharedStep = this . Step ;
this . sharedProviderId = this . Provider . Id ;
if ( this . ProviderIssue is not null || ! this . materials . Any ( entry => entry . IsAvailable ))
{
this . shares = new Dictionary < Guid , BflMaterialShare >();
return ;
}
try
{
var decided = await this . ConversationService . ShareMaterialsAsync ( new BflStepThreadLocation ( this . ProjectId , this . Step ), this . Provider );
this . shares = decided . ToDictionary ( share => share . Material . MaterialId );
}
catch ( Exception exception ) when ( exception is IOException or UnauthorizedAccessException or FormatException )
{
// Sending reports the same problem; the area only leaves out how much fits:
this . Logger . LogDebug ( exception , "Could not decide how much of the material of the BeyondFacts Lab fits." );
this . shares = new Dictionary < Guid , BflMaterialShare >();
}
}
/// <summary>
/// Gets what the person needs to know about how much of a material the model reads, or null when it reads all.
/// </summary>
/// <param name="material">The material.</param>
private string? ShareNotice ( BflMaterial material )
{
if (! this . shares . TryGetValue ( material . MaterialId , out var share ))
return null ;
switch ( share . Image )
{
case BflImageDelivery . MODEL_SEES_NO_IMAGES :
return T ( "The selected model cannot see images; it learns only the name of this one." );
case BflImageDelivery . OVER_THE_LIMIT :
return T ( "The selected model takes no more images at once; it learns only the name of this one." );
case BflImageDelivery . SENT :
return null ;
}
if ( share . IsComplete )
return null ;
// Without a known context window, the lab sends a fixed share, see BflMaterialBudget; the model may read far more:
if ( share . Characters > 0 && ! this . Provider . GetModelProfile (). Context . IsKnown )
return string . Format ( TA ( "AI Studio does not know the context window of the selected model, so the lab sends only its first {0} %. You can enter the window under 'Override Model Limits' in the settings of the provider." ), share . Percent );
return share . Characters > 0
? string . Format ( T ( "Only its first {0} % fits into the context window of the selected model." ), share . Percent )
: T ( "Nothing of it fits into the context window of the selected model; the model learns only its name." );
}
/// <summary>
/// Lets the lab read the documents and images the person just dropped or chose.
/// </summary>
/// <param name="attached">The files, as the attachment control collected them.</param>
private Task AttachedAsync ( HashSet < FileAttachment > attached )
{
var paths = attached . Where ( attachment => attachment is { IsForbidden : false }). Select ( attachment => attachment . FilePath ). ToList ();
attached . Clear ();
if ( paths . Count is 0 )
return Task . CompletedTask ;
// The service goes on reading when the person leaves the page, so nothing here waits for it:
this . MaterialService . ImportAsync ( this . ProjectId , paths , this . PandocAvailability ). Observe ( "BeyondFacts Lab: reading material" );
return Task . CompletedTask ;
}
/// <summary>
/// Lets the lab transcribe the recordings the person just dropped or chose, after asking.
/// </summary>
/// <remarks>
/// The lab asks for itself instead of the attachment control: only a transcription provider with the confidence of
/// the lab may hear the recordings, and the person learns which one that is before anything leaves the computer.
/// </remarks>
/// <param name="paths">The recordings.</param>
private async Task RecordingsAttachedAsync ( IReadOnlyList < string > paths )
{
if ( this . MaterialService . UsableTranscriptionProvider () is not { } provider )
{
2026-11-11 14:20:49 +01:00
await this . MessageBus . SendWarning ( new ( AppIcons . VOICE_TRANSCRIPTION , this . MaterialService . NoTranscriptionProviderMessage ));
2026-11-09 20:00:01 +01:00
return ;
}
var message = string . Format ( TA ( "The lab prepares these recordings on your computer and then uploads their audio to the transcription provider '{0}'. It keeps only their transcript." ), Markdown . EscapeInlineText ( provider . Name ));
var names = string . Join ( '\n' , paths . Select ( path => $"- {Markdown.EscapeInlineText(BeyondFactsLabStore.MaterialName(path))}" ));
var parameters = new DialogParameters < ConfirmDialog >
{
{
dialog => dialog . MarkdownBody ,
$"""
{message}
{names}
"""
},
};
var reference = await this . DialogService . ShowAsync < ConfirmDialog >( T ( "Transcribe recordings" ), parameters , DialogOptions . FULLSCREEN );
var result = await reference . Result ;
if ( result is null || result . Canceled )
return ;
// The service goes on transcribing when the person leaves the page, so nothing here waits for it:
this . MaterialService . ImportAsync ( this . ProjectId , paths , this . PandocAvailability ). Observe ( "BeyondFacts Lab: transcribing material" );
}
/// <summary>
/// Gets the icon of a material, which tells its kind.
/// </summary>
/// <param name="entry">The material.</param>
private static string Icon ( BflMaterialEntry entry ) => entry . Material ?. Kind switch
{
2026-11-11 14:20:49 +01:00
null => AppIcons . FILE_NOT_FOUND ,
BflMaterialKind . RECORDING => AppIcons . MICROPHONE ,
BflMaterialKind . IMAGE => AppIcons . IMAGE ,
_ => AppIcons . DOCUMENT ,
2026-11-09 20:00:01 +01:00
};
/// <summary>
/// Removes a material after asking.
/// </summary>
/// <param name="entry">The material.</param>
private async Task DeleteAsync ( BflMaterialEntry entry )
{
var parameters = new DialogParameters < ConfirmDialog >
{
{
dialog => dialog . Message ,
entry . Material is { } material
? string . Format ( TA ( "Remove '{0}' from the material of this preparation? The file on your computer stays as it is." ), material . Name )
: TA ( "AI Studio cannot open this material. Remove it with its files?" )
},
};
var reference = await this . DialogService . ShowAsync < ConfirmDialog >( T ( "Remove material" ), parameters , DialogOptions . FULLSCREEN );
var result = await reference . Result ;
if ( result is null || result . Canceled )
return ;
await this . MaterialService . DeleteAsync ( this . ProjectId , entry . MaterialId );
}
/// <summary>
/// Gets the line below the name of a material: its size, or the size of the copy of an image in pixels, and when
/// the person added it.
/// </summary>
/// <param name="material">The material.</param>
private string Caption ( BflMaterial material )
{
var addedOn = material . AddedAtUtc . ToLocalTime (). ToString ( "d" , Culture );
return material . Image is { } image
? string . Format ( Culture , T ( "Image of {0} × {1} pixels, added on {2}" ), image . Width . ToString ( "N0" , Culture ), image . Height . ToString ( "N0" , Culture ), addedOn )
: string . Format ( Culture , T ( "{0}, added on {1}" ), material . SourceBytes . FileSize (), addedOn );
}
}