2025-12-28 16:50:36 +01:00
using AIStudio.Chat ;
2025-11-24 12:37:18 +01:00
using AIStudio.Dialogs ;
2026-07-15 12:53:30 +02:00
using AIStudio.Tools.Media ;
2025-12-18 15:00:27 +01:00
using AIStudio.Tools.PluginSystem ;
2025-11-24 12:37:18 +01:00
using AIStudio.Tools.Rust ;
using AIStudio.Tools.Services ;
2025-12-15 13:26:55 +01:00
using AIStudio.Tools.Validation ;
2025-11-24 12:37:18 +01:00
using Microsoft.AspNetCore.Components ;
namespace AIStudio.Components ;
2025-12-05 20:48:54 +01:00
using DialogOptions = Dialogs . DialogOptions ;
2025-11-24 12:37:18 +01:00
public partial class AttachDocuments : MSGComponentBase
{
2026-07-15 12:53:30 +02:00
private readonly MediaImportOwner fallbackMediaImportOwner = new ( MediaImportOwnerKind . CHAT , $"attachments:{Guid.NewGuid():N}" );
[CascadingParameter]
private MediaImportOwner ? ImportOwner { get ; set ; }
2025-12-18 15:00:27 +01:00
private static string TB ( string fallbackEN ) => I18N . I . T ( fallbackEN , typeof ( AttachDocuments ). Namespace , nameof ( AttachDocuments ));
2026-06-20 17:06:43 +02:00
2025-11-24 12:37:18 +01:00
[Parameter]
public string Name { get ; set ; } = string . Empty ;
2026-06-20 17:06:43 +02:00
2025-11-24 12:37:18 +01:00
[Parameter]
2025-12-28 16:50:36 +01:00
public HashSet < FileAttachment > DocumentPaths { get ; set ; } = [];
2025-11-24 12:37:18 +01:00
[Parameter]
2025-12-28 16:50:36 +01:00
public EventCallback < HashSet < FileAttachment >> DocumentPathsChanged { get ; set ; }
2025-11-24 12:37:18 +01:00
[Parameter]
2025-12-28 16:50:36 +01:00
public Func < HashSet < FileAttachment >, Task > OnChange { get ; set ; } = _ => Task . CompletedTask ;
2026-06-20 17:06:43 +02:00
2025-11-24 12:37:18 +01:00
/// <summary>
2026-09-10 15:28: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.
2025-11-24 12:37:18 +01:00
/// </summary>
2026-09-10 15:28: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.
/// </remarks>
2026-06-20 17:06:43 +02:00
[Parameter]
2025-11-24 12:37:18 +01:00
public bool CatchAllDocuments { get ; set ; }
2026-06-20 17:06:43 +02:00
2025-12-10 13:48:13 +01:00
[Parameter]
public bool UseSmallForm { get ; set ; }
2026-06-20 17:06:43 +02:00
2026-07-15 12:53:30 +02:00
/// <summary>Whether this control renders its own media status.</summary>
[Parameter]
public bool ShowMediaStatus { get ; set ; } = true ;
2026-06-20 17:06:43 +02:00
[Parameter]
public bool Disabled { get ; set ; }
2026-01-01 16:47:15 +01:00
/// <summary>
/// When true, validate media file types before attaching. Default is true. That means that
/// the user cannot attach unsupported media file types when the provider or model does not
/// support them. Set it to false in order to disable this validation. This is useful for places
/// where the user might want to prepare a template.
/// </summary>
[Parameter]
public bool ValidateMediaFileTypes { get ; set ; } = true ;
2026-06-20 17:06:43 +02:00
2025-12-30 18:30:32 +01:00
[Parameter]
public AIStudio . Settings . Provider ? Provider { get ; set ; }
2026-06-20 17:06:43 +02:00
2026-08-02 21:41:12 +02:00
/// <summary>
/// Gets or sets the optional picker and drop filter applied before standard attachment validation.
/// </summary>
[Parameter]
public FileTypeFilter []? AllowedFileTypes { get ; set ; }
2026-07-15 12:53:30 +02:00
/// <summary>Optional persisted chat that can own transcript files immediately.</summary>
[Parameter]
public ChatThread ? OwnerChat { get ; set ; }
/// <summary>Creates and persists a draft owner after media import confirmation.</summary>
[Parameter]
public Func < string , Task < ChatThread ?>> EnsureOwnerChatAsync { get ; set ; } = _ => Task . FromResult < ChatThread ?>( null );
2025-11-24 12:37:18 +01:00
[Inject]
private ILogger < AttachDocuments > Logger { get ; set ; } = null !;
2026-06-20 17:06:43 +02:00
2025-11-24 12:37:18 +01:00
[Inject]
private RustService RustService { get ; init ; } = null !;
2026-06-20 17:06:43 +02:00
2025-11-24 12:37:18 +01:00
[Inject]
private IDialogService DialogService { get ; init ; } = null !;
2025-12-11 20:44:27 +01:00
[Inject]
private PandocAvailabilityService PandocAvailabilityService { get ; init ; } = null !;
2026-07-15 12:53:30 +02:00
[Inject]
private MediaTranscriptionService MediaTranscriptionService { get ; init ; } = null !;
2025-12-10 13:48:13 +01:00
private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement . Top ;
2025-12-18 15:00:27 +01:00
private static readonly string DROP_FILES_HERE_TEXT = TB ( "Drop files here to attach them." );
2026-06-20 17:06:43 +02:00
2026-07-19 19:56:30 +02:00
private bool isFileDialogOpen ;
2026-07-15 12:53:30 +02:00
private MediaImportOwner EffectiveImportOwner => this . OwnerChat is not null
? MediaImportOwner . ForChat ( this . OwnerChat . ChatId )
: this . ImportOwner ?? this . fallbackMediaImportOwner ;
private MediaImportTarget EffectiveMediaImportTarget => new ( this . EffectiveImportOwner , string . IsNullOrWhiteSpace ( this . Name ) ? "attachments" : this . Name );
2026-07-19 19:56:30 +02:00
private bool IsUnavailable => this . Disabled || this . isFileDialogOpen || this . MediaTranscriptionService . IsBusy ( this . EffectiveImportOwner );
2026-06-20 17:06:43 +02:00
2025-11-24 12:37:18 +01:00
#region Overrides of MSGComponentBase
protected override async Task OnInitializedAsync ()
{
2026-07-15 12:53:30 +02:00
this . MediaTranscriptionService . StateChanged += this . OnMediaImportStateChanged ;
2026-09-10 15:28:08 +02:00
this . ApplyFilters ([], []);
2026-06-20 17:06:43 +02:00
2025-11-24 12:37:18 +01:00
await base . OnInitializedAsync ();
}
2026-07-15 12:53:30 +02:00
/// <summary>Rehydrates results after the component is assigned another chat or target.</summary>
protected override async Task OnParametersSetAsync ()
{
await base . OnParametersSetAsync ();
await this . SyncCompletedMediaAttachmentsAsync ();
}
/// <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 . SyncCompletedMediaAttachmentsAsync ();
await this . ConsumeStandaloneMediaOutcomeAsync ();
this . StateHasChanged ();
2026-08-25 18:53:09 +02:00
}). Observe ( $"{nameof(AttachDocuments)}: syncing media attachments" );
2026-07-15 12:53:30 +02:00
}
/// <summary>Consumes outcomes for dialog-local controls that have no chat or assistant owner surface.</summary>
private async Task ConsumeStandaloneMediaOutcomeAsync ()
{
if ( this . ImportOwner is not null || this . OwnerChat 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>Reattaches completed owner results after progress updates or navigation.</summary>
private async Task SyncCompletedMediaAttachmentsAsync ()
{
var delivery = this . MediaTranscriptionService . GetPendingDelivery ( this . EffectiveMediaImportTarget );
2026-08-02 21:41:12 +02:00
// Owners that persist their own sources have already taken the media over when the batch
// started, so re-adding the delivered transcripts here would duplicate them.
var completed = this . EffectiveImportOwner . Kind . PersistsOwnSources ()
? Array . Empty < FileAttachment >()
: delivery ?. Attachments ?? [];
2026-07-15 12:53:30 +02:00
var pending = this . OwnerChat ?. PendingMediaTranscripts ?? [];
var changed = false ;
var ownerPendingChanged = false ;
foreach ( var attachment in completed . Concat ( pending ))
changed |= this . DocumentPaths . Add ( attachment );
if ( this . OwnerChat is not null )
{
foreach ( var attachment in completed . OfType < ManagedTranscriptAttachment >())
{
if ( this . OwnerChat . PendingMediaTranscripts . All ( existing => existing . FilePath != attachment . FilePath ))
{
this . OwnerChat . PendingMediaTranscripts . Add ( attachment );
ownerPendingChanged = true ;
}
}
}
if ( changed || ownerPendingChanged )
{
await this . DocumentPathsChanged . InvokeAsync ( this . DocumentPaths );
await this . OnChange ( this . DocumentPaths );
}
if ( delivery is not null )
this . MediaTranscriptionService . AcknowledgeDelivery ( delivery );
}
/// <summary>Unsubscribes from the singleton media service.</summary>
protected override void DisposeResources ()
{
this . MediaTranscriptionService . StateChanged -= this . OnMediaImportStateChanged ;
base . DisposeResources ();
}
2025-11-24 12:37:18 +01:00
#endregion
2026-09-10 15:28:08 +02:00
/// <summary>
/// Attaches what the user dropped on the zone of this component.
/// </summary>
/// <param name="paths">The dropped paths, in the order the runtime delivered them.</param>
private async Task PathsDropped ( List < string > paths )
{
await this . AddFileBatchAsync ( paths );
await this . DocumentPathsChanged . InvokeAsync ( this . DocumentPaths );
await this . OnChange ( this . DocumentPaths );
}
2026-06-20 17:06:43 +02:00
2025-11-24 12:37:18 +01:00
private async Task AddFilesManually ()
{
2026-07-15 12:53:30 +02:00
if ( this . IsUnavailable )
2026-06-20 17:06:43 +02:00
return ;
2026-07-19 19:56:30 +02:00
this . isFileDialogOpen = true ;
try
{
2026-08-02 21:41:12 +02:00
var selectFiles = await this . RustService . SelectFiles ( T ( "Select files to attach" ), this . AllowedFileTypes );
2026-07-19 19:56:30 +02:00
if ( selectFiles . UserCancelled )
return ;
2025-11-24 12:37:18 +01:00
2026-07-19 19:56:30 +02:00
await this . AddFileBatchAsync ( selectFiles . SelectedFilePaths );
await this . DocumentPathsChanged . InvokeAsync ( this . DocumentPaths );
await this . OnChange ( this . DocumentPaths );
}
finally
{
this . isFileDialogOpen = false ;
}
2025-11-24 12:37:18 +01:00
}
2026-06-20 17:06:43 +02:00
2025-12-18 15:00:27 +01:00
private async Task OpenAttachmentsDialog ()
{
2026-07-15 12:53:30 +02:00
if ( this . IsUnavailable )
2026-06-20 17:06:43 +02:00
return ;
2026-07-15 12:53:30 +02:00
var previousAttachments = this . DocumentPaths . ToHashSet ();
2025-12-18 15:00:27 +01:00
this . DocumentPaths = await ReviewAttachmentsDialog . OpenDialogAsync ( this . DialogService , this . DocumentPaths );
2026-07-15 12:53:30 +02:00
foreach ( var removedAttachment in previousAttachments . Except ( this . DocumentPaths ))
ManagedTranscriptAttachment . TryDeleteOwnedFile ( removedAttachment );
this . ReconcileOwnerPendingTranscripts ();
2025-12-18 15:00:27 +01:00
}
2025-11-24 12:37:18 +01:00
private async Task ClearAllFiles ()
{
2026-07-15 12:53:30 +02:00
if ( this . IsUnavailable )
2026-06-20 17:06:43 +02:00
return ;
2026-07-15 12:53:30 +02:00
foreach ( var attachment in this . DocumentPaths )
ManagedTranscriptAttachment . TryDeleteOwnedFile ( attachment );
2025-11-24 12:37:18 +01:00
this . DocumentPaths . Clear ();
2026-07-15 12:53:30 +02:00
this . ReconcileOwnerPendingTranscripts ();
2025-11-24 12:37:18 +01:00
await this . DocumentPathsChanged . InvokeAsync ( this . DocumentPaths );
await this . OnChange ( this . DocumentPaths );
}
2025-12-28 16:50:36 +01:00
private async Task RemoveDocument ( FileAttachment fileAttachment )
2025-11-24 12:37:18 +01:00
{
2026-07-15 12:53:30 +02:00
if ( this . IsUnavailable )
2026-06-20 17:06:43 +02:00
return ;
2025-12-28 16:50:36 +01:00
this . DocumentPaths . Remove ( fileAttachment );
2026-07-15 12:53:30 +02:00
ManagedTranscriptAttachment . TryDeleteOwnedFile ( fileAttachment );
this . ReconcileOwnerPendingTranscripts ();
2025-12-28 16:50:36 +01:00
2025-11-24 12:37:18 +01:00
await this . DocumentPathsChanged . InvokeAsync ( this . DocumentPaths );
await this . OnChange ( this . DocumentPaths );
}
2026-07-15 12:53:30 +02:00
/// <summary>Keeps persisted chat-draft transcript references aligned with the composer.</summary>
private void ReconcileOwnerPendingTranscripts ()
{
if ( this . OwnerChat is null )
return ;
var retainedPaths = this . DocumentPaths . Select ( attachment => attachment . FilePath ). ToHashSet ( StringComparer . Ordinal );
this . OwnerChat . PendingMediaTranscripts . RemoveAll ( attachment => ! retainedPaths . Contains ( attachment . FilePath ));
}
private async Task AddFileBatchAsync ( IEnumerable < string > paths )
{
2026-07-19 19:56:30 +02:00
var pathList = paths . ToList ();
2026-08-02 21:41:12 +02:00
if ( this . AllowedFileTypes is { Length : > 0 })
{
var rejectedPaths = pathList . Where ( path => ! FileTypes . IsAllowedPath ( path , this . AllowedFileTypes )). ToArray ();
pathList . RemoveAll ( path => rejectedPaths . Contains ( path , StringComparer . Ordinal ));
if ( rejectedPaths . Length > 0 )
await this . MessageBus . SendWarning ( new ( Icons . Material . Filled . Warning , this . T ( "Some files do not use an allowed format and were not attached." )));
}
2026-07-19 19:56:30 +02:00
var inaccessiblePaths = pathList . Where ( path => ! File . Exists ( path )). ToList ();
if ( inaccessiblePaths . Count > 0 )
{
this . Logger . LogWarning ( "Could not access {Count} dropped or selected file(s): {Paths}" , inaccessiblePaths . Count , string . Join ( ", " , inaccessiblePaths ));
await this . MessageBus . SendWarning ( new (
Icons . Material . Filled . Warning ,
this . T ( "Some files could not be accessed. Please select them with the file chooser instead." )));
}
var existingPaths = pathList . Except ( inaccessiblePaths ). ToList ();
2026-07-15 12:53:30 +02:00
var mediaPaths = existingPaths . Where ( IsTranscribableMedia ). ToList ();
var regularPaths = existingPaths . Except ( mediaPaths ). ToList ();
2026-08-10 16:37:44 +02:00
//
// Only the formats we convert with Pandoc depend on a Pandoc installation. Everything
// else, PDFs in particular, is read by the Rust runtime itself, so those files must stay
// attachable without Pandoc.
//
var canAddPandocFiles = true ;
if ( regularPaths . Any ( FileTypes . RequiresPandoc ))
2026-07-15 12:53:30 +02:00
{
var pandocState = await this . PandocAvailabilityService . EnsureAvailabilityAsync (
showSuccessMessage : false ,
showDialog : true );
2026-08-10 16:37:44 +02:00
canAddPandocFiles = pandocState . IsAvailable ;
2026-07-15 12:53:30 +02:00
}
foreach ( var path in regularPaths )
{
2026-08-10 16:37:44 +02:00
if (! canAddPandocFiles && FileTypes . RequiresPandoc ( path ))
{
this . Logger . LogWarning ( "The file '{Path}' needs Pandoc and was not attached." , path );
continue ;
}
2026-07-15 12:53:30 +02:00
2026-08-02 21:41:12 +02:00
if (! await FileExtensionValidation . IsExtensionValidWithNotifyAsync ( FileExtensionValidation . UseCase . ATTACHING_CONTENT , path , this . ValidateMediaFileTypes , this . Provider ))
2026-07-15 12:53:30 +02:00
continue ;
2026-08-10 16:37:44 +02:00
2026-07-15 12:53:30 +02:00
this . DocumentPaths . Add ( FileAttachment . FromPath ( path ));
}
if ( mediaPaths . Count is 0 )
return ;
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 ;
}
var names = string . Join ( '\n' , mediaPaths . Select ( path => $"- {Markdown.EscapeInlineText(Path.GetFileName(path))}" ));
var message = this . T ( "The selected audio and video files will be prepared locally. Their audio will then be uploaded to the configured transcription provider." );
var dialogParameters = new DialogParameters < ConfirmDialog >
{
{
x => x . MarkdownBody ,
$"""
{message}
{names}
"""
},
};
var dialogReference = await this . DialogService . ShowAsync < ConfirmDialog >(
this . T ( "Transcribe media files" ),
dialogParameters ,
DialogOptions . FULLSCREEN );
var dialogResult = await dialogReference . Result ;
if ( dialogResult is null || dialogResult . Canceled )
return ;
if ( this . OwnerChat is null )
this . OwnerChat = await this . EnsureOwnerChatAsync ( mediaPaths [ 0 ]);
2026-08-02 21:41:12 +02:00
// Owners that persist their own sources show the file right away and keep it next to the
// stored document, instead of waiting for the transcription to be delivered back.
if ( this . EffectiveImportOwner . Kind . PersistsOwnSources ())
{
foreach ( var mediaPath in mediaPaths )
this . DocumentPaths . Add ( FileAttachment . FromPath ( mediaPath ));
await this . DocumentPathsChanged . InvokeAsync ( this . DocumentPaths );
await this . OnChange ( this . DocumentPaths );
}
2026-07-15 12:53:30 +02:00
this . MediaTranscriptionService . TryStartAttachmentBatch ( mediaPaths , this . EffectiveMediaImportTarget , this . OwnerChat );
}
private static bool IsTranscribableMedia ( string path ) => FileTypes . IsAllowedPath ( path , FileTypes . AUDIO ) || FileTypes . IsAllowedPath ( path , FileTypes . VIDEO );
2025-11-24 12:37:18 +01:00
/// <summary>
2025-12-28 16:50:36 +01:00
/// The user might want to check what we actually extract from his file and therefore give the LLM as an input.
2025-11-24 12:37:18 +01:00
/// </summary>
2025-12-28 16:50:36 +01:00
/// <param name="fileAttachment">The file to check.</param>
private async Task InvestigateFile ( FileAttachment fileAttachment )
2025-11-24 12:37:18 +01:00
{
2025-12-05 20:48:54 +01:00
var dialogParameters = new DialogParameters < DocumentCheckDialog >
{
2025-12-30 18:30:32 +01:00
{ x => x . Document , fileAttachment },
2025-12-05 20:48:54 +01:00
};
await this . DialogService . ShowAsync < DocumentCheckDialog >( T ( "Document Preview" ), dialogParameters , DialogOptions . FULLSCREEN );
2025-11-24 12:37:18 +01:00
}
}