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;
}
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($"""
## DOCUMENT {numDocuments}:

View File

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

View File

@ -382,7 +382,21 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
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($"""
## DOCUMENT {numDocuments}:

View File

@ -317,7 +317,7 @@ public sealed class ContentText : IContent
if (!pandocIsUsable && FileTypes.RequiresPandoc(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;
}

View File

@ -324,8 +324,13 @@ public partial class ReadFileContent : MSGComponentBase
try
{
var fileContent = await UserFile.LoadFileData(filePath, this.RustService, this.DialogService);
await this.ApplyFileContentAsync(fileContent, filePath);
var extraction = await UserFile.LoadFileData(filePath, this.RustService, this.DialogService);
// 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);
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.")
</MudAlert>
}
else if (this.loadFailureMessage is not null)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Filled" Class="my-2">
@this.loadFailureMessage
</MudAlert>
}
else
{
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Outlined="true" PanelClass="pa-2" Class="mb-2">

View File

@ -21,6 +21,11 @@ public partial class DocumentCheckDialog : MSGComponentBase
[Parameter]
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]
private RustService RustService { get; init; } = null!;
@ -38,14 +43,22 @@ public partial class DocumentCheckDialog : MSGComponentBase
{
if (!this.Document.IsImage)
{
var fileContent = await UserFile.LoadFileData(this.Document.FilePath, this.RustService, this.DialogService);
this.FileContent = fileContent;
var extraction = await UserFile.LoadFileData(this.Document.FilePath, this.RustService, this.DialogService);
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)
{
this.Logger.LogError(ex, "Failed to load file content from '{FilePath}'", this.Document);
this.FileContent = string.Empty;
this.loadFailureMessage = FileExtractionErrorCode.INTERNAL.ToUserMessage(this.Document.FileName);
}
this.StateHasChanged();

View File

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

View File

@ -20,7 +20,19 @@ internal static class FileExtractionResultExtensions
/// <param name="result">The extraction result.</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 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>
/// 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.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.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_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."),

View File

@ -1,5 +1,6 @@
using AIStudio.Dialogs;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
@ -14,38 +15,64 @@ public static class UserFile
/// <summary>
/// Attempts to load the content of a file at the specified path, ensuring Pandoc is installed and available before proceeding.
/// </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="rustService">Rust service used to read file content.</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))
{
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.")));
return FileExtractionResult.Failed(FileExtractionErrorCode.INVALID_REQUEST, "The file path is null or empty.");
}
// Ensure that Pandoc is installed and ready:
var pandocState = await Pandoc.CheckAvailabilityAsync(rustService, showSuccessMessage: false);
if (!pandocState.IsAvailable)
var fileName = Path.GetFileName(filePath);
//
// 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>
{
{ 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);
var pandocState = await Pandoc.CheckAvailabilityAsync(rustService, showSuccessMessage: false);
if (!pandocState.IsAvailable)
{
LOGGER.LogError("Pandoc is not available after installation attempt.");
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("Pandoc may be required for importing files.")));
var dialogParameters = new DialogParameters<PandocDialog>
{
{ 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);
return fileContent.Content;
var result = await rustService.ReadArbitraryFileData(filePath, int.MaxValue);
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;
}
}