using AIStudio.Chat; using AIStudio.Dialogs; using AIStudio.Tools.Media; using DialogOptions = AIStudio.Dialogs.DialogOptions; namespace AIStudio.Assistants.VisualBriefing; public partial class VisualBriefingAssistant { /// /// Defines CurrentMediaOwner for the visual briefing feature. /// private MediaImportOwner CurrentMediaOwner => this.selectedBriefing is null ? new(MediaImportOwnerKind.VISUAL_BRIEFING, Guid.Empty.ToString("D")) : MediaImportOwner.ForVisualBriefing(this.selectedBriefing.BriefingId); /// /// Keeps source material and visual assets mutually exclusive after either list changed. /// /// /// A file is either source material or a visual asset, never both: visual assets have to appear in /// the briefing, while source material only feeds the analysis. Visual assets win, so the overlap is /// always resolved on the source-material side. Both attachment controls route here because either /// one can create the overlap — the source-material control catches all document kinds, including /// the image types the visual-asset control is limited to. The warning matters because the file /// would otherwise vanish from the source-material list without any explanation, possibly leaving /// the briefing without the source material it requires. /// /// The changed attachment set. It is ignored because both lists are inspected anyway. private async Task EnforceSourceExclusivityAsync(HashSet _) { var visualPaths = this.editor.VisualAssets.Select(attachment => attachment.FilePath).ToHashSet(PathComparer()); var displaced = this.editor.SourceMaterial.Where(attachment => visualPaths.Contains(attachment.FilePath)).ToArray(); if (displaced.Length > 0) { this.editor.SourceMaterial.ExceptWith(displaced); await this.MessageBus.SendWarning(new( Icons.Material.Filled.Warning, string.Format( T("These files are already attached as visual assets and were removed from the source material: {0}"), string.Join(", ", displaced.Select(attachment => Path.GetFileName(attachment.FilePath)))))); } await this.SaveCurrentAsync(reload: true); } /// /// Defines RefreshSourceStatusAsync for the visual briefing feature. /// private async Task RefreshSourceStatusAsync() { if (this.selectedBriefing is null) return; var latest = await this.Store.LoadAsync(this.selectedBriefing.BriefingId); if (latest is null) return; this.selectedBriefing.Sources = latest.Sources; this.StateHasChanged(); } /// /// Defines MonitorSourceStatusAsync for the visual briefing feature. /// private async Task MonitorSourceStatusAsync(CancellationToken token) { using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5)); try { while (await timer.WaitForNextTickAsync(token)) if (this.selectedBriefing is not null && !this.IsCurrentBusy) await this.InvokeAsync(this.RefreshSourceStatusAsync); } catch (OperationCanceledException) when (token.IsCancellationRequested) { } } /// /// Defines RelinkAsync for the visual briefing feature. /// private async Task RelinkAsync(VisualBriefingSource source) { if (this.selectedBriefing is null) return; var response = await this.RustService.SelectFile(T("Relink briefing source"), initialFile: source.Path); if (response.UserCancelled) return; await this.Store.RelinkSourceAsync(this.selectedBriefing.BriefingId, source.SourceId, response.SelectedFilePath); await this.ReloadListAsync(this.selectedBriefing.BriefingId); } /// /// Defines RemoveSourceAsync for the visual briefing feature. /// private async Task RemoveSourceAsync(VisualBriefingSource source) { if (this.selectedBriefing is null) return; await this.Store.RemoveSourceAsync(this.selectedBriefing.BriefingId, source.SourceId); await this.ReloadListAsync(this.selectedBriefing.BriefingId); } /// /// Defines RetranscribeAsync for the visual briefing feature. /// private async Task RetranscribeAsync(VisualBriefingSource source) { if (this.selectedBriefing is null || !source.IsMedia || !File.Exists(source.Path)) return; var parameters = new DialogParameters { { dialog => dialog.Message, T("The media file changed. Transcribe it again with the configured transcription provider?") }, }; var reference = await this.DialogService.ShowAsync(T("Transcribe media again"), parameters, DialogOptions.FULLSCREEN); var result = await reference.Result; if (result is null || result.Canceled) return; this.MediaTranscriptionService.TryStartAttachmentBatch([source.Path], new(this.CurrentMediaOwner, source.SourceId.ToString("D"))); } /// /// Defines MediaStateChanged for the visual briefing feature. /// private void MediaStateChanged(MediaImportOwner owner) { if (owner.Kind is not MediaImportOwnerKind.VISUAL_BRIEFING || !Guid.TryParse(owner.Id, out var briefingId)) return; _ = this.InvokeAsync(async () => { await this.ConsumeMediaOutcomeAsync(owner); if (!this.MediaTranscriptionService.IsBusy(owner)) { var latest = await this.Store.LoadAsync(briefingId); if (latest is not null) { this.UpdateProject(latest); if (this.selectedBriefing?.BriefingId == briefingId) await this.ApplySelectedBriefingAsync(latest); } } this.StateHasChanged(); }); } /// /// Reports media imports that finished while this page was not open. /// /// /// The transcription service outlives this page, so an import that ends after the user navigated /// away raises its state change with nobody listening. Its outcome then waits in the import lane /// until somebody consumes it, which without this would only happen once that same briefing starts /// another import. /// private async Task ConsumePendingMediaOutcomesAsync() { foreach (var project in this.projects) await this.ConsumeMediaOutcomeAsync(MediaImportOwner.ForVisualBriefing(project.BriefingId)); } /// /// Reports how a media import of one briefing ended, and clears it from the shared import lane. /// /// /// Without this, a failed or canceled transcription stays silent: the source is simply marked as /// outdated and the user is left to guess why. The outcome would also never leave the import lane, /// because consuming it is what removes it. Every assistant built on the assistant base does the /// same for its own single owner; here it happens per briefing, so an import that finishes while a /// different briefing is open still gets reported. /// /// The briefing whose media import finished. private async Task ConsumeMediaOutcomeAsync(MediaImportOwner owner) { var outcome = this.MediaTranscriptionService.TryConsumeOutcome(owner); if (outcome is null) return; if (outcome.Failures.Count > 0) await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, string.Join(Environment.NewLine, outcome.Failures.Select(failure => $"{failure.FileName}: {failure.UserMessage}")))); else if (outcome.Status is MediaImportStatus.FAILED) await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, T("The media file could not be transcribed."))); if (outcome.Warnings.Count > 0) await this.MessageBus.SendWarning(new(Icons.Material.Filled.VoiceChat, string.Join(Environment.NewLine, outcome.Warnings.Select(warning => $"{warning.FileName}: {warning.UserMessage}")))); if (outcome.Status is MediaImportStatus.CANCELLED) await this.MessageBus.SendWarning(new(Icons.Material.Filled.VoiceChat, T("The media transcription was canceled."))); } /// /// Defines SourceStatusName for the visual briefing feature. /// private string SourceStatusName(VisualBriefingSourceStatus status) => status switch { VisualBriefingSourceStatus.UNCHANGED => T("unchanged"), VisualBriefingSourceStatus.CHANGED => T("changed"), VisualBriefingSourceStatus.TRANSCRIPT_OUTDATED => T("transcript outdated"), VisualBriefingSourceStatus.UNREACHABLE => T("unreachable"), _ => status.ToString(), }; /// /// Defines SourceStatusColor for the visual briefing feature. /// private static Color SourceStatusColor(VisualBriefingSourceStatus status) => status switch { VisualBriefingSourceStatus.UNCHANGED => Color.Success, VisualBriefingSourceStatus.CHANGED => Color.Warning, VisualBriefingSourceStatus.TRANSCRIPT_OUTDATED => Color.Warning, VisualBriefingSourceStatus.UNREACHABLE => Color.Error, _ => Color.Default, }; }