using System.Globalization; using AIStudio.Chat; using AIStudio.Components; using AIStudio.Dialogs; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; using DialogOptions = AIStudio.Dialogs.DialogOptions; namespace AIStudio.Assistants.BeyondFactsLab; /// /// The results of a preparation in the last step of the BeyondFacts Lab: what the person takes away, each with its /// draft, its confirmed version, and its export. /// /// /// The component loads and stores the results itself; the page keeps the work states, which tell whether a result is /// outdated. The model writes in BflResultGenerator, so that the work goes on while the person looks elsewhere; the /// component reads the results again whenever the generator says one changed. Only a confirmed result can be exported. /// public partial class BflResults : MSGComponentBase { [Inject] private BeyondFactsLabStore Store { get; init; } = null!; [Inject] private BflResultGenerator Generator { get; init; } = null!; [Inject] private RustService RustService { get; init; } = null!; [Inject] private PandocAvailabilityService PandocAvailability { get; init; } = null!; [Inject] private AIGeneratedContentDisclosureService DisclosureService { get; init; } = null!; [Inject] private IDialogService DialogService { get; init; } = null!; [Inject] private ILogger Logger { get; init; } = null!; /// /// Gets or sets the ID of the preparation whose results the component shows. /// [Parameter, EditorRequired] public Guid ProjectId { get; set; } /// /// Gets or sets the name of the preparation, for the reflection folder and the names of exported files. /// [Parameter, EditorRequired] public string ProjectName { get; set; } = string.Empty; /// /// Gets or sets the work states of the preparation, by their steps. /// [Parameter, EditorRequired] public IReadOnlyDictionary WorkStates { get; set; } = new Dictionary(); /// /// Gets or sets the forms of the preparation, by their steps, which the reflection folder needs to tell where a /// work state stands. /// [Parameter, EditorRequired] public IReadOnlyDictionary Forms { get; set; } = new Dictionary(); /// /// Gets or sets the callback raised after a result was stored, with the preparation and its new time of change. /// [Parameter] public EventCallback ResultStored { get; set; } /// /// Gets or sets the provider which writes the results. /// [Parameter, EditorRequired] public AIStudio.Settings.Provider Provider { get; set; } = Settings.Provider.NONE; /// /// Gets or sets why the provider cannot write, or null when it can. /// [Parameter] public string? ProviderIssue { get; set; } private static CultureInfo Culture => Tools.PluginSystem.I18N.I.Culture; /// /// The results this version can open, by their kinds. /// private Dictionary results = []; /// /// The preparation whose results were loaded, to load again when the page shows another one. /// private Guid loadedProjectId; /// /// The result whose panel is open, or null while all are closed. Only one is open at a time. /// private BflResultKind? expandedKind = BflResultKind.All[0]; /// /// The results whose draft the person edits as text instead of reading its preview. /// private readonly HashSet editing = []; /// /// The results the component stores or puts together right now. /// private readonly HashSet saving = []; /// /// Whether the reflection folder takes up personal disclosures. /// private bool includePersonalDisclosures; /// /// Whether the reflection folder takes up the conversations of the steps as an appendix. /// private bool includeConversations; /// /// Gets the steps without a confirmed work state, in their order. /// private IReadOnlyList MissingSteps => [.. BflStep.All.Where(step => this.WorkStates.GetValueOrDefault(step)?.Confirmed is null)]; /// protected override async Task OnInitializedAsync() { this.Generator.ResultChanged += this.OnResultChangedAsync; await base.OnInitializedAsync(); } /// protected override async Task OnParametersSetAsync() { await base.OnParametersSetAsync(); if (this.loadedProjectId == this.ProjectId) return; this.loadedProjectId = this.ProjectId; this.editing.Clear(); this.results = []; await this.LoadAsync(); } /// protected override void DisposeResources() { this.Generator.ResultChanged -= this.OnResultChangedAsync; base.DisposeResources(); } private bool IsGenerating(BflResultKind kind) => this.Generator.IsRunning(new BflResultLocation(this.ProjectId, kind)); private bool IsBusy(BflResultKind kind) => this.saving.Contains(kind) || this.IsGenerating(kind); /// /// Gets the results which "Create all materials in parallel" creates: every one which can be opened and is idle. /// private IReadOnlyList CreatableKinds => [.. BflResultKind.All.Where(kind => this.results.ContainsKey(kind) && !this.IsBusy(kind))]; private bool CanCreateAll => this.ProviderIssue is null && this.CreatableKinds.Count > 0; private bool AnyCreated => this.results.Values.Any(result => result.Draft is not null || result.Confirmed is not null); /// /// Tells where a result stands, for the header of its panel. /// /// The kind of result. private BflResultStatus StatusOf(BflResultKind kind) { if (this.IsGenerating(kind)) return BflResultStatus.WRITING; return this.results.GetValueOrDefault(kind) switch { null => BflResultStatus.UNREADABLE, { Draft: not null } => BflResultStatus.DRAFT, { Confirmed: { } confirmed } => BflStaleness.IsOutdated(confirmed, this.WorkStates) ? BflResultStatus.OUTDATED : BflResultStatus.CONFIRMED, _ => BflResultStatus.NOT_CREATED }; } private static string StatusIcon(BflResultStatus status) => status switch { BflResultStatus.UNREADABLE => Icons.Material.Filled.Error, BflResultStatus.WRITING => Icons.Material.Filled.HourglassTop, BflResultStatus.DRAFT => Icons.Material.Filled.EditNote, BflResultStatus.CONFIRMED => Icons.Material.Filled.CheckCircle, BflResultStatus.OUTDATED => Icons.Material.Filled.Update, _ => Icons.Material.Filled.RadioButtonUnchecked }; private static Color StatusColor(BflResultStatus status) => status switch { BflResultStatus.UNREADABLE => Color.Error, BflResultStatus.WRITING => Color.Primary, BflResultStatus.DRAFT => Color.Info, BflResultStatus.CONFIRMED => Color.Success, BflResultStatus.OUTDATED => Color.Warning, _ => Color.Default }; private string StatusText(BflResultStatus status) => status switch { BflResultStatus.UNREADABLE => T("Cannot be opened"), BflResultStatus.WRITING => T("Being written"), BflResultStatus.DRAFT => T("Draft"), BflResultStatus.CONFIRMED => T("Confirmed"), BflResultStatus.OUTDATED => T("Outdated"), _ => T("Not created yet") }; /// /// Opens the panel of a result and closes the others, or closes it. /// /// The kind of result. /// Whether the panel is open now. private void SetExpanded(BflResultKind kind, bool expanded) { if (expanded) this.expandedKind = kind; else if (this.expandedKind == kind) this.expandedKind = null; } /// /// Reads the results of the preparation again once the generator started, ended, or stored a draft. /// /// The result which changed. private Task OnResultChangedAsync(BflResultLocation location) { if (location.ProjectId != this.ProjectId) return Task.CompletedTask; return this.InvokeAsync(async () => { await this.LoadAsync(); this.StateHasChanged(); }); } /// /// Reads the results of the preparation shown. /// private async Task LoadAsync() { var projectId = this.ProjectId; var loaded = await this.Store.LoadResultsAsync(projectId); // The page may have shown another preparation meanwhile: if (projectId == this.ProjectId) this.results = new(loaded); } /// /// Lets the model write a result, or puts the reflection folder together. /// /// The kind of result. private async Task CreateAsync(BflResultKind kind) { this.editing.Remove(kind); if (kind.IsWrittenByModel) { if (this.ProviderIssue is null) await this.Generator.GenerateAsync(new BflResultLocation(this.ProjectId, kind), this.Provider); return; } this.saving.Add(kind); try { await this.Generator.AssembleReflectionFolderAsync(this.ProjectId, this.ProjectName, this.Forms, this.includePersonalDisclosures, this.includeConversations); } finally { this.saving.Remove(kind); } } /// /// Creates every idle result at once: the model writes the four of them in parallel, while AI Studio puts the /// reflection folder together with the choices of its panel. /// /// /// Like "Create anew", a new draft replaces an earlier one, while a confirmed version stays until the person /// confirms the new draft. Since a draft may hold edits of the person, the component asks first when there is one. /// private async Task CreateAllAsync() { var kinds = this.CreatableKinds; if (this.ProviderIssue is not null || kinds.Count is 0) return; if (kinds.Any(kind => this.results[kind].Draft is not null)) { var parameters = new DialogParameters { { dialog => dialog.Message, TA("Replace the drafts you have not confirmed yet? Materials you confirmed stay until you confirm their new drafts.") } }; var reference = await this.DialogService.ShowAsync(T("Create all materials in parallel"), parameters, DialogOptions.FULLSCREEN); var answer = await reference.Result; if (answer is null || answer.Canceled) return; } await Task.WhenAll(kinds.Select(this.CreateAsync)); } private Task CancelAsync(BflResultKind kind) => this.Generator.CancelAsync(new BflResultLocation(this.ProjectId, kind)); private void StartEditing(BflResultKind kind) => this.editing.Add(kind); private void ShowPreview(BflResultKind kind) => this.editing.Remove(kind); private async Task SetDraftAsync(BflResultKind kind, string? text) { if (this.results.GetValueOrDefault(kind) is not { Draft: { } draft } result) return; draft.Text = text ?? string.Empty; await this.SaveAsync(kind, result); } private async Task ConfirmAsync(BflResultKind kind) { if (this.results.GetValueOrDefault(kind) is not { Draft: { } draft } result) return; result.Confirm(draft.Text, DateTimeOffset.UtcNow); this.editing.Remove(kind); await this.SaveAsync(kind, result); } private async Task DiscardDraftAsync(BflResultKind kind) { if (this.results.GetValueOrDefault(kind) is not { Draft: not null } result) return; result.Draft = null; this.editing.Remove(kind); await this.SaveAsync(kind, result); } private async Task EditAsync(BflResultKind kind) { if (this.results.GetValueOrDefault(kind) is not { Confirmed: not null } result) return; result.Edit(); this.editing.Add(kind); await this.SaveAsync(kind, result); } /// /// Exports the confirmed version of a result, with the notice that AI generated it when the person or the /// organization switched that on. /// /// /// The format knows who writes it, as in the chat: Pandoc writes documents and HTML, AI Studio itself writes /// Markdown. /// /// The kind of result. /// The confirmed version. /// The format of the file. private async Task ExportAsync(BflResultKind kind, BflResultVersion confirmed, FileExportFormat format) { var fileName = $"{this.ProjectName} - {kind.Title}"; this.saving.Add(kind); try { if (format.UsesPandoc) await PandocExport.ToDocument(this.RustService, this.PandocAvailability, this.DisclosureService, kind.Title, format, new ContentText { Text = confirmed.Text }, fileName); else await PlainFileExport.ToFile(this.RustService, kind.Title, format, this.DisclosureService.AddDisclosureToMarkdown(confirmed.Text), fileName); } catch (ArgumentOutOfRangeException exception) { this.Logger.LogError(exception, "Could not export a result of the BeyondFacts Lab, because no exporter writes the format {ExportFormat}.", format); await this.MessageBus.SendError(new(Icons.Material.Filled.Error, TA("AI Studio could not export this material. Please try another format."))); } finally { this.saving.Remove(kind); } } private async Task SaveAsync(BflResultKind kind, BflResult result) { this.saving.Add(kind); try { var project = await this.Store.SaveResultAsync(this.ProjectId, kind, result); await this.ResultStored.InvokeAsync(project); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException) { this.Logger.LogError(exception, "Could not save the result '{Kind}' of a preparation in the BeyondFacts Lab.", kind); await this.MessageBus.SendError(new(Icons.Material.Filled.SaveAs, TA("AI Studio could not save this material. Please try again."))); } finally { this.saving.Remove(kind); } } }