Fixed PDFs and other formats requiring Pandoc although they do not use it

This commit is contained in:
Thorsten Sommer 2026-08-10 09:09:37 +02:00
parent cf4661d1ad
commit 2cc27859ce
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
3 changed files with 103 additions and 63 deletions

View File

@ -5,6 +5,7 @@ using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.RAG.RAGProcesses;
using AIStudio.Tools.Rust;
namespace AIStudio.Chat;
@ -282,70 +283,84 @@ public sealed class ContentText : IContent
// Only proceed if there are existing, allowed documents:
if (existingDocuments.Count > 0)
{
// Check Pandoc availability once before processing file attachments
var pandocState = await Pandoc.CheckAvailabilityAsync(Program.RUST_SERVICE, showMessages: true, showSuccessMessage: false);
if (!pandocState.IsAvailable)
LOGGER.LogWarning("File attachments could not be processed because Pandoc is not available.");
else if (!pandocState.CheckWasSuccessful)
LOGGER.LogWarning("File attachments could not be processed because the Pandoc version check failed.");
else
//
// Pandoc is only needed for the few formats we convert with it. PDFs, text files,
// spreadsheets, and presentations are read by the runtime itself, so a missing
// Pandoc installation must not stop them.
//
var pandocIsUsable = true;
if (existingDocuments.Any(document => FileTypes.RequiresPandoc(document.FilePath)))
{
var pandocState = await Pandoc.CheckAvailabilityAsync(Program.RUST_SERVICE, showMessages: true, showSuccessMessage: false);
pandocIsUsable = pandocState is { IsAvailable: true, CheckWasSuccessful: true };
if (!pandocState.IsAvailable)
LOGGER.LogWarning("File attachments which need Pandoc could not be processed because Pandoc is not available.");
else if (!pandocState.CheckWasSuccessful)
LOGGER.LogWarning("File attachments which need Pandoc could not be processed because the Pandoc version check failed.");
}
//
// 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)
{
LOGGER.LogWarning("File attachment '{FilePath}' has a forbidden file type and will be skipped.", document.FilePath);
continue;
}
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)));
continue;
}
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 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.
// 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.
//
var documentBlocks = new StringBuilder();
foreach(var document in existingDocuments)
if (extraction.Outcome is FileExtractionOutcome.PARTIAL)
{
if (document.IsForbidden)
{
LOGGER.LogWarning("File attachment '{FilePath}' has a forbidden file type and will be skipped.", document.FilePath);
continue;
}
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("````");
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)));
}
if (documentBlocks.Length > 0)
{
sb.AppendLine();
sb.AppendLine("The following files are attached to this message:");
sb.Append(documentBlocks);
}
documentBlocks.AppendLine();
documentBlocks.AppendLine("---------------------------------------");
documentBlocks.AppendLine($"File path: {document.FilePath}");
documentBlocks.AppendLine("File content:");
documentBlocks.AppendLine("````");
documentBlocks.AppendLine(extraction.Content);
documentBlocks.AppendLine("````");
}
var numImages = normalizedAttachments.Count(x => x is { IsImage: true, Exists: true });
if (numImages > 0)
{
sb.AppendLine();
sb.AppendLine($"Additionally, there are {numImages} image file(s) attached to this message. ");
sb.AppendLine("Please consider them as part of the message content and use them to answer accordingly.");
}
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)
{
sb.AppendLine();
sb.AppendLine($"Additionally, there are {numImages} image file(s) attached to this message. ");
sb.AppendLine("Please consider them as part of the message content and use them to answer accordingly.");
}
}
}

View File

@ -443,19 +443,27 @@ public partial class AttachDocuments : MSGComponentBase
var mediaPaths = existingPaths.Where(IsTranscribableMedia).ToList();
var regularPaths = existingPaths.Except(mediaPaths).ToList();
var canAddRegularFiles = true;
if (regularPaths.Count > 0)
//
// 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))
{
var pandocState = await this.PandocAvailabilityService.EnsureAvailabilityAsync(
showSuccessMessage: false,
showDialog: true);
canAddRegularFiles = pandocState.IsAvailable;
canAddPandocFiles = pandocState.IsAvailable;
}
foreach (var path in regularPaths)
{
if (!canAddRegularFiles)
break;
if (!canAddPandocFiles && FileTypes.RequiresPandoc(path))
{
this.Logger.LogWarning("The file '{Path}' needs Pandoc and was not attached.", path);
continue;
}
if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.ATTACHING_CONTENT, path, this.ValidateMediaFileTypes, this.Provider))
continue;

View File

@ -84,6 +84,23 @@ public static class FileTypes
public static readonly FileTypeFilter EXECUTABLES = FileTypeFilter.Leaf(TB("Executable"), "exe", "app", "bin", "appimage");
public static readonly FileTypeFilter PLUGIN_ARCHIVE = FileTypeFilter.Leaf(TB("Plugin archive"), PluginArchive.PLUGIN_FILE_EXTENSION.TrimStart('.'), "zip");
/// <summary>
/// The file types AI Studio converts using Pandoc.
/// </summary>
/// <remarks>
/// This is not a user-selectable type, it mirrors the formats the Rust runtime hands to
/// Pandoc. Every other document type is read by the runtime itself, so it must never depend
/// on a Pandoc installation. The name is not localized because it is never shown.
/// </remarks>
private static readonly FileTypeFilter PANDOC_CONVERTED = FileTypeFilter.Leaf("Pandoc conversion", "docx", "odt", "html", "htm");
/// <summary>
/// Determines whether reading the given file needs Pandoc.
/// </summary>
/// <param name="filePath">The path of the file to check.</param>
/// <returns>True, when reading the file needs Pandoc.</returns>
public static bool RequiresPandoc(string filePath) => IsAllowedPath(filePath, PANDOC_CONVERTED);
public static FileTypeFilter? AsOneFileType(params FileTypeFilter[]? types)
{
if (types == null || types.Length == 0)