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

336 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}