2026-07-15 12:53:30 +02:00
using AIStudio.Dialogs ;
using AIStudio.Tools.Media ;
2026-07-06 20:27:25 +02:00
using AIStudio.Tools.Rust ;
2025-06-30 18:56:48 +02:00
using AIStudio.Tools.Services ;
2025-12-15 13:26:55 +01:00
using AIStudio.Tools.Validation ;
2025-06-30 18:56:48 +02:00
using Microsoft.AspNetCore.Components ;
namespace AIStudio.Components ;
public partial class ReadFileContent : MSGComponentBase
{
2026-07-15 12:53:30 +02:00
private readonly MediaImportOwner fallbackMediaImportOwner = new ( MediaImportOwnerKind . ASSISTANT , $"read-file-content:{Guid.NewGuid():N}" );
[CascadingParameter]
private MediaImportOwner ? ImportOwner { get ; set ; }
[Parameter]
public string MediaImportTargetId { get ; set ; } = string . Empty ;
2025-11-24 12:37:18 +01:00
[Parameter]
public string Text { get ; set ; } = string . Empty ;
2025-06-30 18:56:48 +02:00
[Parameter]
public string FileContent { get ; set ; } = string . Empty ;
[Parameter]
public EventCallback < string > FileContentChanged { get ; set ; }
2026-02-01 14:50:19 +01:00
2026-08-11 20:14:21 +02:00
/// <summary>
/// Reports the path after a file was loaded successfully.
/// </summary>
[Parameter]
public EventCallback < string > FilePathLoaded { get ; set ; }
2026-07-21 10:05:43 +02:00
/// <summary>
/// If true, the component will display the state of the attached document (if any).
/// </summary>
[Parameter]
public bool ShowAttachedDocumentState { get ; set ; }
2026-02-01 14:50:19 +01:00
[Parameter]
public bool Disabled { get ; set ; }
2026-07-06 20:27:25 +02:00
[Parameter]
public bool EnableDragDrop { get ; set ; }
/// <summary>
2026-09-10 11:32:08 +02:00
/// Makes this component the default target of its area, meaning of its page, assistant, or
/// dialog: it then also takes the drops which land anywhere in that area without hitting a zone
/// of their own.
2026-07-06 20:27:25 +02:00
/// </summary>
2026-09-10 11:32:08 +02:00
/// <remarks>
/// Only one zone per area can hold that role, and if several ask for it, the first one in the
/// markup gets it. The flag has no effect without drag and drop being enabled.
/// </remarks>
2026-07-06 20:27:25 +02:00
[Parameter]
2026-09-10 11:32:08 +02:00
public bool CatchAllDocuments { get ; set ; }
2026-07-06 20:27:25 +02:00
/// <summary>
2026-09-10 11:32:08 +02:00
/// The area this component lives in, if it lives in one at all.
2026-07-06 20:27:25 +02:00
/// </summary>
2026-09-10 11:32:08 +02:00
[CascadingParameter]
private DropZoneScopeState ? Scope { get ; set ; }
2026-08-11 20:14:21 +02:00
/// <summary>
/// Optionally restricts the file types offered by the native file picker
/// and accepted by this component.
/// </summary>
[Parameter]
public FileTypeFilter []? Filter { get ; set ; }
2025-06-30 18:56:48 +02:00
[Inject]
private RustService RustService { get ; init ; } = null !;
2025-11-14 13:34:30 +01:00
[Inject]
private IDialogService DialogService { get ; init ; } = null !;
[Inject]
private ILogger < ReadFileContent > Logger { get ; init ; } = null !;
2025-12-10 13:48:13 +01:00
[Inject]
private PandocAvailabilityService PandocAvailabilityService { get ; init ; } = null !;
2026-07-06 20:27:25 +02:00
2026-07-15 12:53:30 +02:00
[Inject]
private MediaTranscriptionService MediaTranscriptionService { get ; init ; } = null !;
2026-07-06 20:27:25 +02:00
private const string DEFAULT_DRAG_CLASS = "relative rounded-lg border-2 border-dashed pa-3 mb-3 mud-width-full" ;
2026-09-10 11:32:08 +02:00
private readonly string dropZoneId = $"read-file-content-{Guid.NewGuid():N}" ;
2026-07-06 20:27:25 +02:00
private string ButtonText => string . IsNullOrWhiteSpace ( this . Text ) ? T ( "Use file content as input" ) : this . Text ;
private string dragClass = DEFAULT_DRAG_CLASS ;
2026-09-10 11:32:08 +02:00
private bool isDefaultZone ;
private bool isHighlighted ;
2026-07-19 19:56:30 +02:00
private bool isFileDialogOpen ;
2026-07-21 10:05:43 +02:00
private bool hasLoadedFileContent ;
private string loadedFileName = string . Empty ;
2026-07-15 12:53:30 +02:00
private bool IsCurrentTargetBusy => this . MediaTranscriptionService . GetSnapshot ( this . EffectiveImportOwner ) is { IsBusy : true } snapshot
&& snapshot . Target == this . EffectiveMediaImportTarget ;
2026-07-21 10:05:43 +02:00
2026-07-19 19:56:30 +02:00
private bool IsUnavailable => this . Disabled || this . isFileDialogOpen || this . MediaTranscriptionService . IsBusy ( this . EffectiveImportOwner );
2026-07-06 20:27:25 +02:00
2026-07-21 10:05:43 +02:00
private MediaImportOwner EffectiveImportOwner => this . ImportOwner ?? this . fallbackMediaImportOwner ;
private string EffectiveMediaImportTargetId => string . IsNullOrWhiteSpace ( this . MediaImportTargetId )
? string . IsNullOrWhiteSpace ( this . Text ) ? "primary" : this . Text
: this . MediaImportTargetId ;
private MediaImportTarget EffectiveMediaImportTarget => new ( this . EffectiveImportOwner , this . EffectiveMediaImportTargetId );
2026-07-06 20:27:25 +02:00
#region Overrides of MSGComponentBase
2026-07-21 10:05:43 +02:00
protected override void OnParametersSet ()
{
if ( string . IsNullOrWhiteSpace ( this . FileContent ))
{
this . hasLoadedFileContent = false ;
this . loadedFileName = string . Empty ;
}
base . OnParametersSet ();
}
2026-07-06 20:27:25 +02:00
protected override async Task OnInitializedAsync ()
{
2026-07-15 12:53:30 +02:00
this . MediaTranscriptionService . StateChanged += this . OnMediaImportStateChanged ;
2026-07-06 20:27:25 +02:00
if ( this . EnableDragDrop )
{
2026-09-10 11:32:08 +02:00
this . ApplyFilters ([], [ Event . HIGHLIGHT_DROP_ZONE , Event . PATHS_DROPPED ]);
this . ClaimDefaultZoneRole ();
2026-07-06 20:27:25 +02:00
}
await base . OnInitializedAsync ();
2026-07-15 12:53:30 +02:00
await this . SyncCompletedMediaTextAsync ();
}
/// <summary>Refreshes disabled controls when the shared import lane changes.</summary>
private void OnMediaImportStateChanged ( MediaImportOwner owner )
{
if ( owner == this . EffectiveImportOwner )
2026-08-25 18:53:09 +02:00
this . InvokeAsync ( async () =>
2026-07-15 12:53:30 +02:00
{
await this . SyncCompletedMediaTextAsync ();
await this . ConsumeStandaloneMediaOutcomeAsync ();
this . StateHasChanged ();
2026-08-25 18:53:09 +02:00
}). Observe ( $"{nameof(ReadFileContent)}: syncing transcribed text" );
2026-07-15 12:53:30 +02:00
}
/// <summary>Consumes outcomes for dialog-local controls that have no assistant owner surface.</summary>
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." )));
}
}
/// <summary>Applies a completed target transcript after progress or navigation.</summary>
private async Task SyncCompletedMediaTextAsync ()
{
var delivery = this . MediaTranscriptionService . GetPendingDelivery ( this . EffectiveMediaImportTarget );
if ( delivery is null || delivery . Text is not { } text )
return ;
2026-07-21 10:05:43 +02:00
var fileName = this . MediaTranscriptionService . GetSnapshot ( this . EffectiveImportOwner ) is { Target : var target } snapshot
&& target == this . EffectiveMediaImportTarget
? snapshot . CurrentFileName
: string . Empty ;
await this . ApplyFileContentAsync ( text , fileName );
2026-07-15 12:53:30 +02:00
this . MediaTranscriptionService . AcknowledgeDelivery ( delivery );
}
2026-08-06 10:42:20 +02:00
/// <summary>Unsubscribes from the singleton media service and releases the drop area.</summary>
2026-07-15 12:53:30 +02:00
protected override void DisposeResources ()
{
this . MediaTranscriptionService . StateChanged -= this . OnMediaImportStateChanged ;
2026-08-06 10:42:20 +02:00
2026-09-10 11:32:08 +02:00
// Hand the role of the default target back to the area:
if ( this . isDefaultZone )
this . Scope ?. ReleaseDefaultZone ( this );
2026-08-06 10:42:20 +02:00
2026-07-15 12:53:30 +02:00
base . DisposeResources ();
2026-07-06 20:27:25 +02:00
}
protected override async Task ProcessIncomingMessage < T >( ComponentBase ? sendingComponent , Event triggeredEvent , T ? data ) where T : default
{
if (! this . EnableDragDrop )
return ;
switch ( triggeredEvent )
{
2026-09-10 11:32:08 +02:00
case Event . HIGHLIGHT_DROP_ZONE when data is DropZoneHighlight highlight :
this . ApplyHighlight ( this . IsThisZone ( highlight . ZoneId ));
2026-07-06 20:27:25 +02:00
break ;
2026-09-10 11:32:08 +02:00
case Event . PATHS_DROPPED when data is DroppedPaths dropped :
// Whoever the drop was meant for, the drag is over and no zone stays highlighted:
this . ApplyHighlight ( false );
2026-07-06 20:27:25 +02:00
2026-09-10 11:32:08 +02:00
if (! this . IsThisZone ( dropped . ZoneId ))
2026-07-06 20:27:25 +02:00
return ;
2026-09-10 11:32:08 +02:00
if ( this . IsUnavailable )
{
this . Logger . LogDebug ( "The file zone '{ZoneId}' is unavailable and swallowed {Count} dropped path(s)." , this . dropZoneId , dropped . Paths . Count );
2026-07-06 20:27:25 +02:00
return ;
2026-09-10 11:32:08 +02:00
}
2026-07-06 20:27:25 +02:00
2026-09-10 11:32:08 +02:00
await this . LoadFirstValidFile ( dropped . Paths );
2026-07-06 20:27:25 +02:00
this . StateHasChanged ();
break ;
}
}
#endregion
2026-09-10 11:32:08 +02:00
/// <summary>
/// Asks the area for the role of its default target, if this component wants it.
/// </summary>
private void ClaimDefaultZoneRole ()
{
if (! this . CatchAllDocuments || this . Scope is null )
return ;
this . isDefaultZone = this . Scope . TryBecomeDefaultZone ( this );
if (! this . isDefaultZone )
this . Logger . LogDebug ( "The file zone '{ZoneId}' asked to be the default target of its area, which another zone already is. It now takes only the drops aimed at itself." , this . dropZoneId );
}
/// <summary>
/// Decides whether the named zone is this one.
/// </summary>
/// <remarks>
/// The area counts as this zone as long as this zone is its default target. That is the whole
/// mechanism behind dropping anywhere in an assistant and still landing here.
/// </remarks>
/// <param name="zoneId">The ID the hit test reported, or null when it hit nothing.</param>
private bool IsThisZone ( string? zoneId ) => zoneId is not null && ( zoneId == this . dropZoneId || ( this . isDefaultZone && zoneId == this . Scope ?. ScopeId ));
/// <summary>
/// Highlights the zone, or takes the highlight away.
/// </summary>
/// <remarks>
/// The comparison is not for tidiness: a throttled drag-over event arrives about ten times per
/// second, and without it every one of them would render every zone on the page anew.
/// </remarks>
private void ApplyHighlight ( bool shouldBeHighlighted )
{
var highlighted = shouldBeHighlighted && ! this . IsUnavailable ;
if ( highlighted == this . isHighlighted )
return ;
this . isHighlighted = highlighted ;
this . dragClass = highlighted ? $"{DEFAULT_DRAG_CLASS} mud-border-primary border-2" : DEFAULT_DRAG_CLASS ;
this . StateHasChanged ();
}
2025-11-14 13:34:30 +01:00
2025-06-30 18:56:48 +02:00
private async Task SelectFile ()
{
2026-07-15 12:53:30 +02:00
if ( this . IsUnavailable )
2026-07-06 20:27:25 +02:00
return ;
2026-07-19 19:56:30 +02:00
this . isFileDialogOpen = true ;
try
2026-07-06 20:27:25 +02:00
{
2026-08-11 20:14:21 +02:00
var selectedFile = await this . RustService . SelectFile ( T ( "Select file to read its content" ), this . Filter );
2026-07-19 19:56:30 +02:00
if ( selectedFile . UserCancelled )
{
this . Logger . LogInformation ( "User cancelled the file selection" );
return ;
}
2026-07-06 20:27:25 +02:00
2026-07-19 19:56:30 +02:00
await this . LoadFileIfValid ( selectedFile . SelectedFilePath );
}
finally
{
this . isFileDialogOpen = false ;
}
2026-07-06 20:27:25 +02:00
}
private async Task < bool > EnsurePandocAvailability ()
{
2025-12-15 12:45:54 +01:00
// 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." );
2026-07-06 20:27:25 +02:00
return false ;
2025-12-15 12:45:54 +01:00
}
2026-07-06 20:27:25 +02:00
return true ;
}
private async Task LoadFirstValidFile ( List < string > paths )
{
2026-07-19 19:56:30 +02:00
var inaccessiblePaths = paths . Where ( path => ! File . Exists ( path )). ToList ();
if ( inaccessiblePaths . Count > 0 )
{
this . Logger . LogWarning ( "Could not access {Count} dropped file(s): {Paths}" , inaccessiblePaths . Count , string . Join ( ", " , inaccessiblePaths ));
await this . MessageBus . SendWarning ( new (
Icons . Material . Filled . Warning ,
this . T ( "Some dropped files could not be accessed. Please select them with the file chooser instead." )));
}
2026-07-06 20:27:25 +02:00
foreach ( var path in paths )
{
if ( await this . LoadFileIfValid ( path ))
return ;
2025-12-08 21:15:45 +01:00
}
2026-07-06 20:27:25 +02:00
}
2025-12-08 21:15:45 +01:00
2026-07-06 20:27:25 +02:00
private async Task < bool > LoadFileIfValid ( string filePath )
{
if (! File . Exists ( filePath ))
2025-12-08 21:15:45 +01:00
{
2026-07-06 20:27:25 +02:00
this . Logger . LogWarning ( "Selected file does not exist: '{FilePath}'" , filePath );
return false ;
2025-12-08 21:15:45 +01:00
}
2026-08-11 20:14:21 +02:00
if ( this . Filter is { Length : > 0 } && ! FileTypes . IsAllowedPath ( filePath , this . Filter ))
{
this . Logger . LogWarning ( "Selected file does not match the configured file type filter: '{FilePath}'" , filePath );
await this . MessageBus . SendWarning ( new ( Icons . Material . Filled . Warning , this . T ( "Please select a file with a supported file type." )));
return false ;
}
2026-07-15 12:53:30 +02:00
if ( FileTypes . IsAllowedPath ( filePath , FileTypes . AUDIO ) || FileTypes . IsAllowedPath ( filePath , FileTypes . VIDEO ))
return await this . LoadMediaTranscriptAsync ( filePath );
if (! await this . EnsurePandocAvailability ())
return false ;
2026-07-06 20:27:25 +02:00
if (! await FileExtensionValidation . IsExtensionValidWithNotifyAsync ( FileExtensionValidation . UseCase . DIRECTLY_LOADING_CONTENT , filePath ))
2025-06-30 18:56:48 +02:00
{
2026-07-06 20:27:25 +02:00
this . Logger . LogWarning ( "User attempted to load unsupported file: {FilePath}" , filePath );
return false ;
2025-06-30 18:56:48 +02:00
}
2025-12-08 21:15:45 +01:00
try
{
2026-08-31 08:40:50 +02:00
var extraction = await UserFile . LoadFileData ( filePath , this . RustService , this . PandocAvailabilityService );
2026-08-10 16:37:44 +02:00
// The failure was already reported by UserFile.LoadFileData, so we only stop here:
if (! extraction . HasUsableContent )
return false ;
await this . ApplyFileContentAsync ( extraction . Content , filePath );
2026-07-06 20:27:25 +02:00
this . Logger . LogInformation ( "Successfully loaded file content: {FilePath}" , filePath );
return true ;
2025-12-08 21:15:45 +01:00
}
catch ( Exception ex )
{
2026-07-06 20:27:25 +02:00
this . Logger . LogError ( ex , "Failed to load file content: {FilePath}" , filePath );
2025-12-08 21:15:45 +01:00
await MessageBus . INSTANCE . SendError ( new ( Icons . Material . Filled . Error , T ( "Failed to load file content" )));
2026-07-06 20:27:25 +02:00
return false ;
2025-12-08 21:15:45 +01:00
}
2025-06-30 18:56:48 +02:00
}
2026-07-06 20:27:25 +02:00
2026-07-21 10:05:43 +02:00
private async Task ApplyFileContentAsync ( string fileContent , string filePath )
{
await this . FileContentChanged . InvokeAsync ( fileContent );
2026-08-11 20:14:21 +02:00
await this . FilePathLoaded . InvokeAsync ( filePath );
2026-07-21 10:05:43 +02:00
this . loadedFileName = Path . GetFileName ( filePath );
this . hasLoadedFileContent = true ;
}
2026-07-15 12:53:30 +02:00
private async Task < bool > 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 < ConfirmDialog >
{
{
x => x . MarkdownBody ,
$"""
{message}
- {Markdown.EscapeInlineText(Path.GetFileName(filePath))}
"""
},
};
var dialogReference = await this . DialogService . ShowAsync < ConfirmDialog >(
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 );
}
2026-07-21 10:05:43 +02:00
private string FileLoadedTooltip ()
{
if (! this . hasLoadedFileContent )
return string . Empty ;
if ( string . IsNullOrWhiteSpace ( this . loadedFileName ))
return this . T ( "File content loaded" );
return string . Format ( this . T ( "Attached file '{0}'." ), this . loadedFileName );
}
2026-08-11 20:14:21 +02:00
}