mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-16 22:17:59 +00:00
Fixed attached files silently reaching the AI as empty documents (#904)
This commit is contained in:
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."),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user