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; /// /// 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. /// /// /// 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. /// public partial class BflMaterials : MSGComponentBase { /// /// The kinds of files the lab reads: documents, audio or video recordings for their transcript, and the images /// the runtime prepares for models. /// private static readonly FileTypeFilter[] ALLOWED_FILE_TYPES = [FileTypes.DOCUMENT, FileTypes.AUDIO, FileTypes.VIDEO, FileTypes.MODEL_IMAGE]; /// /// Gets or sets the ID of the preparation whose material to show. /// [Parameter, EditorRequired] public Guid ProjectId { get; set; } /// /// Gets or sets the step shown, whose prompt decides how much of the material fits. /// [Parameter, EditorRequired] public BflStep Step { get; set; } /// /// Gets or sets the provider which answers in the chats of the preparation. /// [Parameter, EditorRequired] public AIStudio.Settings.Provider Provider { get; set; } = Settings.Provider.NONE; /// /// Gets or sets why the provider cannot answer, or null when it can. /// [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 Logger { get; init; } = null!; /// /// The documents the person just dropped or chose; they leave as soon as the lab begins to read them. /// private readonly HashSet attachments = []; /// /// The material shown. /// private IReadOnlyList materials = []; /// /// How much of each material the prompt of the step shown carries, by the IDs of the materials; empty while no /// provider can answer. /// private IReadOnlyDictionary shares = new Dictionary(); /// /// The preparation whose material is shown, to notice when the parent shows another one. /// private Guid shownProjectId = Guid.Empty; /// /// The step the shares were decided for, to decide them again for another step. /// private BflStep? sharedStep; /// /// The provider the shares were decided for, to decide them again for another provider. /// private string sharedProviderId = string.Empty; private static CultureInfo Culture => Tools.PluginSystem.I18N.I.Culture; /// /// Gets the file the lab reads for the preparation right now, or null while it reads none. /// private BflMaterialInProgress? MaterialInProgress => this.MaterialService.MaterialInProgress(this.ProjectId); /// protected override async Task OnInitializedAsync() { this.MaterialService.MaterialsChanged += this.OnMaterialsChangedAsync; await base.OnInitializedAsync(); } /// 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(); } /// protected override void DisposeResources() { this.MaterialService.MaterialsChanged -= this.OnMaterialsChangedAsync; base.DisposeResources(); } /// /// Shows the material again when the service changed it. /// /// The ID of the preparation whose material changed. private Task OnMaterialsChangedAsync(Guid projectId) { if (projectId != this.ProjectId) return Task.CompletedTask; return this.InvokeAsync(async () => { await this.ReloadAsync(); this.StateHasChanged(); }); } /// /// Reads the list of the material of the preparation. /// 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(); } /// /// Decides how much of each material the prompt of the step shown carries, for the provider chosen. /// 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(); 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(); } } /// /// Gets what the person needs to know about how much of a material the model reads, or null when it reads all. /// /// The material. 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."); } /// /// Lets the lab read the documents and images the person just dropped or chose. /// /// The files, as the attachment control collected them. private Task AttachedAsync(HashSet 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; } /// /// Lets the lab transcribe the recordings the person just dropped or chose, after asking. /// /// /// 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. /// /// The recordings. private async Task RecordingsAttachedAsync(IReadOnlyList 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 { { dialog => dialog.MarkdownBody, $""" {message} {names} """ }, }; var reference = await this.DialogService.ShowAsync(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"); } /// /// Gets the icon of a material, which tells its kind. /// /// The material. 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, }; /// /// Removes a material after asking. /// /// The material. private async Task DeleteAsync(BflMaterialEntry entry) { var parameters = new DialogParameters { { 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(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); } /// /// 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. /// /// The material. 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); } }