Fixed attached files silently reaching the AI as empty documents (#904)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-08-10 16:37:44 +02:00
1 parent b9ec13edcf
commit 8f8f788896
31 files changed
+1730 -285

No files matched your search

@@ -0,0 +1,55 @@
using System.Text.Json.Serialization;
namespace AIStudio.Tools;
// ReSharper disable UnusedAutoPropertyAccessor.Global
// ReSharper disable ClassNeverInstantiated.Global
public sealed class ContentStreamErrorDetails
{
[JsonPropertyName("code")]
public string? Code { get; init; }
[JsonPropertyName("message")]
public string? Message { get; init; }
/// <summary>
/// The page the failure belongs to, when the failure affects a single page only.
/// </summary>
[JsonPropertyName("page_number")]
public int? PageNumber { get; init; }
/// <summary>
/// The format the runtime identified by looking at the content, e.g. when it contradicts the
/// file extension.
/// </summary>
[JsonPropertyName("detected_format")]
public string? DetectedFormat { get; init; }
/// <summary>
/// Gets the parsed error code.
/// </summary>
/// <remarks>
/// Codes this version does not know map to <see cref="FileExtractionErrorCode.UNKNOWN"/>
/// instead of failing the deserialization. A failed deserialization would turn the reported
/// error back into empty file content, which is exactly what we want to avoid here.
/// </remarks>
[JsonIgnore]
public FileExtractionErrorCode ParsedCode => Enum.TryParse<FileExtractionErrorCode>(this.Code, ignoreCase: true, out var parsedCode) ? parsedCode : FileExtractionErrorCode.UNKNOWN;
/// <summary>
/// Gets a value indicating whether this failure affects one part of the file only, while the
/// remaining content is still usable.
/// </summary>
[JsonIgnore]
public bool IsPartialFailure => this.ParsedCode is FileExtractionErrorCode.PAGE_EXTRACTION_FAILED;
/// <summary>
/// Gets a value indicating whether this is a notice rather than a failure.
/// </summary>
/// <remarks>
/// A notice tells the user something worth knowing about the file, while the content itself
/// was read completely. It must therefore never degrade the outcome of an extraction.
/// </remarks>
[JsonIgnore]
public bool IsNotice => this.ParsedCode is FileExtractionErrorCode.EXTENSION_MISMATCH;
}
@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace AIStudio.Tools;
// ReSharper disable UnusedAutoPropertyAccessor.Global
// ReSharper disable ClassNeverInstantiated.Global
public sealed class ContentStreamErrorMetadata : ContentStreamSseMetadata
{
[JsonPropertyName("Error")]
public ContentStreamErrorDetails? Error { get; init; }
}
@@ -23,7 +23,8 @@ public sealed class ContentStreamMetadataJsonConverter : JsonConverter<ContentSt
"Presentation" => JsonSerializer.Deserialize<ContentStreamPresentationMetadata?>(rawText, options),
"Image" => JsonSerializer.Deserialize<ContentStreamImageMetadata?>(rawText, options),
"Document" => JsonSerializer.Deserialize<ContentStreamDocumentMetadata?>(rawText, options),
"Error" => JsonSerializer.Deserialize<ContentStreamErrorMetadata?>(rawText, options),
_ => null
};
}
@@ -0,0 +1,23 @@
namespace AIStudio.Tools;
/// <summary>
/// The outcome of processing one content stream event: either content to append, or a reported
/// failure.
/// </summary>
/// <remarks>
/// Content and error are kept apart on purpose. A reported failure must never be appended as
/// content, because that would hand the failure to the AI as if it were part of the document.
/// </remarks>
/// <param name="Content">The content to append, or null when this event carries none.</param>
/// <param name="Error">The reported failure, or null when the event was processed successfully.</param>
public readonly record struct ContentStreamProcessedEvent(string? Content, ContentStreamErrorDetails? Error)
{
/// <summary>
/// An event which neither produced content nor reported a failure.
/// </summary>
public static readonly ContentStreamProcessedEvent NOTHING = new(null, null);
public static ContentStreamProcessedEvent FromContent(string? content) => new(content, null);
public static ContentStreamProcessedEvent FromError(ContentStreamErrorDetails? error) => new(null, error);
}
@@ -8,7 +8,7 @@ public static class ContentStreamSseHandler
private static readonly ConcurrentDictionary<string, List<ContentStreamPptxImageData>> CHUNKED_IMAGES = new();
private static readonly ConcurrentDictionary<string, SlideManager> SLIDE_MANAGERS = new();
public static string? ProcessEvent(ContentStreamSseEvent? sseEvent, bool extractImages = true)
public static ContentStreamProcessedEvent ProcessEvent(ContentStreamSseEvent? sseEvent, bool extractImages = true)
{
switch (sseEvent)
{
@@ -16,16 +16,16 @@ public static class ContentStreamSseHandler
switch (sseEvent.Metadata)
{
case ContentStreamTextMetadata:
return sseEvent.Content;
return ContentStreamProcessedEvent.FromContent(sseEvent.Content);
case ContentStreamPdfMetadata pdfMetadata:
var pageNumber = pdfMetadata.Pdf?.PageNumber ?? 0;
return $"""
return ContentStreamProcessedEvent.FromContent($"""
# Page {pageNumber}
{sseEvent.Content}
""";
""");
case ContentStreamSpreadsheetMetadata spreadsheetMetadata:
var sheetName = spreadsheetMetadata.Spreadsheet?.SheetName;
var rowNumber = spreadsheetMetadata.Spreadsheet?.RowNumber;
@@ -37,30 +37,38 @@ public static class ContentStreamSseHandler
}
spreadSheetResult.Append(sseEvent.Content);
return spreadSheetResult.ToString();
return ContentStreamProcessedEvent.FromContent(spreadSheetResult.ToString());
case ContentStreamDocumentMetadata:
case ContentStreamImageMetadata:
return sseEvent.Content;
return ContentStreamProcessedEvent.FromContent(sseEvent.Content);
case ContentStreamPresentationMetadata presentationMetadata:
var slideManager = SLIDE_MANAGERS.GetOrAdd(
sseEvent.StreamId!,
_ => new()
);
slideManager.AddSlide(presentationMetadata, sseEvent.Content, extractImages);
return null;
return ContentStreamProcessedEvent.NOTHING;
//
// The runtime reported a failure. It must not contribute any content: an empty
// or partial document would otherwise be handed to the AI as if it were the
// real file content.
//
case ContentStreamErrorMetadata errorMetadata:
return ContentStreamProcessedEvent.FromError(errorMetadata.Error);
default:
return sseEvent.Content;
return ContentStreamProcessedEvent.FromContent(sseEvent.Content);
}
case { Content: not null, Metadata: null }:
return sseEvent.Content;
return ContentStreamProcessedEvent.FromContent(sseEvent.Content);
default:
return null;
return ContentStreamProcessedEvent.NOTHING;
}
}
@@ -0,0 +1,86 @@
namespace AIStudio.Tools;
/// <summary>
/// Why reading a file failed. The Rust runtime reports these codes as part of the content
/// stream, so the app can tell the user what happened instead of showing an empty document.
/// </summary>
public enum FileExtractionErrorCode
{
/// <summary>
/// No failure happened.
/// </summary>
NONE,
/// <summary>
/// A code this version does not know, e.g. from a newer runtime.
/// </summary>
UNKNOWN,
//
// Codes reported by the Rust runtime:
//
INVALID_REQUEST,
FILE_NOT_FOUND,
FILE_NOT_READABLE,
/// <summary>
/// Another program holds the file open and denies reading it.
/// </summary>
FILE_LOCKED,
FORMAT_DETECTION_FAILED,
NOT_A_VALID_PDF,
NOT_A_VALID_SPREADSHEET,
PDFIUM_UNAVAILABLE,
PDF_ENCRYPTED,
PAGE_EXTRACTION_FAILED,
NO_TEXT_EXTRACTED,
/// <summary>
/// The content does not match the file extension. This is a notice, not a failure: the file
/// was read according to its content.
/// </summary>
EXTENSION_MISMATCH,
/// <summary>
/// The file was read as text, but its bytes are not text.
/// </summary>
NOT_TEXT_CONTENT,
/// <summary>
/// The file is an executable, no matter what its extension claims.
/// </summary>
EXECUTABLE_REJECTED,
UNSUPPORTED,
INTERNAL,
//
// 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>
REQUEST_FAILED,
/// <summary>
/// Reading the file took longer than the app is willing to wait.
/// </summary>
TIMEOUT,
/// <summary>
/// The runtime sent something the app could not deserialize.
/// </summary>
INVALID_RESPONSE,
/// <summary>
/// The extraction finished without reporting a failure, but produced no content at all.
/// </summary>
NO_CONTENT,
}
@@ -0,0 +1,23 @@
namespace AIStudio.Tools;
/// <summary>
/// How reading a file ended.
/// </summary>
public enum FileExtractionOutcome
{
/// <summary>
/// The whole file was read.
/// </summary>
SUCCESS,
/// <summary>
/// Parts of the file could not be read, e.g. single pages of a PDF, while the remaining
/// content is still usable.
/// </summary>
PARTIAL,
/// <summary>
/// The file could not be read. There is no content the app is allowed to use.
/// </summary>
FAILED,
}
@@ -0,0 +1,47 @@
namespace AIStudio.Tools;
/// <summary>
/// The result of reading a file through the Rust runtime.
/// </summary>
/// <remarks>
/// Content and failure travel together on purpose. When reading a file returns a bare string, a
/// failed extraction is indistinguishable from an empty document, and the empty document reaches
/// the AI as if that were the content of the user's file.
/// </remarks>
/// <param name="Outcome">How the extraction ended.</param>
/// <param name="Content">The extracted content. Empty when the extraction failed.</param>
/// <param name="ErrorCode">Why the extraction failed or lost parts of the file.</param>
/// <param name="ErrorMessage">The technical failure description, meant for logs and diagnostics.</param>
/// <param name="FailedPages">The pages which could not be read, when known.</param>
/// <param name="DetectedFormat">The format the runtime identified by looking at the content, when it is worth naming.</param>
public readonly record struct FileExtractionResult(FileExtractionOutcome Outcome, string Content, FileExtractionErrorCode ErrorCode, string? ErrorMessage, IReadOnlyList<int> FailedPages, string? DetectedFormat)
{
private static readonly int[] NO_FAILED_PAGES = [];
public static FileExtractionResult Success(string content, string? detectedFormat = null) => new(FileExtractionOutcome.SUCCESS, content, FileExtractionErrorCode.NONE, null, NO_FAILED_PAGES, detectedFormat);
public static FileExtractionResult Partial(string content, IReadOnlyList<int> failedPages, string? detectedFormat = null) => new(FileExtractionOutcome.PARTIAL, content, FileExtractionErrorCode.PAGE_EXTRACTION_FAILED, null, failedPages, detectedFormat);
public static FileExtractionResult Failed(FileExtractionErrorCode errorCode, string? errorMessage, string? detectedFormat = null) => new(FileExtractionOutcome.FAILED, string.Empty, errorCode, errorMessage, NO_FAILED_PAGES, detectedFormat);
/// <summary>
/// Gets a value indicating whether the whole file was read.
/// </summary>
public bool IsSuccess => this.Outcome is FileExtractionOutcome.SUCCESS;
/// <summary>
/// Gets a value indicating whether the content may be handed to the AI, i.e. the extraction
/// either succeeded or lost only parts of the file.
/// </summary>
public bool HasUsableContent => this.Outcome is FileExtractionOutcome.SUCCESS or FileExtractionOutcome.PARTIAL;
/// <summary>
/// Gets a value indicating whether the file was read, but its content did not match its file
/// extension.
/// </summary>
/// <remarks>
/// On a readable file, only the mismatch notice names a detected format, which is why no
/// separate flag is needed here.
/// </remarks>
public bool HasExtensionMismatch => this.HasUsableContent && this.DetectedFormat is not null;
}
@@ -0,0 +1,95 @@
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools;
/// <summary>
/// Translates the stable failure codes of a file extraction into user-facing text.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal static class FileExtractionResultExtensions
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(FileExtractionResultExtensions).Namespace, nameof(FileExtractionResultExtensions));
/// <summary>
/// Gets the localized message which explains why a file could not be read.
/// </summary>
/// <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)
{
// When we know what the file really is, naming it beats a generic "not supported":
if (result.ErrorCode is FileExtractionErrorCode.UNSUPPORTED && result.DetectedFormat is not null)
return string.Format(TB("The file '{0}' is a {1}, which AI Studio cannot read, so it was not sent."), fileName, result.DetectedFormat);
return result.ErrorCode.ToUserMessage(fileName);
}
/// <summary>
/// Gets the localized message for a file whose content does not match its file extension.
/// </summary>
/// <remarks>
/// This is a notice, not a failure: the file was read according to its content. We still tell
/// the user, because a wrong extension is a real problem for every other program as well.
/// </remarks>
/// <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 ToExtensionMismatchUserMessage(this FileExtractionResult result, string fileName) => string.Format(
TB("The file '{0}' is actually a {1} and was read as such. Please correct its file extension."),
fileName,
result.DetectedFormat);
/// <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.
/// </summary>
/// <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 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.FILE_LOCKED => TB("The file '{0}' is currently open in another program, which is why it was not sent. Please close the file and try again. When the file is stored on a shared network drive, a colleague might have it open."),
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.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. It might contain images only, such as a scanned PDF without a text layer, or no readable text at all."),
FileExtractionErrorCode.NO_CONTENT => TB("The file '{0}' did not provide any content and was not sent."),
FileExtractionErrorCode.NOT_TEXT_CONTENT => TB("The file '{0}' is not a text file and was not sent. Its content could not be read as text, so it might have a wrong file extension."),
FileExtractionErrorCode.EXECUTABLE_REJECTED => TB("The file '{0}' is an executable program and was not sent, regardless of its file extension."),
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."),
};
}
+33 -29
View File
@@ -30,9 +30,21 @@ public static partial class Pandoc
private static readonly Version FALLBACK_VERSION = new (3, 7, 0, 2);
/// <summary>
/// Tracks whether the first availability check log has been written to avoid log spam on repeated calls.
/// Tracks whether the executable AI Studio checks was already logged.
/// </summary>
private static bool HAS_LOGGED_AVAILABILITY_CHECK_ONCE;
/// <remarks>
/// Only informational logs are written once, because they describe a stable state and would
/// otherwise spam the log on repeated calls. Failures are always logged: they are usually
/// transient, e.g. an executable which is temporarily blocked or unreachable. Suppressing
/// repeated failures hid exactly the interesting case, where the check succeeded during
/// startup and started failing later on.
/// </remarks>
private static bool HAS_LOGGED_EXECUTABLE_ONCE;
/// <summary>
/// Tracks whether a successful availability check was already logged.
/// </summary>
private static bool HAS_LOGGED_SUCCESSFUL_CHECK_ONCE;
private static readonly HttpClient WEB_CLIENT = new();
private static readonly SemaphoreSlim INSTALLATION_LOCK = new(1, 1);
@@ -52,11 +64,6 @@ public static partial class Pandoc
/// <returns>True, if pandoc is available and the minimum required version is met, else false.</returns>
public static async Task<PandocInstallation> CheckAvailabilityAsync(RustService rustService, bool showMessages = true, bool showSuccessMessage = true)
{
//
// Determine if we should log (only on the first call):
//
var shouldLog = !HAS_LOGGED_AVAILABILITY_CHECK_ONCE;
try
{
//
@@ -64,7 +71,7 @@ public static partial class Pandoc
// This can happen on dev machines where the metadata.txt contains stale values.
// We always use the runtime-detected RID for correct behavior.
//
if (shouldLog && CPU_ARCHITECTURE != METADATA_ARCHITECTURE)
if (!HAS_LOGGED_EXECUTABLE_ONCE && CPU_ARCHITECTURE != METADATA_ARCHITECTURE)
{
LOG.LogWarning(
"Runtime-detected RID '{RuntimeRID}' differs from metadata RID '{MetadataRID}'. Using runtime-detected RID. This is expected on dev machines where metadata.txt may be outdated.",
@@ -73,8 +80,11 @@ public static partial class Pandoc
}
var preparedProcess = await PreparePandocProcess().AddArgument("--version").BuildAsync(rustService);
if (shouldLog)
if (!HAS_LOGGED_EXECUTABLE_ONCE)
{
LOG.LogInformation("Checking Pandoc availability using executable: '{Executable}' (IsLocal: {IsLocal}).", preparedProcess.StartInfo.FileName, preparedProcess.IsLocal);
HAS_LOGGED_EXECUTABLE_ONCE = true;
}
using var process = Process.Start(preparedProcess.StartInfo);
if (process == null)
@@ -82,9 +92,8 @@ public static partial class Pandoc
if (showMessages)
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Help, TB("Was not able to check the Pandoc installation.")));
if (shouldLog)
LOG.LogError("The Pandoc process was not started, it was null. Executable path: '{Executable}'.", preparedProcess.StartInfo.FileName);
LOG.LogError("The Pandoc process was not started, it was null. Executable path: '{Executable}'.", preparedProcess.StartInfo.FileName);
return new(false, TB("Was not able to check the Pandoc installation."), false, string.Empty, preparedProcess.IsLocal);
}
@@ -102,9 +111,8 @@ public static partial class Pandoc
if (showMessages)
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Error, TB("Pandoc is not available on the system or the process had issues.")));
if (shouldLog)
LOG.LogError("The Pandoc process exited with code {ProcessExitCode}. Error output: '{ErrorText}'", process.ExitCode, error);
LOG.LogError("The Pandoc process exited with code {ProcessExitCode}. Error output: '{ErrorText}'", process.ExitCode, error);
return new(false, TB("Pandoc is not available on the system or the process had issues."), false, string.Empty, preparedProcess.IsLocal);
}
@@ -114,9 +122,8 @@ public static partial class Pandoc
if (showMessages)
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Terminal, TB("Was not able to validate the Pandoc installation.")));
if (shouldLog)
LOG.LogError("Pandoc --version returned an invalid format: '{Output}'.", output);
LOG.LogError("Pandoc --version returned an invalid format: '{Output}'.", output);
return new(false, TB("Was not able to validate the Pandoc installation."), false, string.Empty, preparedProcess.IsLocal);
}
@@ -129,8 +136,11 @@ public static partial class Pandoc
if (showMessages && showSuccessMessage)
await MessageBus.INSTANCE.SendSuccess(new(Icons.Material.Filled.CheckCircle, string.Format(TB("Pandoc v{0} is installed."), installedVersionString)));
if (shouldLog)
if (!HAS_LOGGED_SUCCESSFUL_CHECK_ONCE)
{
LOG.LogInformation("Pandoc v{0} is installed and matches the required version (v{1}).", installedVersionString, MINIMUM_REQUIRED_VERSION.ToString());
HAS_LOGGED_SUCCESSFUL_CHECK_ONCE = true;
}
return new(true, string.Empty, true, installedVersionString, preparedProcess.IsLocal);
}
@@ -138,9 +148,8 @@ public static partial class Pandoc
if (showMessages)
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Build, string.Format(TB("Pandoc v{0} is installed, but it doesn't match the required version (v{1})."), installedVersionString, MINIMUM_REQUIRED_VERSION.ToString())));
if (shouldLog)
LOG.LogWarning("Pandoc v{0} is installed, but it does not match the required version (v{1}).", installedVersionString, MINIMUM_REQUIRED_VERSION.ToString());
LOG.LogWarning("Pandoc v{0} is installed, but it does not match the required version (v{1}).", installedVersionString, MINIMUM_REQUIRED_VERSION.ToString());
return new(true, string.Format(TB("Pandoc v{0} is installed, but it does not match the required version (v{1})."), installedVersionString, MINIMUM_REQUIRED_VERSION.ToString()), false, installedVersionString, preparedProcess.IsLocal);
}
catch (Exception e)
@@ -148,15 +157,10 @@ public static partial class Pandoc
if (showMessages)
await MessageBus.INSTANCE.SendError(new(@Icons.Material.Filled.AppsOutage, TB("Pandoc doesn't seem to be installed.")));
if(shouldLog)
LOG.LogError(e, "Pandoc availability check failed. This usually means Pandoc is not installed or not in the system PATH.");
LOG.LogError(e, "Pandoc availability check failed. This usually means Pandoc is not installed or not in the system PATH.");
return new(false, TB("Pandoc doesn't seem to be installed."), false, string.Empty, false);
}
finally
{
HAS_LOGGED_AVAILABILITY_CHECK_ONCE = true;
}
}
/// <summary>
@@ -216,8 +216,12 @@ public sealed class PandocProcessBuilder
}
catch (Exception ex)
{
if (shouldLog)
LOGGER.LogWarning(ex, "Error while searching for a local Pandoc installation in: '{LocalInstallationRootDirectory}'.", localInstallationRootDirectory);
//
// Always logged, in contrast to the lines above: those describe a stable setup,
// while this one is a transient fault, e.g. an unreachable data directory on a
// network drive. Suppressing repeats would hide it after the first call.
//
LOGGER.LogWarning(ex, "Error while searching for a local Pandoc installation in: '{LocalInstallationRootDirectory}'.", localInstallationRootDirectory);
}
}
+23 -2
View File
@@ -51,17 +51,21 @@ public static class FileTypes
// Document hierarchy
public static readonly FileTypeFilter PDF = FileTypeFilter.Leaf("PDF", "pdf");
public static readonly FileTypeFilter TEXT = FileTypeFilter.Leaf(TB("Text"), "txt", "md", "rtf");
public static readonly FileTypeFilter TABULAR = FileTypeFilter.Leaf(TB("Tabular text"), "csv", "tsv");
public static readonly FileTypeFilter MS_WORD = FileTypeFilter.Leaf("Microsoft Word", "docx");
public static readonly FileTypeFilter WORD = FileTypeFilter.Composite("Word", ["odt"], MS_WORD);
public static readonly FileTypeFilter EXCEL = FileTypeFilter.Leaf("Excel", "xls", "xlsx");
public static readonly FileTypeFilter POWER_POINT = FileTypeFilter.Leaf("PowerPoint", "ppt", "pptx", "odp");
// The legacy binary ".ppt" is missing on purpose: AI Studio has no reader for it, so offering
// it would only let users attach a file which cannot be read.
public static readonly FileTypeFilter POWER_POINT = FileTypeFilter.Leaf("PowerPoint", "pptx", "odp");
public static readonly FileTypeFilter MAIL = FileTypeFilter.Leaf(TB("Mail"), "eml", "msg", "mbox");
public static readonly FileTypeFilter LATEX = FileTypeFilter.Leaf("LaTeX", "tex", "bib", "sty", "cls", "log");
public static readonly FileTypeFilter OFFICE_FILES = FileTypeFilter.Parent(TB("Office Files"),
WORD, EXCEL, POWER_POINT, PDF);
public static readonly FileTypeFilter DOCUMENT = FileTypeFilter.Parent(TB("Document"),
TEXT, OFFICE_FILES, SOURCE_CODE, LATEX);
TEXT, TABULAR, OFFICE_FILES, SOURCE_CODE, LATEX);
// Media hierarchy
public static readonly FileTypeFilter IMAGE = FileTypeFilter.Leaf(TB("Image"),
@@ -84,6 +88,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)
@@ -5,36 +5,61 @@ namespace AIStudio.Tools.Services;
public sealed partial class RustService
{
public async Task<string> ReadArbitraryFileData(string path, int maxChunks, bool extractImages = false)
/// <summary>
/// How long one file extraction may take.
/// </summary>
/// <remarks>
/// Reading a large file from a slow network share is legitimately slow, so this is well above
/// the default HTTP client timeout. It still bounds the operation, because an unbounded read
/// would keep the caller waiting forever.
/// </remarks>
private static readonly TimeSpan EXTRACTION_TIMEOUT = TimeSpan.FromMinutes(10);
public async Task<FileExtractionResult> ReadArbitraryFileData(string path, int maxChunks, bool extractImages = false)
{
var streamId = Guid.NewGuid().ToString();
var requestUri = $"/retrieval/fs/extract?path={Uri.EscapeDataString(path)}&stream_id={streamId}&extract_images={extractImages}";
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
var response = await this.http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!response.IsSuccessStatusCode)
{
var responseBody = await response.Content.ReadAsStringAsync();
this.logger?.LogError(
"Failed to read arbitrary file data from Rust runtime. Status: {StatusCode}, reason: '{ReasonPhrase}', path: '{Path}', body: '{Body}'",
response.StatusCode,
response.ReasonPhrase,
path,
responseBody);
return string.Empty;
}
using var timeoutTokenSource = new CancellationTokenSource(EXTRACTION_TIMEOUT);
var cancellationToken = timeoutTokenSource.Token;
var resultBuilder = new StringBuilder();
var failedPages = new List<int>();
var hasPartialFailure = false;
var failureCode = FileExtractionErrorCode.NONE;
string? failureMessage = null;
string? detectedFormat = null;
try
{
await using var stream = await response.Content.ReadAsStreamAsync();
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
using var response = await this.extractionHttp.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
this.logger?.LogError(
"Failed to read arbitrary file data from Rust runtime. Status: {StatusCode}, reason: '{ReasonPhrase}', path: '{Path}', body: '{Body}'",
response.StatusCode,
response.ReasonPhrase,
path,
responseBody);
return FileExtractionResult.Failed(FileExtractionErrorCode.REQUEST_FAILED, $"The runtime answered with the status {(int)response.StatusCode} ({response.ReasonPhrase}).");
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var reader = new StreamReader(stream);
var chunkCount = 0;
while (!reader.EndOfStream && chunkCount < maxChunks)
while (chunkCount < maxChunks)
{
var line = await reader.ReadLineAsync();
// We read line by line instead of checking EndOfStream: the latter blocks on a
// network stream and cannot be cancelled, which would defeat the timeout above.
var line = await reader.ReadLineAsync(cancellationToken);
if (line is null)
break;
if (string.IsNullOrWhiteSpace(line))
continue;
@@ -46,24 +71,85 @@ public sealed partial class RustService
try
{
var sseEvent = JsonSerializer.Deserialize<ContentStreamSseEvent>(jsonContent);
if (sseEvent is not null)
{
var content = ContentStreamSseHandler.ProcessEvent(sseEvent, extractImages);
if (content is not null)
resultBuilder.AppendLine(content);
if (sseEvent is null)
continue;
chunkCount++;
var processedEvent = ContentStreamSseHandler.ProcessEvent(sseEvent, extractImages);
if (processedEvent.Error is not null)
{
var error = processedEvent.Error;
//
// A notice is not a failure: the file was read completely, we only learned
// something about it worth telling the user. It must not change the outcome.
//
if (error.IsNotice)
{
this.logger?.LogInformation(
"The runtime reported a notice while reading '{Path}': code={ErrorCode}, detectedFormat='{DetectedFormat}', message='{Message}'",
path,
error.ParsedCode,
error.DetectedFormat,
error.Message);
detectedFormat ??= error.DetectedFormat;
chunkCount++;
continue;
}
this.logger?.LogError(
"The runtime reported a failure while reading '{Path}': code={ErrorCode}, page={PageNumber}, partial={IsPartialFailure}, detectedFormat='{DetectedFormat}', message='{Message}'",
path,
error.ParsedCode,
error.PageNumber,
error.IsPartialFailure,
error.DetectedFormat,
error.Message);
//
// A partial failure costs us one part of the file, e.g. a single PDF page,
// but keeps the rest usable. Any other failure means what we collected is
// not the document the user picked, so we must not pass it on as content.
//
if (error.IsPartialFailure)
{
hasPartialFailure = true;
if (error.PageNumber is { } pageNumber)
failedPages.Add(pageNumber);
}
else if (failureCode is FileExtractionErrorCode.NONE)
{
failureCode = error.ParsedCode;
failureMessage = error.Message;
detectedFormat = error.DetectedFormat;
}
}
else if (processedEvent.Content is not null)
resultBuilder.AppendLine(processedEvent.Content);
chunkCount++;
}
catch (JsonException)
catch (JsonException e)
{
this.logger?.LogError("Failed to deserialize SSE event: {JsonContent}", jsonContent);
this.logger?.LogError(e, "Failed to deserialize SSE event while reading '{Path}': {JsonContent}", path, jsonContent);
if (failureCode is FileExtractionErrorCode.NONE)
{
failureCode = FileExtractionErrorCode.INVALID_RESPONSE;
failureMessage = "The runtime sent a response the app was not able to read.";
}
}
}
}
catch(Exception e)
catch (OperationCanceledException) when (timeoutTokenSource.IsCancellationRequested)
{
this.logger?.LogError("Reading the file '{Path}' timed out after {Timeout}.", path, EXTRACTION_TIMEOUT);
return FileExtractionResult.Failed(FileExtractionErrorCode.TIMEOUT, $"Reading the file timed out after {EXTRACTION_TIMEOUT.TotalMinutes:0} minutes.");
}
catch (Exception e)
{
this.logger?.LogError(e, "Error reading file data from stream: {Path}", path);
return FileExtractionResult.Failed(FileExtractionErrorCode.INTERNAL, e.Message);
}
finally
{
@@ -71,7 +157,25 @@ public sealed partial class RustService
if (!string.IsNullOrWhiteSpace(finalContentChunk))
resultBuilder.AppendLine(finalContentChunk);
}
return resultBuilder.ToString();
if (failureCode is not FileExtractionErrorCode.NONE)
return FileExtractionResult.Failed(failureCode, failureMessage, detectedFormat);
var content = resultBuilder.ToString();
//
// Nothing failed, yet nothing came out either. We report this as a failure as well:
// handing an empty document to the AI looks like a file without content, and the user
// would never learn that reading the file did not work.
//
if (string.IsNullOrWhiteSpace(content))
{
this.logger?.LogWarning("Reading the file '{Path}' produced no content at all.", path);
return FileExtractionResult.Failed(FileExtractionErrorCode.NO_CONTENT, "Reading the file produced no content.");
}
return hasPartialFailure
? FileExtractionResult.Partial(content, failedPages, detectedFormat)
: FileExtractionResult.Success(content, detectedFormat);
}
}
@@ -17,6 +17,19 @@ public sealed partial class RustService : BackgroundService
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(RustService).Namespace, nameof(RustService));
private readonly HttpClient http;
/// <summary>
/// A dedicated client for file extraction.
/// </summary>
/// <remarks>
/// Extraction needs its own client because <see cref="HttpClient.Timeout"/> is a client-wide
/// setting which also covers reading the streamed response body. A per-request cancellation
/// token can only shorten that limit, never extend it. Reading a large file from a slow
/// network share legitimately exceeds the default limit, so this client has no timeout of its
/// own and the extraction bounds each request itself.
/// </remarks>
private readonly HttpClient extractionHttp;
private readonly SemaphoreSlim fileDialogLock = new(1, 1);
private readonly SemaphoreSlim userLanguageLock = new(1, 1);
private readonly SemaphoreSlim userNameLock = new(1, 1);
@@ -42,26 +55,37 @@ public sealed partial class RustService : BackgroundService
{
this.apiPort = apiPort;
this.certificateFingerprint = certificateFingerprint;
// The default timeout of HttpClient, kept explicit so the difference to the
// extraction client below is visible:
this.http = CreateHttpClient(apiPort, certificateFingerprint, TimeSpan.FromSeconds(100));
this.extractionHttp = CreateHttpClient(apiPort, certificateFingerprint, Timeout.InfiniteTimeSpan);
}
private static HttpClient CreateHttpClient(string apiPort, string certificateFingerprint, TimeSpan timeout)
{
var certificateValidationHandler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, certificate, _, _) =>
{
if(certificate is null)
return false;
var currentCertificateFingerprint = certificate.GetCertHashString(HashAlgorithmName.SHA256);
return currentCertificateFingerprint == certificateFingerprint;
},
};
this.http = new HttpClient(certificateValidationHandler)
var client = new HttpClient(certificateValidationHandler)
{
BaseAddress = new Uri($"https://127.0.0.1:{apiPort}"),
DefaultRequestVersion = Version.Parse("2.0"),
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
Timeout = timeout,
};
this.http.DefaultRequestHeaders.AddApiToken();
client.DefaultRequestHeaders.AddApiToken();
return client;
}
public void SetLogger(ILogger<RustService> logService)
@@ -93,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();
+53 -19
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,71 @@ 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;
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)));
}
// The file was read correctly, but its extension lies about what it contains:
if (result.HasExtensionMismatch)
{
LOGGER.LogWarning("The file '{FilePath}' is actually a '{DetectedFormat}'.", filePath, result.DetectedFormat);
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.RuleFolder, result.ToExtensionMismatchUserMessage(fileName)));
}
return result;
}
}