using AIStudio.Components; using Microsoft.AspNetCore.Components; namespace AIStudio.Assistants.VisualBriefing; /// /// Renders the staged progress, durations, and failures of one visual briefing build. /// /// /// The component derives everything it shows from alone. It also owns the timer /// that keeps the duration of a running stage current, so a build in progress re-renders this panel /// once per second instead of the entire assistant page. /// public partial class VisualBriefingBuildProgress : MSGComponentBase { /// /// Gets or sets the build whose progress is displayed. /// [Parameter, EditorRequired] public VisualBriefingBuildRecord? Build { get; set; } /// /// Gets or sets whether the resume action is blocked because other work is running. /// [Parameter] public bool Disabled { get; set; } /// /// Gets or sets the callback raised when the user resumes a failed or canceled build. /// [Parameter] public EventCallback OnResume { get; set; } /// /// The six UI groups covering the eight durable build stages. /// private static readonly VisualBriefingBuildStage[][] STAGE_GROUPS = [ [VisualBriefingBuildStage.SOURCE_PREPARATION], [VisualBriefingBuildStage.EVIDENCE], [VisualBriefingBuildStage.PLAN], [VisualBriefingBuildStage.CONTENT], [VisualBriefingBuildStage.DESIGN], [VisualBriefingBuildStage.COMPILATION, VisualBriefingBuildStage.ASSEMBLY, VisualBriefingBuildStage.COMMIT], ]; /// Stops the live build-duration monitor. private readonly CancellationTokenSource durationMonitorCancellation = new(); /// Stores the shared timestamp used to render consistent live build durations. private DateTimeOffset durationReferenceUtc = DateTimeOffset.UtcNow; #region Overrides of ComponentBase protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); _ = this.MonitorBuildDurationAsync(this.durationMonitorCancellation.Token); } protected override void OnParametersSet() { // The parent re-renders us whenever it received a progress update, so this is the moment the // durations of running stages must be measured against again. this.durationReferenceUtc = DateTimeOffset.UtcNow; } #endregion #region Overrides of MSGComponentBase protected override void DisposeResources() { this.durationMonitorCancellation.Cancel(); this.durationMonitorCancellation.Dispose(); base.DisposeResources(); } #endregion /// /// Refreshes live build durations at most once per second while a stage is running. /// /// The token that stops the monitor. /// A task that completes once the monitor was stopped. private async Task MonitorBuildDurationAsync(CancellationToken token) { using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); try { while (await timer.WaitForNextTickAsync(token)) { // This panel stays on screen for as long as the briefing has any build, so most of the // time there is no running stage and nothing to refresh. The check happens here rather // than inside the callback below, because otherwise every second would still cost a hop // onto the renderer just to find that out. Reading the build here is safe: the progress // service publishes snapshots, so this record is never the one the build mutates. if (this.Build?.Stages.Any(stage => stage.Status is VisualBriefingBuildStageStatus.RUNNING) != true) continue; await this.InvokeAsync(() => { this.durationReferenceUtc = DateTimeOffset.UtcNow; this.StateHasChanged(); }); } } catch (OperationCanceledException) when (token.IsCancellationRequested) { } } /// /// Gets the localized title of one build step. /// /// The zero-based index of the step. /// The localized step title. private string StepTitle(int index) => index switch { 0 => T("Prepare sources"), 1 => T("Analyze material"), 2 => T("Plan briefing"), 3 => T("Curate content"), 4 => T("Design presentation"), _ => T("Compile and save"), }; /// Gets the active build stepper index. private int BuildStepperIndex { get { for (var index = 0; index < STAGE_GROUPS.Length; index++) { var statuses = STAGE_GROUPS[index].Select(this.StageStatus).ToArray(); if (statuses.Any(status => status is VisualBriefingBuildStageStatus.RUNNING or VisualBriefingBuildStageStatus.FAILED or VisualBriefingBuildStageStatus.CANCELED)) return index; if (statuses.Any(status => status is VisualBriefingBuildStageStatus.NOT_STARTED)) return index; } return STAGE_GROUPS.Length - 1; } } /// /// Gets the localized collapsed build-progress summary. /// private string BuildProgressTitle { get { if(this.Build is null) return $"{T("Build progress")} · {T("Running")}"; var title = this.Build.Status switch { VisualBriefingBuildStatus.COMPLETED => $"{T("Build progress")} · {T("Completed")}", VisualBriefingBuildStatus.FAILED => $"{T("Build progress")} · {T("Failed")}", VisualBriefingBuildStatus.CANCELED => $"{T("Build progress")} · {T("Canceled")}", VisualBriefingBuildStatus.AWAITING_REBUILD => $"{T("Build progress")} · {T("Action required")}", _ => $"{T("Build progress")} · {T("Running")}", }; var duration = this.CalculateBuildDuration(this.Build.Stages); return duration > TimeSpan.Zero ? $"{title} · {FormatBuildDuration(duration)}" : title; } } /// /// Gets a persistent stage status, defaulting to not started. /// /// The stage to look up. /// The stage status. private VisualBriefingBuildStageStatus StageStatus(VisualBriefingBuildStage stage) => this.Build?.Stages.FirstOrDefault(item => item.Stage == stage)?.Status ?? VisualBriefingBuildStageStatus.NOT_STARTED; /// /// Gets whether one UI group completed or was reused. /// /// The zero-based index of the group. /// true when the group finished. private bool BuildGroupCompleted(int index) => STAGE_GROUPS[index].All(stage => this.StageStatus(stage) is VisualBriefingBuildStageStatus.COMPLETED or VisualBriefingBuildStageStatus.SKIPPED); /// /// Gets whether one UI group failed. /// /// The zero-based index of the group. /// true when the group failed. private bool BuildGroupFailed(int index) => STAGE_GROUPS[index].Any(stage => this.StageStatus(stage) is VisualBriefingBuildStageStatus.FAILED); /// /// Gets whether one UI group was canceled. /// /// The zero-based index of the group. /// true when the group was canceled. private bool BuildGroupCanceled(int index) => STAGE_GROUPS[index].Any(stage => this.StageStatus(stage) is VisualBriefingBuildStageStatus.CANCELED); /// /// Gets whether one UI group stopped with a failure or cancellation. /// /// The zero-based index of the group. /// true when the group stopped. private bool BuildGroupStopped(int index) => this.BuildGroupFailed(index) || this.BuildGroupCanceled(index); /// /// Gets whether one UI group is active. /// /// The zero-based index of the group. /// true when the group is running. private bool BuildGroupRunning(int index) => STAGE_GROUPS[index].Any(stage => this.StageStatus(stage) is VisualBriefingBuildStageStatus.RUNNING); /// /// Formats a safe localized status summary and duration. /// /// The zero-based index of the group. /// The localized summary. private string BuildGroupSummary(int index) { if(this.Build is null) return T("Not started"); var records = STAGE_GROUPS[index] .Select(stage => this.Build.Stages.FirstOrDefault(item => item.Stage == stage)) .Where(record => record is not null) .Cast() .ToArray(); var status = this.BuildGroupRunning(index) ? T("Running") : this.BuildGroupFailed(index) ? T("Failed") : this.BuildGroupCanceled(index) ? T("Canceled") : records.Length > 0 && records.All(record => record.Status is VisualBriefingBuildStageStatus.SKIPPED) ? T("Reused") : this.BuildGroupCompleted(index) ? T("Completed") : T("Not started"); var duration = this.CalculateBuildDuration(records); return duration > TimeSpan.Zero ? $"{status} · {FormatBuildDuration(duration)}" : status; } /// /// Calculates active processing time without counting reused stages or time between resume attempts. /// /// The stage records to aggregate. /// The aggregated duration. private TimeSpan CalculateBuildDuration(IEnumerable records) => records .Where(record => record.StartedAtUtc is not null && record.Status is not VisualBriefingBuildStageStatus.SKIPPED) .Aggregate(TimeSpan.Zero, (total, record) => total + this.CalculateStageDuration(record)); /// /// Calculates one stage duration against the shared live timestamp. /// /// The stage record to measure. /// The stage duration. private TimeSpan CalculateStageDuration(VisualBriefingBuildStageRecord record) { var finishedAtUtc = record.Status is VisualBriefingBuildStageStatus.RUNNING ? this.durationReferenceUtc : record.FinishedAtUtc; if (record.StartedAtUtc is null || finishedAtUtc is null) return TimeSpan.Zero; var duration = finishedAtUtc.Value - record.StartedAtUtc.Value; return duration > TimeSpan.Zero ? duration : TimeSpan.Zero; } /// /// Formats a build duration in seconds using the current culture. /// /// The duration to format. /// The formatted duration. private static string FormatBuildDuration(TimeSpan duration) => $"{duration.TotalSeconds:0.0} s"; /// /// Gets the safe failure reason for a UI group. /// /// The zero-based index of the group. /// The user-facing failure message. private string BuildGroupFailure(int index) => this.Build is null ? string.Empty : STAGE_GROUPS[index] .Select(stage => this.Build.Stages.FirstOrDefault(item => item.Stage == stage)?.Failure) .FirstOrDefault(failure => failure is not null)?.UserMessage ?? this.Build.Failure?.UserMessage ?? string.Empty; }