From cf4661d1ad89067a02ace2d5a484663ab3868c89 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 10 Aug 2026 08:46:05 +0200 Subject: [PATCH] Fixed empty file content being sent to the AI when reading a file fails --- app/MindWork AI Studio/Chat/ContentText.cs | 70 ++++++++++++++----- .../Tools/FileExtractionResultExtensions.cs | 55 +++++++++++++++ .../Tools/Services/RustService.cs | 1 + 3 files changed, 109 insertions(+), 17 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/FileExtractionResultExtensions.cs diff --git a/app/MindWork AI Studio/Chat/ContentText.cs b/app/MindWork AI Studio/Chat/ContentText.cs index d5fca9ec..906c0ee1 100644 --- a/app/MindWork AI Studio/Chat/ContentText.cs +++ b/app/MindWork AI Studio/Chat/ContentText.cs @@ -14,6 +14,7 @@ namespace AIStudio.Chat; public sealed class ContentText : IContent { private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ContentText).Namespace, nameof(ContentText)); /// @@ -266,12 +267,18 @@ public sealed class ContentText : IContent // Get the list of existing documents: var existingDocuments = normalizedAttachments.Where(x => x.Type is FileAttachmentType.DOCUMENT && x.Exists).ToList(); - // Log warning for missing files: + // + // Report missing files. We tell the user about them instead of only logging: on a + // network drive, a file which is temporarily unreachable looks exactly like a deleted + // one, and silently dropping it would let the AI answer without that document. + // var missingDocuments = normalizedAttachments.Except(existingDocuments).Where(x => x.Type is FileAttachmentType.DOCUMENT).ToList(); - if (missingDocuments.Count > 0) - foreach (var missingDocument in missingDocuments) - LOGGER.LogWarning("File attachment no longer exists and will be skipped: '{MissingDocument}'.", missingDocument.FilePath); - + foreach (var missingDocument in missingDocuments) + { + LOGGER.LogWarning("File attachment no longer exists and will be skipped: '{MissingDocument}'.", missingDocument.FilePath); + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.FindInPage, string.Format(TB("The file '{0}' is currently not available and was not sent."), missingDocument.FileName))); + } + // Only proceed if there are existing, allowed documents: if (existingDocuments.Count > 0) { @@ -284,8 +291,12 @@ public sealed class ContentText : IContent LOGGER.LogWarning("File attachments could not be processed because the Pandoc version check failed."); else { - sb.AppendLine(); - sb.AppendLine("The following files are attached to this message:"); + // + // The document blocks are collected separately, so we only announce attached + // files when at least one of them could actually be read. Announcing files we + // then hand over as empty blocks makes the AI answer about an empty document. + // + var documentBlocks = new StringBuilder(); foreach(var document in existingDocuments) { if (document.IsForbidden) @@ -293,16 +304,41 @@ public sealed class ContentText : IContent LOGGER.LogWarning("File attachment '{FilePath}' has a forbidden file type and will be skipped.", document.FilePath); continue; } - - sb.AppendLine(); - sb.AppendLine("---------------------------------------"); - sb.AppendLine($"File path: {document.FilePath}"); - sb.AppendLine("File content:"); - sb.AppendLine("````"); - sb.AppendLine((await Program.RUST_SERVICE.ReadArbitraryFileData(document.FilePath, int.MaxValue)).Content); - sb.AppendLine("````"); + + var extraction = await Program.RUST_SERVICE.ReadArbitraryFileData(document.FilePath, int.MaxValue); + if (!extraction.HasUsableContent) + { + LOGGER.LogError("Reading the file attachment '{FilePath}' failed and it will not be sent: code={ErrorCode}, message='{ErrorMessage}'.", document.FilePath, extraction.ErrorCode, extraction.ErrorMessage); + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Description, extraction.ToUserMessage(document.FileName))); + continue; + } + + // + // The file is usable, but we lost parts of it. The user has to know which + // parts are missing, because the answer will be based on the rest. + // + if (extraction.Outcome is FileExtractionOutcome.PARTIAL) + { + LOGGER.LogWarning("Parts of the file attachment '{FilePath}' could not be read: pages={FailedPages}.", document.FilePath, string.Join(", ", extraction.FailedPages)); + await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(document.FileName))); + } + + documentBlocks.AppendLine(); + documentBlocks.AppendLine("---------------------------------------"); + documentBlocks.AppendLine($"File path: {document.FilePath}"); + documentBlocks.AppendLine("File content:"); + documentBlocks.AppendLine("````"); + documentBlocks.AppendLine(extraction.Content); + documentBlocks.AppendLine("````"); } - + + if (documentBlocks.Length > 0) + { + sb.AppendLine(); + sb.AppendLine("The following files are attached to this message:"); + sb.Append(documentBlocks); + } + var numImages = normalizedAttachments.Count(x => x is { IsImage: true, Exists: true }); if (numImages > 0) { @@ -321,4 +357,4 @@ public sealed class ContentText : IContent /// The text content. /// public string Text { get; set; } = string.Empty; -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/FileExtractionResultExtensions.cs b/app/MindWork AI Studio/Tools/FileExtractionResultExtensions.cs new file mode 100644 index 00000000..f8da220c --- /dev/null +++ b/app/MindWork AI Studio/Tools/FileExtractionResultExtensions.cs @@ -0,0 +1,55 @@ +using AIStudio.Tools.PluginSystem; + +namespace AIStudio.Tools; + +/// +/// Translates the stable failure codes of a file extraction into user-facing text. +/// +/// +/// The message which travels with a result is technical: it comes from the runtime, names the +/// library which failed, and belongs into the log. The texts here are the counterpart for the +/// user, and they name what the user can act on, such as an unavailable network drive. +/// +internal static class FileExtractionResultExtensions +{ + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(FileExtractionResultExtensions).Namespace, nameof(FileExtractionResultExtensions)); + + /// + /// Gets the localized message which explains why a file could not be read. + /// + /// The extraction result. + /// The name of the file, as shown to the user. + /// The localized message. + internal static string ToUserMessage(this FileExtractionResult result, string fileName) => string.Format(ToUserMessageFormat(result.ErrorCode), fileName); + + /// + /// Gets the localized message for a file which was read, but lost some of its pages. + /// + /// The extraction result. + /// The name of the file, as shown to the user. + /// The localized message. + internal static string ToPartialUserMessage(this FileExtractionResult result, string fileName) + { + if (result.FailedPages.Count == 0) + return string.Format(TB("Parts of the file '{0}' could not be read. The remaining content was sent."), fileName); + + return string.Format(TB("The pages {1} of the file '{0}' could not be read. The remaining content was sent."), fileName, string.Join(", ", result.FailedPages)); + } + + private static string ToUserMessageFormat(FileExtractionErrorCode code) => code switch + { + FileExtractionErrorCode.FILE_NOT_FOUND => TB("The file '{0}' does not exist anymore and was not sent."), + FileExtractionErrorCode.FILE_NOT_READABLE => TB("The file '{0}' could not be read and was not sent. When the file is stored on a network drive, the drive might be unavailable, or another program might be blocking the file."), + FileExtractionErrorCode.TIMEOUT => TB("Reading the file '{0}' took too long and was stopped, so the file was not sent. When the file is stored on a network drive, the connection might be slow or interrupted."), + FileExtractionErrorCode.NOT_A_VALID_PDF => TB("The file '{0}' is not a readable PDF 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.PDFIUM_UNAVAILABLE => TB("AI Studio was not able to start its PDF engine, so the file '{0}' 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."), + FileExtractionErrorCode.UNSUPPORTED => TB("The file type of '{0}' is not supported, so the file was not sent."), + + _ => TB("The file '{0}' could not be read and was not sent."), + }; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.cs b/app/MindWork AI Studio/Tools/Services/RustService.cs index d9e72ff1..88fddfe6 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.cs @@ -117,6 +117,7 @@ public sealed partial class RustService : BackgroundService public override void Dispose() { this.http.Dispose(); + this.extractionHttp.Dispose(); this.userLanguageLock.Dispose(); this.userNameLock.Dispose(); base.Dispose();