2026-07-15 10:53:30 +00:00
using AIStudio.Dialogs ;
using AIStudio.Tools.Media ;
2026-07-06 18:27:25 +00:00
using AIStudio.Tools.Rust ;
2025-06-30 16:56:48 +00:00
using AIStudio.Tools.Services ;
2025-12-15 12:26:55 +00:00
using AIStudio.Tools.Validation ;
2025-06-30 16:56:48 +00:00
using Microsoft.AspNetCore.Components ;
namespace AIStudio.Components ;
public partial class ReadFileContent : MSGComponentBase
{
2026-07-15 10:53:30 +00: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 11:37:18 +00:00
[Parameter]
public string Text { get ; set ; } = string . Empty ;
2025-06-30 16:56:48 +00:00
[Parameter]
public string FileContent { get ; set ; } = string . Empty ;
[Parameter]
public EventCallback < string > FileContentChanged { get ; set ; }
2026-02-01 13:50:19 +00:00
2026-08-11 18:14:21 +00:00
/// <summary>
/// Reports the path after a file was loaded successfully.
/// </summary>
[Parameter]
public EventCallback < string > FilePathLoaded { get ; set ; }
2026-07-21 08:05:43 +00: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 13:50:19 +00:00
[Parameter]
public bool Disabled { get ; set ; }
2026-07-06 18:27:25 +00:00
[Parameter]
public bool EnableDragDrop { get ; set ; }
/// <summary>
2026-09-10 09:32:08 +00: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 18:27:25 +00:00
/// </summary>
2026-09-10 09:32:08 +00: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 18:27:25 +00:00
[Parameter]
2026-09-10 09:32:08 +00:00
public bool CatchAllDocuments { get ; set ; }
2026-07-06 18:27:25 +00:00
/// <summary>
2026-09-10 09:32:08 +00:00
/// The area this component lives in, if it lives in one at all.
2026-07-06 18:27:25 +00:00
/// </summary>
2026-09-10 09:32:08 +00:00
[CascadingParameter]
private DropZoneScopeState ? Scope { get ; set ; }
2026-08-11 18:14:21 +00: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 16:56:48 +00:00
[Inject]
private RustService RustService { get ; init ; } = null ! ;
2025-11-14 12:34:30 +00:00
[Inject]
private IDialogService DialogService { get ; init ; } = null ! ;
[Inject]
private ILogger < ReadFileContent > Logger { get ; init ; } = null ! ;
2025-12-10 12:48:13 +00:00
[Inject]
private PandocAvailabilityService PandocAvailabilityService { get ; init ; } = null ! ;
2026-07-06 18:27:25 +00:00
2026-07-15 10:53:30 +00:00
[Inject]
private MediaTranscriptionService MediaTranscriptionService { get ; init ; } = null ! ;
2026-07-06 18:27:25 +00:00
private const string DEFAULT_DRAG_CLASS = "relative rounded-lg border-2 border-dashed pa-3 mb-3 mud-width-full" ;
2026-09-10 09:32:08 +00:00
private readonly string dropZoneId = $"read-file-content-{Guid.NewGuid():N}" ;
2026-07-06 18:27:25 +00: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 09:32:08 +00:00
private bool isDefaultZone ;
private bool isHighlighted ;
2026-07-19 17:56:30 +00:00
private bool isFileDialogOpen ;
2026-07-21 08:05:43 +00:00
private bool hasLoadedFileContent ;
private string loadedFileName = string . Empty ;
2026-07-15 10:53:30 +00:00
private bool IsCurrentTargetBusy = > this . MediaTranscriptionService . GetSnapshot ( this . EffectiveImportOwner ) is { IsBusy : true } snapshot
& & snapshot . Target = = this . EffectiveMediaImportTarget ;
2026-07-21 08:05:43 +00:00
2026-07-19 17:56:30 +00:00
private bool IsUnavailable = > this . Disabled | | this . isFileDialogOpen | | this . MediaTranscriptionService . IsBusy ( this . EffectiveImportOwner ) ;
2026-07-06 18:27:25 +00:00
2026-07-21 08:05:43 +00: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 18:27:25 +00:00
#region Overrides of MSGComponentBase
2026-07-21 08:05:43 +00:00
protected override void OnParametersSet ( )
{
if ( string . IsNullOrWhiteSpace ( this . FileContent ) )
{
this . hasLoadedFileContent = false ;
this . loadedFileName = string . Empty ;
}
base . OnParametersSet ( ) ;
}
2026-07-06 18:27:25 +00:00
protected override async Task OnInitializedAsync ( )
{
2026-07-15 10:53:30 +00:00
this . MediaTranscriptionService . StateChanged + = this . OnMediaImportStateChanged ;
2026-07-06 18:27:25 +00:00
if ( this . EnableDragDrop )
{
2026-09-10 09:32:08 +00:00
this . ApplyFilters ( [ ] , [ Event . HIGHLIGHT_DROP_ZONE , Event . PATHS_DROPPED ] ) ;
this . ClaimDefaultZoneRole ( ) ;
2026-07-06 18:27:25 +00:00
}
await base . OnInitializedAsync ( ) ;
2026-07-15 10:53:30 +00: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 16:53:09 +00:00
this . InvokeAsync ( async ( ) = >
2026-07-15 10:53:30 +00:00
{
await this . SyncCompletedMediaTextAsync ( ) ;
await this . ConsumeStandaloneMediaOutcomeAsync ( ) ;
this . StateHasChanged ( ) ;
2026-08-25 16:53:09 +00:00
} ) . Observe ( $"{nameof(ReadFileContent)}: syncing transcribed text" ) ;
2026-07-15 10:53:30 +00: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 08:05:43 +00: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 10:53:30 +00:00
this . MediaTranscriptionService . AcknowledgeDelivery ( delivery ) ;
}
2026-08-06 08:42:20 +00:00
/// <summary>Unsubscribes from the singleton media service and releases the drop area.</summary>
2026-07-15 10:53:30 +00:00
protected override void DisposeResources ( )
{
this . MediaTranscriptionService . StateChanged - = this . OnMediaImportStateChanged ;
2026-08-06 08:42:20 +00:00
2026-09-10 09:32:08 +00:00
// Hand the role of the default target back to the area:
if ( this . isDefaultZone )
this . Scope ? . ReleaseDefaultZone ( this ) ;
2026-08-06 08:42:20 +00:00
2026-07-15 10:53:30 +00:00
base . DisposeResources ( ) ;
2026-07-06 18:27:25 +00: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 09:32:08 +00:00
case Event . HIGHLIGHT_DROP_ZONE when data is DropZoneHighlight highlight :
this . ApplyHighlight ( this . IsThisZone ( highlight . ZoneId ) ) ;
2026-07-06 18:27:25 +00:00
break ;
2026-09-10 09:32:08 +00: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 18:27:25 +00:00
2026-09-10 09:32:08 +00:00
if ( ! this . IsThisZone ( dropped . ZoneId ) )
2026-07-06 18:27:25 +00:00
return ;
2026-09-10 09:32:08 +00: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 18:27:25 +00:00
return ;
2026-09-10 09:32:08 +00:00
}
2026-07-06 18:27:25 +00:00
2026-09-10 09:32:08 +00:00
await this . LoadFirstValidFile ( dropped . Paths ) ;
2026-07-06 18:27:25 +00:00
this . StateHasChanged ( ) ;
break ;
}
}
#endregion
2026-09-10 09:32:08 +00: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 12:34:30 +00:00
2025-06-30 16:56:48 +00:00
private async Task SelectFile ( )
{
2026-07-15 10:53:30 +00:00
if ( this . IsUnavailable )
2026-07-06 18:27:25 +00:00
return ;
2026-07-19 17:56:30 +00:00
this . isFileDialogOpen = true ;
try
2026-07-06 18:27:25 +00:00
{
2026-08-11 18:14:21 +00:00
var selectedFile = await this . RustService . SelectFile ( T ( "Select file to read its content" ) , this . Filter ) ;
2026-07-19 17:56:30 +00:00
if ( selectedFile . UserCancelled )
{
this . Logger . LogInformation ( "User cancelled the file selection" ) ;
return ;
}
2026-07-06 18:27:25 +00:00
2026-07-19 17:56:30 +00:00
await this . LoadFileIfValid ( selectedFile . SelectedFilePath ) ;
}
finally
{
this . isFileDialogOpen = false ;
}
2026-07-06 18:27:25 +00:00
}
private async Task < bool > EnsurePandocAvailability ( )
{
2025-12-15 11:45:54 +00: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 18:27:25 +00:00
return false ;
2025-12-15 11:45:54 +00:00
}
2026-07-06 18:27:25 +00:00
return true ;
}
private async Task LoadFirstValidFile ( List < string > paths )
{
2026-07-19 17:56:30 +00: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 18:27:25 +00:00
foreach ( var path in paths )
{
if ( await this . LoadFileIfValid ( path ) )
return ;
2025-12-08 20:15:45 +00:00
}
2026-07-06 18:27:25 +00:00
}
2025-12-08 20:15:45 +00:00
2026-07-06 18:27:25 +00:00
private async Task < bool > LoadFileIfValid ( string filePath )
{
if ( ! File . Exists ( filePath ) )
2025-12-08 20:15:45 +00:00
{
2026-07-06 18:27:25 +00:00
this . Logger . LogWarning ( "Selected file does not exist: '{FilePath}'" , filePath ) ;
return false ;
2025-12-08 20:15:45 +00:00
}
2026-08-11 18:14:21 +00: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 10:53:30 +00: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 18:27:25 +00:00
if ( ! await FileExtensionValidation . IsExtensionValidWithNotifyAsync ( FileExtensionValidation . UseCase . DIRECTLY_LOADING_CONTENT , filePath ) )
2025-06-30 16:56:48 +00:00
{
2026-07-06 18:27:25 +00:00
this . Logger . LogWarning ( "User attempted to load unsupported file: {FilePath}" , filePath ) ;
return false ;
2025-06-30 16:56:48 +00:00
}
2025-12-08 20:15:45 +00:00
try
{
2026-08-31 06:40:50 +00:00
var extraction = await UserFile . LoadFileData ( filePath , this . RustService , this . PandocAvailabilityService ) ;
2026-08-10 14:37:44 +00: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 18:27:25 +00:00
this . Logger . LogInformation ( "Successfully loaded file content: {FilePath}" , filePath ) ;
return true ;
2025-12-08 20:15:45 +00:00
}
catch ( Exception ex )
{
2026-07-06 18:27:25 +00:00
this . Logger . LogError ( ex , "Failed to load file content: {FilePath}" , filePath ) ;
2025-12-08 20:15:45 +00:00
await MessageBus . INSTANCE . SendError ( new ( Icons . Material . Filled . Error , T ( "Failed to load file content" ) ) ) ;
2026-07-06 18:27:25 +00:00
return false ;
2025-12-08 20:15:45 +00:00
}
2025-06-30 16:56:48 +00:00
}
2026-07-06 18:27:25 +00:00
2026-07-21 08:05:43 +00:00
private async Task ApplyFileContentAsync ( string fileContent , string filePath )
{
await this . FileContentChanged . InvokeAsync ( fileContent ) ;
2026-08-11 18:14:21 +00:00
await this . FilePathLoaded . InvokeAsync ( filePath ) ;
2026-07-21 08:05:43 +00:00
this . loadedFileName = Path . GetFileName ( filePath ) ;
this . hasLoadedFileContent = true ;
}
2026-07-15 10:53:30 +00: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 08:05:43 +00: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 18:14:21 +00:00
}