Improved file loading so every caller reports failures instead of empty content

This commit is contained in:
Thorsten Sommer 2026-08-10 09:31:11 +02:00
parent 582346644d
commit c7a88e81b9
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
10 changed files with 129 additions and 30 deletions

View File

@ -716,7 +716,21 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
continue; continue;
} }
var fileContent = (await this.RustService.ReadArbitraryFileData(document.FilePath, int.MaxValue)).Content; var extraction = await this.RustService.ReadArbitraryFileData(document.FilePath, int.MaxValue);
if (!extraction.HasUsableContent)
{
this.Logger.LogError("Reading the document '{FilePath}' failed and it will not be analyzed: code={ErrorCode}, message='{ErrorMessage}'.", document.FilePath, extraction.ErrorCode, extraction.ErrorMessage);
await this.MessageBus.SendError(new(Icons.Material.Filled.Description, extraction.ToUserMessage(document.FileName)));
continue;
}
if (extraction.Outcome is FileExtractionOutcome.PARTIAL)
{
this.Logger.LogWarning("Parts of the document '{FilePath}' could not be read: pages={FailedPages}.", document.FilePath, string.Join(", ", extraction.FailedPages));
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(document.FileName)));
}
var fileContent = extraction.Content;
sb.AppendLine($""" sb.AppendLine($"""
## DOCUMENT {numDocuments}: ## DOCUMENT {numDocuments}:

View File

@ -579,9 +579,10 @@ public partial class AssistantPromptOptimizer : AssistantBaseCore<SettingsDialog
try try
{ {
this.isLoadingCustomPromptGuide = true; this.isLoadingCustomPromptGuide = true;
this.customPromptingGuidelineContent = await UserFile.LoadFileData(fileAttachment.FilePath, this.RustService, this.DialogService);
if (string.IsNullOrWhiteSpace(this.customPromptingGuidelineContent)) // A failure was already reported by UserFile.LoadFileData, so we only keep the content:
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Description, T("The custom prompt guide file is empty or could not be read."))); var extraction = await UserFile.LoadFileData(fileAttachment.FilePath, this.RustService, this.DialogService);
this.customPromptingGuidelineContent = extraction.HasUsableContent ? extraction.Content : string.Empty;
} }
catch catch
{ {

View File

@ -382,7 +382,21 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
continue; continue;
} }
var fileContent = (await this.RustService.ReadArbitraryFileData(document.FilePath, int.MaxValue)).Content; var extraction = await this.RustService.ReadArbitraryFileData(document.FilePath, int.MaxValue);
if (!extraction.HasUsableContent)
{
this.Logger.LogError("Reading the document '{FilePath}' failed and it will not be used: code={ErrorCode}, message='{ErrorMessage}'.", document.FilePath, extraction.ErrorCode, extraction.ErrorMessage);
await this.MessageBus.SendError(new(Icons.Material.Filled.Description, extraction.ToUserMessage(document.FileName)));
continue;
}
if (extraction.Outcome is FileExtractionOutcome.PARTIAL)
{
this.Logger.LogWarning("Parts of the document '{FilePath}' could not be read: pages={FailedPages}.", document.FilePath, string.Join(", ", extraction.FailedPages));
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(document.FileName)));
}
var fileContent = extraction.Content;
sb.AppendLine($""" sb.AppendLine($"""
## DOCUMENT {numDocuments}: ## DOCUMENT {numDocuments}:

View File

@ -317,7 +317,7 @@ public sealed class ContentText : IContent
if (!pandocIsUsable && FileTypes.RequiresPandoc(document.FilePath)) if (!pandocIsUsable && FileTypes.RequiresPandoc(document.FilePath))
{ {
LOGGER.LogWarning("The file attachment '{FilePath}' needs Pandoc and will be skipped.", document.FilePath); LOGGER.LogWarning("The file attachment '{FilePath}' needs Pandoc and will be skipped.", document.FilePath);
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Description, string.Format(TB("The file '{0}' needs Pandoc to be read and was not sent."), document.FileName))); await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Description, FileExtractionErrorCode.PANDOC_UNAVAILABLE.ToUserMessage(document.FileName)));
continue; continue;
} }

View File

@ -324,8 +324,13 @@ public partial class ReadFileContent : MSGComponentBase
try try
{ {
var fileContent = await UserFile.LoadFileData(filePath, this.RustService, this.DialogService); var extraction = await UserFile.LoadFileData(filePath, this.RustService, this.DialogService);
await this.ApplyFileContentAsync(fileContent, filePath);
// The failure was already reported by UserFile.LoadFileData, so we only stop here:
if (!extraction.HasUsableContent)
return false;
await this.ApplyFileContentAsync(extraction.Content, filePath);
this.Logger.LogInformation("Successfully loaded file content: {FilePath}", filePath); this.Logger.LogInformation("Successfully loaded file content: {FilePath}", filePath);
return true; return true;
} }

View File

@ -33,6 +33,12 @@
@T("The specified file could not be found. The file have been moved, deleted, renamed, or is otherwise inaccessible.") @T("The specified file could not be found. The file have been moved, deleted, renamed, or is otherwise inaccessible.")
</MudAlert> </MudAlert>
} }
else if (this.loadFailureMessage is not null)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Filled" Class="my-2">
@this.loadFailureMessage
</MudAlert>
}
else else
{ {
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Outlined="true" PanelClass="pa-2" Class="mb-2"> <MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Outlined="true" PanelClass="pa-2" Class="mb-2">

View File

@ -20,6 +20,11 @@ public partial class DocumentCheckDialog : MSGComponentBase
[Parameter] [Parameter]
public string FileContent { get; set; } = string.Empty; public string FileContent { get; set; } = string.Empty;
/// <summary>
/// Set when reading the file failed, so the dialog shows the reason instead of empty content.
/// </summary>
private string? loadFailureMessage;
[Inject] [Inject]
private RustService RustService { get; init; } = null!; private RustService RustService { get; init; } = null!;
@ -38,14 +43,22 @@ public partial class DocumentCheckDialog : MSGComponentBase
{ {
if (!this.Document.IsImage) if (!this.Document.IsImage)
{ {
var fileContent = await UserFile.LoadFileData(this.Document.FilePath, this.RustService, this.DialogService); var extraction = await UserFile.LoadFileData(this.Document.FilePath, this.RustService, this.DialogService);
this.FileContent = fileContent; this.FileContent = extraction.Content;
//
// This dialog exists so the user can check what we hand to the AI. Showing an
// empty document when reading the file failed would answer that question wrong.
//
if (!extraction.HasUsableContent)
this.loadFailureMessage = extraction.ToUserMessage(this.Document.FileName);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
this.Logger.LogError(ex, "Failed to load file content from '{FilePath}'", this.Document); this.Logger.LogError(ex, "Failed to load file content from '{FilePath}'", this.Document);
this.FileContent = string.Empty; this.FileContent = string.Empty;
this.loadFailureMessage = FileExtractionErrorCode.INTERNAL.ToUserMessage(this.Document.FileName);
} }
this.StateHasChanged(); this.StateHasChanged();

View File

@ -37,6 +37,11 @@ public enum FileExtractionErrorCode
// Codes reported by the app itself: // Codes reported by the app itself:
// //
/// <summary>
/// Reading the file needs Pandoc, which is not available.
/// </summary>
PANDOC_UNAVAILABLE,
/// <summary> /// <summary>
/// The runtime answered with an unsuccessful HTTP status. /// The runtime answered with an unsuccessful HTTP status.
/// </summary> /// </summary>

View File

@ -20,7 +20,19 @@ internal static class FileExtractionResultExtensions
/// <param name="result">The extraction result.</param> /// <param name="result">The extraction result.</param>
/// <param name="fileName">The name of the file, as shown to the user.</param> /// <param name="fileName">The name of the file, as shown to the user.</param>
/// <returns>The localized message.</returns> /// <returns>The localized message.</returns>
internal static string ToUserMessage(this FileExtractionResult result, string fileName) => string.Format(ToUserMessageFormat(result.ErrorCode), fileName); internal static string ToUserMessage(this FileExtractionResult result, string fileName) => result.ErrorCode.ToUserMessage(fileName);
/// <summary>
/// Gets the localized message which explains why a file could not be read.
/// </summary>
/// <remarks>
/// This overload exists for the places which know the reason before an extraction was even
/// attempted, so both ways of skipping a file tell the user the same thing.
/// </remarks>
/// <param name="code">The stable failure code.</param>
/// <param name="fileName">The name of the file, as shown to the user.</param>
/// <returns>The localized message.</returns>
internal static string ToUserMessage(this FileExtractionErrorCode code, string fileName) => string.Format(ToUserMessageFormat(code), fileName);
/// <summary> /// <summary>
/// Gets the localized message for a file which was read, but lost some of its pages. /// Gets the localized message for a file which was read, but lost some of its pages.
@ -45,6 +57,8 @@ internal static class FileExtractionResultExtensions
FileExtractionErrorCode.NOT_A_VALID_SPREADSHEET => TB("The file '{0}' is not a readable spreadsheet and was not sent. It might be damaged or transferred incompletely."), FileExtractionErrorCode.NOT_A_VALID_SPREADSHEET => TB("The file '{0}' is not a readable spreadsheet and was not sent. It might be damaged or transferred incompletely."),
FileExtractionErrorCode.PDF_ENCRYPTED => TB("The file '{0}' is protected and could not be opened, so it was not sent."), FileExtractionErrorCode.PDF_ENCRYPTED => TB("The file '{0}' is protected and could not be opened, so it was not sent."),
FileExtractionErrorCode.PDFIUM_UNAVAILABLE => TB("AI Studio was not able to start its PDF engine, so the file '{0}' was not sent."), FileExtractionErrorCode.PDFIUM_UNAVAILABLE => TB("AI Studio was not able to start its PDF engine, so the file '{0}' was not sent."),
FileExtractionErrorCode.PANDOC_UNAVAILABLE => TB("Reading the file '{0}' needs Pandoc, which is not available, so the file was not sent."),
FileExtractionErrorCode.NO_TEXT_EXTRACTED => TB("No text could be read from the file '{0}', so it was not sent. The file might consist of scanned images without a text layer."), FileExtractionErrorCode.NO_TEXT_EXTRACTED => TB("No text could be read from the file '{0}', so it was not sent. The file might consist of scanned images without a text layer."),
FileExtractionErrorCode.NO_CONTENT => TB("The file '{0}' did not provide any content and was not sent."), FileExtractionErrorCode.NO_CONTENT => TB("The file '{0}' did not provide any content and was not sent."),
FileExtractionErrorCode.FORMAT_DETECTION_FAILED => TB("The file type of '{0}' could not be determined, so the file was not sent."), FileExtractionErrorCode.FORMAT_DETECTION_FAILED => TB("The file type of '{0}' could not be determined, so the file was not sent."),

View File

@ -1,5 +1,6 @@
using AIStudio.Dialogs; using AIStudio.Dialogs;
using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services; using AIStudio.Tools.Services;
using DialogOptions = AIStudio.Dialogs.DialogOptions; using DialogOptions = AIStudio.Dialogs.DialogOptions;
@ -14,38 +15,64 @@ public static class UserFile
/// <summary> /// <summary>
/// Attempts to load the content of a file at the specified path, ensuring Pandoc is installed and available before proceeding. /// Attempts to load the content of a file at the specified path, ensuring Pandoc is installed and available before proceeding.
/// </summary> /// </summary>
/// <remarks>
/// This is the one place which reports a failed load to the user, so callers neither have to
/// repeat that nor may they treat a failure as an empty file.
/// </remarks>
/// <param name="filePath">The full path to the file to be read. Must not be null or empty.</param> /// <param name="filePath">The full path to the file to be read. Must not be null or empty.</param>
/// <param name="rustService">Rust service used to read file content.</param> /// <param name="rustService">Rust service used to read file content.</param>
/// <param name="dialogService">Dialogservice used to display the Pandoc installation dialog if needed.</param> /// <param name="dialogService">Dialogservice used to display the Pandoc installation dialog if needed.</param>
public static async Task<string> LoadFileData(string filePath, RustService rustService, IDialogService dialogService) /// <returns>The result of reading the file.</returns>
public static async Task<FileExtractionResult> LoadFileData(string filePath, RustService rustService, IDialogService dialogService)
{ {
if (string.IsNullOrEmpty(filePath)) if (string.IsNullOrEmpty(filePath))
{ {
LOGGER.LogError("Can't load from an empty or null file path."); LOGGER.LogError("Can't load from an empty or null file path.");
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("The file path is null or empty and the file therefore can not be loaded."))); await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("The file path is null or empty and the file therefore can not be loaded.")));
return FileExtractionResult.Failed(FileExtractionErrorCode.INVALID_REQUEST, "The file path is null or empty.");
} }
// Ensure that Pandoc is installed and ready: var fileName = Path.GetFileName(filePath);
var pandocState = await Pandoc.CheckAvailabilityAsync(rustService, showSuccessMessage: false);
if (!pandocState.IsAvailable) //
// Ensure that Pandoc is installed and ready. This is only needed for the formats we
// convert with it: PDFs and the other document types are read by the Rust runtime itself.
//
if (FileTypes.RequiresPandoc(filePath))
{ {
var dialogParameters = new DialogParameters<PandocDialog> var pandocState = await Pandoc.CheckAvailabilityAsync(rustService, showSuccessMessage: false);
{
{ x => x.ShowInitialResultInSnackbar, false },
};
var dialogReference = await dialogService.ShowAsync<PandocDialog>(TB("Pandoc Installation"), dialogParameters, DialogOptions.FULLSCREEN);
await dialogReference.Result;
pandocState = await Pandoc.CheckAvailabilityAsync(rustService, showSuccessMessage: true);
if (!pandocState.IsAvailable) if (!pandocState.IsAvailable)
{ {
LOGGER.LogError("Pandoc is not available after installation attempt."); var dialogParameters = new DialogParameters<PandocDialog>
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("Pandoc may be required for importing files."))); {
{ x => x.ShowInitialResultInSnackbar, false },
};
var dialogReference = await dialogService.ShowAsync<PandocDialog>(TB("Pandoc Installation"), dialogParameters, DialogOptions.FULLSCREEN);
await dialogReference.Result;
pandocState = await Pandoc.CheckAvailabilityAsync(rustService, showSuccessMessage: true);
if (!pandocState.IsAvailable)
{
LOGGER.LogError("Pandoc is not available after installation attempt, so '{FilePath}' cannot be read.", filePath);
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, FileExtractionErrorCode.PANDOC_UNAVAILABLE.ToUserMessage(fileName)));
return FileExtractionResult.Failed(FileExtractionErrorCode.PANDOC_UNAVAILABLE, "Pandoc is required to read this file, but it is not available.");
}
} }
} }
var fileContent = await rustService.ReadArbitraryFileData(filePath, int.MaxValue); var result = await rustService.ReadArbitraryFileData(filePath, int.MaxValue);
return fileContent.Content; if (!result.HasUsableContent)
{
LOGGER.LogError("Reading the file '{FilePath}' failed: code={ErrorCode}, message='{ErrorMessage}'.", filePath, result.ErrorCode, result.ErrorMessage);
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Description, result.ToUserMessage(fileName)));
}
else if (result.Outcome is FileExtractionOutcome.PARTIAL)
{
LOGGER.LogWarning("Parts of the file '{FilePath}' could not be read: pages={FailedPages}.", filePath, string.Join(", ", result.FailedPages));
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.Description, result.ToPartialUserMessage(fileName)));
}
return result;
} }
} }