Files
AI-Studio/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflMaterials.razor.cs
T

336 lines
13 KiB
C#
Raw Normal View History

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)
{
await this.MessageBus.SendWarning(new(Icons.Material.Filled.VoiceChat, this.MaterialService.NoTranscriptionProviderMessage));
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
{
null => Icons.Material.Filled.ErrorOutline,
BflMaterialKind.RECORDING => Icons.Material.Filled.Mic,
BflMaterialKind.IMAGE => Icons.Material.Filled.Image,
_ => Icons.Material.Filled.Description,
};
/// <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);
}
}