using AIStudio.Dialogs; using AIStudio.Tools.Media; using AIStudio.Tools.Rust; using AIStudio.Tools.Services; using AIStudio.Tools.Validation; using Microsoft.AspNetCore.Components; namespace AIStudio.Components; public partial class ReadFileContent : MSGComponentBase { private readonly MediaImportOwner fallbackMediaImportOwner = new(MediaImportOwnerKind.ASSISTANT, $"read-file-content:{Guid.NewGuid():N}"); [CascadingParameter] private MediaImportOwner? ImportOwner { get; set; } private MediaImportOwner EffectiveImportOwner => this.ImportOwner ?? this.fallbackMediaImportOwner; [Parameter] public string MediaImportTargetId { get; set; } = string.Empty; private string EffectiveMediaImportTargetId => string.IsNullOrWhiteSpace(this.MediaImportTargetId) ? string.IsNullOrWhiteSpace(this.Text) ? "primary" : this.Text : this.MediaImportTargetId; private MediaImportTarget EffectiveMediaImportTarget => new(this.EffectiveImportOwner, this.EffectiveMediaImportTargetId); [Parameter] public string Text { get; set; } = string.Empty; [Parameter] public string FileContent { get; set; } = string.Empty; [Parameter] public EventCallback FileContentChanged { get; set; } [Parameter] public bool Disabled { get; set; } [Parameter] public bool EnableDragDrop { get; set; } /// /// On which layer to register the drop area. Higher layers have priority over lower layers. /// [Parameter] public int Layer { get; set; } /// /// Catch all documents that are hovered over the AI Studio window and not only over the drop zone. /// [Parameter] public bool CatchAllDocuments { get; set; } [Inject] private RustService RustService { get; init; } = null!; [Inject] private IDialogService DialogService { get; init; } = null!; [Inject] private ILogger Logger { get; init; } = null!; [Inject] private PandocAvailabilityService PandocAvailabilityService { get; init; } = null!; [Inject] private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!; private const string DEFAULT_DRAG_CLASS = "relative rounded-lg border-2 border-dashed pa-3 mb-3 mud-width-full"; private string ButtonText => string.IsNullOrWhiteSpace(this.Text) ? T("Use file content as input") : this.Text; private string dragClass = DEFAULT_DRAG_CLASS; private uint numDropAreasAboveThis; private bool isComponentHovered; private bool IsCurrentTargetBusy => this.MediaTranscriptionService.GetSnapshot(this.EffectiveImportOwner) is { IsBusy: true } snapshot && snapshot.Target == this.EffectiveMediaImportTarget; private bool IsUnavailable => this.Disabled || this.MediaTranscriptionService.IsBusy(this.EffectiveImportOwner); #region Overrides of MSGComponentBase protected override async Task OnInitializedAsync() { this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged; if (this.EnableDragDrop) { this.ApplyFilters([], [ Event.TAURI_EVENT_RECEIVED, Event.REGISTER_FILE_DROP_AREA, Event.UNREGISTER_FILE_DROP_AREA ]); await this.MessageBus.SendMessage(this, Event.REGISTER_FILE_DROP_AREA, this.Layer); } await base.OnInitializedAsync(); await this.SyncCompletedMediaTextAsync(); } /// Refreshes disabled controls when the shared import lane changes. private void OnMediaImportStateChanged(MediaImportOwner owner) { if (owner == this.EffectiveImportOwner) _ = this.InvokeAsync(async () => { await this.SyncCompletedMediaTextAsync(); await this.ConsumeStandaloneMediaOutcomeAsync(); this.StateHasChanged(); }); } /// Consumes outcomes for dialog-local controls that have no assistant owner surface. private async Task ConsumeStandaloneMediaOutcomeAsync() { if (this.ImportOwner is not null) return; var outcome = this.MediaTranscriptionService.TryConsumeOutcome(this.EffectiveImportOwner); if (outcome is null) return; if (outcome.Failures.Count > 0) { var message = string.Join(Environment.NewLine, outcome.Failures.Select(failure => $"{failure.FileName}: {failure.UserMessage}")); await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, message)); } else if (outcome.Status is MediaImportStatus.FAILED) { await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, this.T("The media file could not be transcribed."))); } if (outcome.Warnings.Count > 0) { var message = string.Join(Environment.NewLine, outcome.Warnings.Select(warning => $"{warning.FileName}: {warning.UserMessage}")); await this.MessageBus.SendWarning(new(Icons.Material.Filled.VoiceChat, message)); } if (outcome.Status is MediaImportStatus.CANCELLED) { await this.MessageBus.SendWarning(new(Icons.Material.Filled.VoiceChat, this.T("The media transcription was canceled."))); } } /// Applies a completed target transcript after progress or navigation. private async Task SyncCompletedMediaTextAsync() { var delivery = this.MediaTranscriptionService.GetPendingDelivery(this.EffectiveMediaImportTarget); if (delivery is null || delivery.Text is not { } text) return; await this.FileContentChanged.InvokeAsync(text); this.MediaTranscriptionService.AcknowledgeDelivery(delivery); } /// Unsubscribes from the singleton media service. protected override void DisposeResources() { this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged; base.DisposeResources(); } protected override async Task ProcessIncomingMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default { if (!this.EnableDragDrop) return; if (this.IsUnavailable && triggeredEvent == Event.TAURI_EVENT_RECEIVED) return; switch (triggeredEvent) { case Event.REGISTER_FILE_DROP_AREA when sendingComponent != this: { if(data is int layer && layer > this.Layer) { this.numDropAreasAboveThis++; this.ClearDragClass(); } break; } case Event.UNREGISTER_FILE_DROP_AREA when sendingComponent != this: { if(data is int layer && layer > this.Layer && this.numDropAreasAboveThis > 0) this.numDropAreasAboveThis--; break; } case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_HOVERED }: if(!this.CanCatchDroppedFile()) return; this.SetDragClass(); this.StateHasChanged(); break; case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_CANCELED }: case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.WINDOW_NOT_FOCUSED }: this.isComponentHovered = false; this.ClearDragClass(); this.StateHasChanged(); break; case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_DROPPED, Payload: var paths }: if(!this.CanCatchDroppedFile()) return; await this.LoadFirstValidFile(paths); this.ClearDragClass(); this.StateHasChanged(); break; } } #endregion private async Task SelectFile() { if (this.IsUnavailable) return; var selectedFile = await this.RustService.SelectFile(T("Select file to read its content")); if (selectedFile.UserCancelled) { this.Logger.LogInformation("User cancelled the file selection"); return; } await this.LoadFileIfValid(selectedFile.SelectedFilePath); } private async Task EnsurePandocAvailability() { // Ensure that Pandoc is installed and ready: var pandocState = await this.PandocAvailabilityService.EnsureAvailabilityAsync( showSuccessMessage: false, showDialog: true); // Check if Pandoc is available after the check / installation: if (!pandocState.IsAvailable) { this.Logger.LogWarning("The user cancelled the Pandoc installation or Pandoc is not available. Aborting file selection."); return false; } return true; } private async Task LoadFirstValidFile(List paths) { foreach (var path in paths) { if (await this.LoadFileIfValid(path)) return; } } private async Task LoadFileIfValid(string filePath) { if(!File.Exists(filePath)) { this.Logger.LogWarning("Selected file does not exist: '{FilePath}'", filePath); return false; } if (FileTypes.IsAllowedPath(filePath, FileTypes.AUDIO) || FileTypes.IsAllowedPath(filePath, FileTypes.VIDEO)) return await this.LoadMediaTranscriptAsync(filePath); if (!await this.EnsurePandocAvailability()) return false; if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.DIRECTLY_LOADING_CONTENT, filePath)) { this.Logger.LogWarning("User attempted to load unsupported file: {FilePath}", filePath); return false; } try { var fileContent = await UserFile.LoadFileData(filePath, this.RustService, this.DialogService); await this.FileContentChanged.InvokeAsync(fileContent); this.Logger.LogInformation("Successfully loaded file content: {FilePath}", filePath); return true; } catch (Exception ex) { this.Logger.LogError(ex, "Failed to load file content: {FilePath}", filePath); await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Error, T("Failed to load file content"))); return false; } } private async Task LoadMediaTranscriptAsync(string filePath) { if (string.IsNullOrWhiteSpace(this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider)) { await this.MessageBus.SendWarning(new( Icons.Material.Filled.VoiceChat, this.T("Media files require a configured transcription provider. Configure one in the transcription settings."))); return false; } var message = this.T("The selected media file will be prepared locally. Its audio will then be uploaded to the configured transcription provider."); var dialogParameters = new DialogParameters { { x => x.MarkdownBody, $""" {message} - {Markdown.EscapeInlineText(Path.GetFileName(filePath))} """ }, }; var dialogReference = await this.DialogService.ShowAsync( this.T("Transcribe media file"), dialogParameters, Dialogs.DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled) return false; return this.MediaTranscriptionService.TryStartTextImport( filePath, this.EffectiveMediaImportTarget); } private bool CanCatchDroppedFile() => this.numDropAreasAboveThis is 0 && (this.isComponentHovered || this.CatchAllDocuments); private void SetDragClass() => this.dragClass = $"{DEFAULT_DRAG_CLASS} mud-border-primary border-2"; private void ClearDragClass() => this.dragClass = DEFAULT_DRAG_CLASS; private void OnMouseEnter(EventArgs _) { if(this.IsUnavailable || this.numDropAreasAboveThis > 0) return; this.Logger.LogDebug("Read file content component is hovered."); this.isComponentHovered = true; this.SetDragClass(); this.StateHasChanged(); } private void OnMouseLeave(EventArgs _) { if(this.IsUnavailable) return; this.Logger.LogDebug("Read file content component is no longer hovered."); this.isComponentHovered = false; this.ClearDragClass(); this.StateHasChanged(); } }