mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-11 19:52:10 +00:00
Fixed attached files silently reaching the AI as empty documents (#904)
This commit is contained in:
parent
b9ec13edcf
commit
8f8f788896
@ -716,7 +716,28 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
|
||||
continue;
|
||||
}
|
||||
|
||||
var fileContent = await this.RustService.ReadArbitraryFileData(document.FilePath, int.MaxValue);
|
||||
var extraction = await this.RustService.ReadArbitraryFileData(document.FilePath, int.MaxValue);
|
||||
if (!extraction.HasUsableContent)
|
||||
{
|
||||
this.Logger.LogError("Reading the document '{FilePath}' failed and it will not be analyzed: code={ErrorCode}, message='{ErrorMessage}'.", document.FilePath, extraction.ErrorCode, extraction.ErrorMessage);
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Description, extraction.ToUserMessage(document.FileName)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (extraction.Outcome is FileExtractionOutcome.PARTIAL)
|
||||
{
|
||||
this.Logger.LogWarning("Parts of the document '{FilePath}' could not be read: pages={FailedPages}.", document.FilePath, string.Join(", ", extraction.FailedPages));
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(document.FileName)));
|
||||
}
|
||||
|
||||
// The file was read correctly, but its extension lies about what it contains:
|
||||
if (extraction.HasExtensionMismatch)
|
||||
{
|
||||
this.Logger.LogWarning("The document '{FilePath}' is actually a '{DetectedFormat}'.", document.FilePath, extraction.DetectedFormat);
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.RuleFolder, extraction.ToExtensionMismatchUserMessage(document.FileName)));
|
||||
}
|
||||
|
||||
var fileContent = extraction.Content;
|
||||
sb.AppendLine($"""
|
||||
|
||||
## DOCUMENT {numDocuments}:
|
||||
|
||||
@ -1735,9 +1735,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T534887559"] =
|
||||
-- Please provide a custom language.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T656744944"] = "Please provide a custom language."
|
||||
|
||||
-- The custom prompt guide file is empty or could not be read.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::PROMPTOPTIMIZER::ASSISTANTPROMPTOPTIMIZER::T1173408044"] = "The custom prompt guide file is empty or could not be read."
|
||||
|
||||
-- Use English for complex prompts and explicitly request response language if needed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::PROMPTOPTIMIZER::ASSISTANTPROMPTOPTIMIZER::T119999744"] = "Use English for complex prompts and explicitly request response language if needed."
|
||||
|
||||
@ -2866,6 +2863,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4188329028"] = "No, kee
|
||||
-- Export Chat to Microsoft Word
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T861873672"] = "Export Chat to Microsoft Word"
|
||||
|
||||
-- The file '{0}' is currently not available and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T1432544573"] = "The file '{0}' is currently not available and was not sent."
|
||||
|
||||
-- The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T3267850764"] = "The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings."
|
||||
|
||||
@ -7708,6 +7708,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1290340974"] = "Unknown configur
|
||||
-- Copies the configuration slot to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1347508205"] = "Copies the configuration slot to the clipboard"
|
||||
|
||||
-- Once the encoding of a text file is known, encoding_rs turns its content into the text AI Studio works with. Together with chardetng, this lets AI Studio read text, CSV, and similar files no matter which encoding they were saved in.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1378412877"] = "Once the encoding of a text file is known, encoding_rs turns its content into the text AI Studio works with. Together with chardetng, this lets AI Studio read text, CSV, and similar files no matter which encoding they were saved in."
|
||||
|
||||
-- This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1388816916"] = "This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat."
|
||||
|
||||
@ -7819,6 +7822,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T234598990"] = "Linux AppImages b
|
||||
-- Used PDFium version
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2368247719"] = "Used PDFium version"
|
||||
|
||||
-- Text files are not always saved in the same encoding: files written on Windows often use a legacy one. chardetng recognizes which encoding a text file uses, so AI Studio can read it instead of rejecting it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T236832881"] = "Text files are not always saved in the same encoding: files written on Windows often use a legacy one. chardetng recognizes which encoding a text file uses, so AI Studio can read it instead of rejecting it."
|
||||
|
||||
-- installation provided by the system
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2371107659"] = "installation provided by the system"
|
||||
|
||||
@ -9082,6 +9088,66 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T599774443"] = "The
|
||||
-- policy files
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T632340680"] = "policy files"
|
||||
|
||||
-- The file type of '{0}' could not be determined, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1459702734"] = "The file type of '{0}' could not be determined, so the file was not sent."
|
||||
|
||||
-- The file '{0}' is an executable program and was not sent, regardless of its file extension.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1481258284"] = "The file '{0}' is an executable program and was not sent, regardless of its file extension."
|
||||
|
||||
-- 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.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1488076079"] = "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."
|
||||
|
||||
-- The pages {1} of the file '{0}' could not be read. The remaining content was sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1928400379"] = "The pages {1} of the file '{0}' could not be read. The remaining content was sent."
|
||||
|
||||
-- Parts of the file '{0}' could not be read. The remaining content was sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2036654169"] = "Parts of the file '{0}' could not be read. The remaining content was sent."
|
||||
|
||||
-- The file type of '{0}' is not supported, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2064321829"] = "The file type of '{0}' is not supported, so the file was not sent."
|
||||
|
||||
-- The file '{0}' is not a readable spreadsheet and was not sent. It might be damaged or transferred incompletely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2240855899"] = "The file '{0}' is not a readable spreadsheet and was not sent. It might be damaged or transferred incompletely."
|
||||
|
||||
-- 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.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2701144378"] = "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."
|
||||
|
||||
-- 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.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2793077828"] = "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."
|
||||
|
||||
-- The file '{0}' is not a readable PDF and was not sent. It might be damaged or transferred incompletely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2891768359"] = "The file '{0}' is not a readable PDF and was not sent. It might be damaged or transferred incompletely."
|
||||
|
||||
-- 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.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2897122009"] = "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."
|
||||
|
||||
-- The file '{0}' is a {1}, which AI Studio cannot read, so it was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3262447403"] = "The file '{0}' is a {1}, which AI Studio cannot read, so it was not sent."
|
||||
|
||||
-- The file '{0}' is actually a {1} and was read as such. Please correct its file extension.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3297602719"] = "The file '{0}' is actually a {1} and was read as such. Please correct its file extension."
|
||||
|
||||
-- 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.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3303873344"] = "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."
|
||||
|
||||
-- The file '{0}' could not be read and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3527027650"] = "The file '{0}' could not be read and was not sent."
|
||||
|
||||
-- The file '{0}' is protected and could not be opened, so it was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3840033580"] = "The file '{0}' is protected and could not be opened, so it was not sent."
|
||||
|
||||
-- AI Studio was not able to start its PDF engine, so the file '{0}' was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3927045859"] = "AI Studio was not able to start its PDF engine, so the file '{0}' was not sent."
|
||||
|
||||
-- The file '{0}' does not exist anymore and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T4071378057"] = "The file '{0}' does not exist anymore and was not sent."
|
||||
|
||||
-- The file '{0}' did not provide any content and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T4291141931"] = "The file '{0}' did not provide any content and was not sent."
|
||||
|
||||
-- Reading the file '{0}' needs Pandoc, which is not available, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T594894810"] = "Reading the file '{0}' needs Pandoc, which is not available, so the file was not sent."
|
||||
|
||||
-- AI Studio couldn't install Pandoc because the archive was not found.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1059477764"] = "AI Studio couldn't install Pandoc because the archive was not found."
|
||||
|
||||
@ -9610,6 +9676,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1041509726"] = "Text"
|
||||
-- Office Files
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1063218378"] = "Office Files"
|
||||
|
||||
-- Tabular text
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T13157661"] = "Tabular text"
|
||||
|
||||
-- Executable
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1364437037"] = "Executable"
|
||||
|
||||
@ -10027,9 +10096,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4261248356"] = "Sources pro
|
||||
-- Pandoc Installation
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T185447014"] = "Pandoc Installation"
|
||||
|
||||
-- Pandoc may be required for importing files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T2596465560"] = "Pandoc may be required for importing files."
|
||||
|
||||
-- The file path is null or empty and the file therefore can not be loaded.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T932243993"] = "The file path is null or empty and the file therefore can not be loaded."
|
||||
|
||||
|
||||
@ -579,9 +579,10 @@ public partial class AssistantPromptOptimizer : AssistantBaseCore<SettingsDialog
|
||||
try
|
||||
{
|
||||
this.isLoadingCustomPromptGuide = true;
|
||||
this.customPromptingGuidelineContent = await UserFile.LoadFileData(fileAttachment.FilePath, this.RustService, this.DialogService);
|
||||
if (string.IsNullOrWhiteSpace(this.customPromptingGuidelineContent))
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Description, T("The custom prompt guide file is empty or could not be read.")));
|
||||
|
||||
// A failure was already reported by UserFile.LoadFileData, so we only keep the content:
|
||||
var extraction = await UserFile.LoadFileData(fileAttachment.FilePath, this.RustService, this.DialogService);
|
||||
this.customPromptingGuidelineContent = extraction.HasUsableContent ? extraction.Content : string.Empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@ -382,7 +382,28 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
continue;
|
||||
}
|
||||
|
||||
var fileContent = await this.RustService.ReadArbitraryFileData(document.FilePath, int.MaxValue);
|
||||
var extraction = await this.RustService.ReadArbitraryFileData(document.FilePath, int.MaxValue);
|
||||
if (!extraction.HasUsableContent)
|
||||
{
|
||||
this.Logger.LogError("Reading the document '{FilePath}' failed and it will not be used: code={ErrorCode}, message='{ErrorMessage}'.", document.FilePath, extraction.ErrorCode, extraction.ErrorMessage);
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Description, extraction.ToUserMessage(document.FileName)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (extraction.Outcome is FileExtractionOutcome.PARTIAL)
|
||||
{
|
||||
this.Logger.LogWarning("Parts of the document '{FilePath}' could not be read: pages={FailedPages}.", document.FilePath, string.Join(", ", extraction.FailedPages));
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(document.FileName)));
|
||||
}
|
||||
|
||||
// The file was read correctly, but its extension lies about what it contains:
|
||||
if (extraction.HasExtensionMismatch)
|
||||
{
|
||||
this.Logger.LogWarning("The document '{FilePath}' is actually a '{DetectedFormat}'.", document.FilePath, extraction.DetectedFormat);
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.RuleFolder, extraction.ToExtensionMismatchUserMessage(document.FileName)));
|
||||
}
|
||||
|
||||
var fileContent = extraction.Content;
|
||||
sb.AppendLine($"""
|
||||
|
||||
## DOCUMENT {numDocuments}:
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -14,6 +15,7 @@ namespace AIStudio.Chat;
|
||||
public sealed class ContentText : IContent
|
||||
{
|
||||
private static readonly ILogger<ContentText> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ContentText>();
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ContentText).Namespace, nameof(ContentText));
|
||||
|
||||
/// <summary>
|
||||
@ -266,26 +268,44 @@ public sealed class ContentText : IContent
|
||||
// Get the list of existing documents:
|
||||
var existingDocuments = normalizedAttachments.Where(x => x.Type is FileAttachmentType.DOCUMENT && x.Exists).ToList();
|
||||
|
||||
// Log warning for missing files:
|
||||
//
|
||||
// Report missing files. We tell the user about them instead of only logging: on a
|
||||
// network drive, a file which is temporarily unreachable looks exactly like a deleted
|
||||
// one, and silently dropping it would let the AI answer without that document.
|
||||
//
|
||||
var missingDocuments = normalizedAttachments.Except(existingDocuments).Where(x => x.Type is FileAttachmentType.DOCUMENT).ToList();
|
||||
if (missingDocuments.Count > 0)
|
||||
foreach (var missingDocument in missingDocuments)
|
||||
{
|
||||
LOGGER.LogWarning("File attachment no longer exists and will be skipped: '{MissingDocument}'.", missingDocument.FilePath);
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.FindInPage, string.Format(TB("The file '{0}' is currently not available and was not sent."), missingDocument.FileName)));
|
||||
}
|
||||
|
||||
// Only proceed if there are existing, allowed documents:
|
||||
if (existingDocuments.Count > 0)
|
||||
{
|
||||
// Check Pandoc availability once before processing file attachments
|
||||
//
|
||||
// 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 could not be processed because Pandoc is not available.");
|
||||
LOGGER.LogWarning("File attachments which need Pandoc 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
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("The following files are attached to this message:");
|
||||
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)
|
||||
@ -294,13 +314,52 @@ public sealed class ContentText : IContent
|
||||
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, FileExtractionErrorCode.PANDOC_UNAVAILABLE.ToUserMessage(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 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)));
|
||||
}
|
||||
|
||||
// The file was read correctly, but its extension lies about what it contains:
|
||||
if (extraction.HasExtensionMismatch)
|
||||
{
|
||||
LOGGER.LogWarning("The file attachment '{FilePath}' is actually a '{DetectedFormat}'.", document.FilePath, extraction.DetectedFormat);
|
||||
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.RuleFolder, extraction.ToExtensionMismatchUserMessage(document.FileName)));
|
||||
}
|
||||
|
||||
documentBlocks.AppendLine();
|
||||
documentBlocks.AppendLine("---------------------------------------");
|
||||
documentBlocks.AppendLine($"File path: {document.FilePath}");
|
||||
documentBlocks.AppendLine("File content:");
|
||||
documentBlocks.AppendLine("````");
|
||||
documentBlocks.AppendLine(extraction.Content);
|
||||
documentBlocks.AppendLine("````");
|
||||
}
|
||||
|
||||
if (documentBlocks.Length > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("---------------------------------------");
|
||||
sb.AppendLine($"File path: {document.FilePath}");
|
||||
sb.AppendLine("File content:");
|
||||
sb.AppendLine("````");
|
||||
sb.AppendLine(await Program.RUST_SERVICE.ReadArbitraryFileData(document.FilePath, int.MaxValue));
|
||||
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 });
|
||||
@ -312,7 +371,6 @@ public sealed class ContentText : IContent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -324,8 +324,13 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
|
||||
try
|
||||
{
|
||||
var fileContent = await UserFile.LoadFileData(filePath, this.RustService, this.DialogService);
|
||||
await this.ApplyFileContentAsync(fileContent, filePath);
|
||||
var extraction = await UserFile.LoadFileData(filePath, this.RustService, this.DialogService);
|
||||
|
||||
// The failure was already reported by UserFile.LoadFileData, so we only stop here:
|
||||
if (!extraction.HasUsableContent)
|
||||
return false;
|
||||
|
||||
await this.ApplyFileContentAsync(extraction.Content, filePath);
|
||||
this.Logger.LogInformation("Successfully loaded file content: {FilePath}", filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -33,6 +33,12 @@
|
||||
@T("The specified file could not be found. The file have been moved, deleted, renamed, or is otherwise inaccessible.")
|
||||
</MudAlert>
|
||||
}
|
||||
else if (this.loadFailureMessage is not null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Variant="Variant.Filled" Class="my-2">
|
||||
@this.loadFailureMessage
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Outlined="true" PanelClass="pa-2" Class="mb-2">
|
||||
|
||||
@ -21,6 +21,11 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
||||
[Parameter]
|
||||
public string FileContent { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Set when reading the file failed, so the dialog shows the reason instead of empty content.
|
||||
/// </summary>
|
||||
private string? loadFailureMessage;
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
@ -38,14 +43,22 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
||||
{
|
||||
if (!this.Document.IsImage)
|
||||
{
|
||||
var fileContent = await UserFile.LoadFileData(this.Document.FilePath, this.RustService, this.DialogService);
|
||||
this.FileContent = fileContent;
|
||||
var extraction = await UserFile.LoadFileData(this.Document.FilePath, this.RustService, this.DialogService);
|
||||
this.FileContent = extraction.Content;
|
||||
|
||||
//
|
||||
// This dialog exists so the user can check what we hand to the AI. Showing an
|
||||
// empty document when reading the file failed would answer that question wrong.
|
||||
//
|
||||
if (!extraction.HasUsableContent)
|
||||
this.loadFailureMessage = extraction.ToUserMessage(this.Document.FileName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.Logger.LogError(ex, "Failed to load file content from '{FilePath}'", this.Document);
|
||||
this.FileContent = string.Empty;
|
||||
this.loadFailureMessage = FileExtractionErrorCode.INTERNAL.ToUserMessage(this.Document.FileName);
|
||||
}
|
||||
|
||||
this.StateHasChanged();
|
||||
|
||||
@ -340,6 +340,8 @@
|
||||
<ThirdPartyComponent Name="windows-rs" Developer="Microsoft, Kenny Kerr, Ryan Levick, Rafael Rivera, sivadeilra, Marijn Suijten & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/microsoft/windows-rs/blob/master/license-mit" RepositoryUrl="https://github.com/microsoft/windows-rs" UseCase="@T("The windows-rs project provides access to Windows APIs from Rust. We use several libraries from this project: windows-registry is used to read the desired configuration in Windows enterprise environments. The windows and windows-collections libraries are used to open the native Windows share dialog, e.g., when you share a plugin with others.")"/>
|
||||
<ThirdPartyComponent Name="objc2" Developer="Steven Sheldon, Mads Marquart, silvanshade, Dzmitry Malyshau, Felix Nemo Kaaman, adamnemecek, Samuel Sleight, Paul Mabileau & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/madsmtm/objc2/blob/main/LICENSE-MIT.txt" RepositoryUrl="https://github.com/madsmtm/objc2" UseCase="@T("The objc2 project provides access to Apple's Objective-C frameworks from Rust. On macOS, we use the libraries objc2, objc2-app-kit, and objc2-foundation to open the native macOS share sheet, e.g., when you share a plugin with others.")"/>
|
||||
<ThirdPartyComponent Name="file-format" Developer="Mickaël Malécot & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/mmalecot/file-format/blob/main/LICENSE-MIT" RepositoryUrl="https://github.com/mmalecot/file-format" UseCase="@T("This library identifies files by their content. It is used for document streaming and as the first safety and media classification step before local audio processing.")"/>
|
||||
<ThirdPartyComponent Name="chardetng" Developer="Henri Sivonen & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/hsivonen/chardetng/blob/main/LICENSE-MIT" RepositoryUrl="https://github.com/hsivonen/chardetng" UseCase="@T("Text files are not always saved in the same encoding: files written on Windows often use a legacy one. chardetng recognizes which encoding a text file uses, so AI Studio can read it instead of rejecting it.")"/>
|
||||
<ThirdPartyComponent Name="encoding_rs" Developer="Henri Sivonen, M. Larsen, kornelski, Manish Goregaokar & Open Source Community" LicenseName="MIT & BSD-3-Clause" LicenseUrl="https://github.com/hsivonen/encoding_rs/blob/main/COPYRIGHT" RepositoryUrl="https://github.com/hsivonen/encoding_rs" UseCase="@T("Once the encoding of a text file is known, encoding_rs turns its content into the text AI Studio works with. Together with chardetng, this lets AI Studio read text, CSV, and similar files no matter which encoding they were saved in.")"/>
|
||||
<ThirdPartyComponent Name="Symphonia" Developer="Philip Deljanov & Open Source Community" LicenseName="MPL-2.0" LicenseUrl="https://github.com/pdeljanov/Symphonia/blob/v0.6.0/LICENSE" RepositoryUrl="https://github.com/pdeljanov/Symphonia" UseCase="@T("Symphonia is used for media container demuxing and audio decoding. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio.")"/>
|
||||
<ThirdPartyComponent Name="Ropus" Developer="0x4D44, Xiph.Org, Skype Limited, Octasic, Jean-Marc Valin, Timothy B. Terriberry, CSIRO, Gregory Maxwell, Mark Borgerding, Erik de Castro Lopo, Mozilla, Amazon & Open Source Community" LicenseName="BSD-3-Clause" LicenseUrl="https://github.com/0x4D44/ropus/blob/main/LICENSE" RepositoryUrl="https://github.com/0x4d44/ropus" UseCase="@T("Ropus provides the Opus encoder and decoder used by the media pipeline.")"/>
|
||||
<ThirdPartyComponent Name="Rubato" Developer="Henrik Enquist & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/HEnquist/rubato/blob/master/LICENSE-MIT" RepositoryUrl="https://github.com/HEnquist/rubato" UseCase="@T("We use Rubato to resample the decoded audio to 48 kHz before the Opus encoding.")"/>
|
||||
|
||||
@ -1737,9 +1737,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T534887559"] =
|
||||
-- Please provide a custom language.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T656744944"] = "Bitte wählen Sie eine eigene Sprache aus."
|
||||
|
||||
-- The custom prompt guide file is empty or could not be read.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::PROMPTOPTIMIZER::ASSISTANTPROMPTOPTIMIZER::T1173408044"] = "Der benutzerdefinierte Prompting Leitfaden ist leer oder konnte nicht gelesen werden."
|
||||
|
||||
-- Use English for complex prompts and explicitly request response language if needed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::PROMPTOPTIMIZER::ASSISTANTPROMPTOPTIMIZER::T119999744"] = "Verwenden Sie Englisch für komplexe Prompts und fordern Sie dann explizit die gewünschte Antwortsprache im Prompt an."
|
||||
|
||||
@ -2868,6 +2865,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4188329028"] = "Nein, b
|
||||
-- Export Chat to Microsoft Word
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T861873672"] = "Chat in Microsoft Word exportieren"
|
||||
|
||||
-- The file '{0}' is currently not available and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T1432544573"] = "Die Datei „{0}“ ist derzeit nicht verfügbar und wurde nicht gesendet."
|
||||
|
||||
-- The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T3267850764"] = "Das ausgewählte Modell '{0}' ist bei '{1}' (Anbieter={2}) nicht mehr verfügbar. Bitte passen Sie Ihre Anbietereinstellungen an."
|
||||
|
||||
@ -9084,6 +9084,66 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T599774443"] = "Das
|
||||
-- policy files
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T632340680"] = "Richtliniendateien"
|
||||
|
||||
-- The file type of '{0}' could not be determined, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1459702734"] = "Der Dateityp von „{0}“ konnte nicht bestimmt werden. Daher wurde die Datei nicht gesendet."
|
||||
|
||||
-- The file '{0}' is an executable program and was not sent, regardless of its file extension.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1481258284"] = "Die Datei „{0}“ ist ein ausführbares Programm und wurde unabhängig von ihrer Dateierweiterung nicht gesendet."
|
||||
|
||||
-- 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.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1488076079"] = "Die Datei „{0}“ konnte nicht gelesen und daher nicht gesendet werden. Wenn die Datei auf einem Netzlaufwerk gespeichert ist, ist das Laufwerk möglicherweise nicht verfügbar oder ein anderes Programm blockiert die Datei."
|
||||
|
||||
-- The pages {1} of the file '{0}' could not be read. The remaining content was sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1928400379"] = "Die Seiten {1} der Datei „{0}“ konnten nicht gelesen werden. Der verbleibende Inhalt wurde gesendet."
|
||||
|
||||
-- Parts of the file '{0}' could not be read. The remaining content was sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2036654169"] = "Teile der Datei „{0}“ konnten nicht gelesen werden. Der verbleibende Inhalt wurde gesendet."
|
||||
|
||||
-- The file type of '{0}' is not supported, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2064321829"] = "Der Dateityp von „{0}“ wird nicht unterstützt. Die Datei wurde daher nicht gesendet."
|
||||
|
||||
-- The file '{0}' is not a readable spreadsheet and was not sent. It might be damaged or transferred incompletely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2240855899"] = "Die Datei „{0}“ ist keine lesbare Tabellenkalkulation und wurde nicht gesendet. Möglicherweise ist sie beschädigt oder unvollständig übertragen worden."
|
||||
|
||||
-- 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.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2701144378"] = "Die Datei „{0}“ ist derzeit in einem anderen Programm geöffnet und wurde daher nicht gesendet. Bitte schließen Sie die Datei und versuchen Sie es erneut. Wenn die Datei auf einem freigegebenen Netzlaufwerk gespeichert ist, könnte sie von einem Kollegen geöffnet sein."
|
||||
|
||||
-- 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.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2793077828"] = "Das Lesen der Datei „{0}“ dauerte zu lange und wurde abgebrochen. Daher wurde die Datei nicht gesendet. Wenn die Datei auf einem Netzlaufwerk gespeichert ist, könnte die Verbindung langsam oder unterbrochen sein."
|
||||
|
||||
-- The file '{0}' is not a readable PDF and was not sent. It might be damaged or transferred incompletely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2891768359"] = "Die Datei „{0}“ ist keine lesbare PDF-Datei und wurde nicht gesendet. Sie ist möglicherweise beschädigt oder wurde unvollständig übertragen."
|
||||
|
||||
-- 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.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2897122009"] = "Aus der Datei „{0}“ konnte kein Text gelesen werden, daher wurde sie nicht gesendet. Möglicherweise enthält sie nur Bilder, etwa ein gescanntes PDF ohne Textebene, oder gar keinen lesbaren Text."
|
||||
|
||||
-- The file '{0}' is a {1}, which AI Studio cannot read, so it was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3262447403"] = "Die Datei „{0}“ ist eine {1}, die AI Studio nicht lesen kann. Daher wurde sie nicht gesendet."
|
||||
|
||||
-- The file '{0}' is actually a {1} and was read as such. Please correct its file extension.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3297602719"] = "Die Datei „{0}“ ist tatsächlich eine {1} und wurde als solche gelesen. Bitte korrigieren Sie ihre Dateiendung."
|
||||
|
||||
-- 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.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3303873344"] = "Die Datei „{0}“ ist keine Textdatei und wurde nicht gesendet. Ihr Inhalt konnte nicht als Text gelesen werden; möglicherweise hat sie die falsche Dateiendung."
|
||||
|
||||
-- The file '{0}' could not be read and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3527027650"] = "Die Datei „{0}“ konnte nicht gelesen und daher nicht gesendet werden."
|
||||
|
||||
-- The file '{0}' is protected and could not be opened, so it was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3840033580"] = "Die Datei „{0}“ ist geschützt und konnte nicht geöffnet werden. Daher wurde sie nicht gesendet."
|
||||
|
||||
-- AI Studio was not able to start its PDF engine, so the file '{0}' was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3927045859"] = "AI Studio konnte das PDF-System nicht starten, daher wurde die Datei „{0}“ nicht gesendet."
|
||||
|
||||
-- The file '{0}' does not exist anymore and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T4071378057"] = "Die Datei „{0}“ existiert nicht mehr und wurde nicht gesendet."
|
||||
|
||||
-- The file '{0}' did not provide any content and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T4291141931"] = "Die Datei „{0}“ enthielt keinen Inhalt und wurde nicht gesendet."
|
||||
|
||||
-- Reading the file '{0}' needs Pandoc, which is not available, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T594894810"] = "Zum Lesen der Datei „{0}“ wird Pandoc benötigt. Da Pandoc nicht verfügbar ist, wurde die Datei nicht gesendet."
|
||||
|
||||
-- AI Studio couldn't install Pandoc because the archive was not found.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1059477764"] = "AI Studio konnte Pandoc nicht installieren, da das Archiv nicht gefunden wurde."
|
||||
|
||||
@ -9612,6 +9672,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1041509726"] = "Text"
|
||||
-- Office Files
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1063218378"] = "Office-Dateien"
|
||||
|
||||
-- Tabular text
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T13157661"] = "Tabellarischer Text"
|
||||
|
||||
-- Executable
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1364437037"] = "Ausführbare Dateien"
|
||||
|
||||
@ -10029,9 +10092,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4261248356"] = "Von der KI
|
||||
-- Pandoc Installation
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T185447014"] = "Pandoc-Installation"
|
||||
|
||||
-- Pandoc may be required for importing files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T2596465560"] = "Für das Importieren von Dateien ist möglicherweise Pandoc erforderlich."
|
||||
|
||||
-- The file path is null or empty and the file therefore can not be loaded.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T932243993"] = "Der Dateipfad ist leer, daher kann die Datei nicht geladen werden."
|
||||
|
||||
|
||||
@ -1737,9 +1737,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T534887559"] =
|
||||
-- Please provide a custom language.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T656744944"] = "Please provide a custom language."
|
||||
|
||||
-- The custom prompt guide file is empty or could not be read.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::PROMPTOPTIMIZER::ASSISTANTPROMPTOPTIMIZER::T1173408044"] = "The custom prompt guide file is empty or could not be read."
|
||||
|
||||
-- Use English for complex prompts and explicitly request response language if needed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::PROMPTOPTIMIZER::ASSISTANTPROMPTOPTIMIZER::T119999744"] = "Use English for complex prompts and explicitly request response language if needed."
|
||||
|
||||
@ -2868,6 +2865,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4188329028"] = "No, kee
|
||||
-- Export Chat to Microsoft Word
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T861873672"] = "Export Chat to Microsoft Word"
|
||||
|
||||
-- The file '{0}' is currently not available and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T1432544573"] = "The file '{0}' is currently not available and was not sent."
|
||||
|
||||
-- The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T3267850764"] = "The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings."
|
||||
|
||||
@ -9084,6 +9084,66 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T599774443"] = "The
|
||||
-- policy files
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T632340680"] = "policy files"
|
||||
|
||||
-- The file type of '{0}' could not be determined, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1459702734"] = "The file type of '{0}' could not be determined, so the file was not sent."
|
||||
|
||||
-- The file '{0}' is an executable program and was not sent, regardless of its file extension.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1481258284"] = "The file '{0}' is an executable program and was not sent, regardless of its file extension."
|
||||
|
||||
-- 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.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1488076079"] = "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."
|
||||
|
||||
-- The pages {1} of the file '{0}' could not be read. The remaining content was sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1928400379"] = "The pages {1} of the file '{0}' could not be read. The remaining content was sent."
|
||||
|
||||
-- Parts of the file '{0}' could not be read. The remaining content was sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2036654169"] = "Parts of the file '{0}' could not be read. The remaining content was sent."
|
||||
|
||||
-- The file type of '{0}' is not supported, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2064321829"] = "The file type of '{0}' is not supported, so the file was not sent."
|
||||
|
||||
-- The file '{0}' is not a readable spreadsheet and was not sent. It might be damaged or transferred incompletely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2240855899"] = "The file '{0}' is not a readable spreadsheet and was not sent. It might be damaged or transferred incompletely."
|
||||
|
||||
-- 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.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2701144378"] = "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."
|
||||
|
||||
-- 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.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2793077828"] = "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."
|
||||
|
||||
-- The file '{0}' is not a readable PDF and was not sent. It might be damaged or transferred incompletely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2891768359"] = "The file '{0}' is not a readable PDF and was not sent. It might be damaged or transferred incompletely."
|
||||
|
||||
-- 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.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2897122009"] = "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."
|
||||
|
||||
-- The file '{0}' is a {1}, which AI Studio cannot read, so it was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3262447403"] = "The file '{0}' is a {1}, which AI Studio cannot read, so it was not sent."
|
||||
|
||||
-- The file '{0}' is actually a {1} and was read as such. Please correct its file extension.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3297602719"] = "The file '{0}' is actually a {1} and was read as such. Please correct its file extension."
|
||||
|
||||
-- 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.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3303873344"] = "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."
|
||||
|
||||
-- The file '{0}' could not be read and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3527027650"] = "The file '{0}' could not be read and was not sent."
|
||||
|
||||
-- The file '{0}' is protected and could not be opened, so it was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3840033580"] = "The file '{0}' is protected and could not be opened, so it was not sent."
|
||||
|
||||
-- AI Studio was not able to start its PDF engine, so the file '{0}' was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3927045859"] = "AI Studio was not able to start its PDF engine, so the file '{0}' was not sent."
|
||||
|
||||
-- The file '{0}' does not exist anymore and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T4071378057"] = "The file '{0}' does not exist anymore and was not sent."
|
||||
|
||||
-- The file '{0}' did not provide any content and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T4291141931"] = "The file '{0}' did not provide any content and was not sent."
|
||||
|
||||
-- Reading the file '{0}' needs Pandoc, which is not available, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T594894810"] = "Reading the file '{0}' needs Pandoc, which is not available, so the file was not sent."
|
||||
|
||||
-- AI Studio couldn't install Pandoc because the archive was not found.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1059477764"] = "AI Studio couldn't install Pandoc because the archive was not found."
|
||||
|
||||
@ -9612,6 +9672,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1041509726"] = "Text"
|
||||
-- Office Files
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1063218378"] = "Office Files"
|
||||
|
||||
-- Tabular text
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T13157661"] = "Tabular text"
|
||||
|
||||
-- Executable
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1364437037"] = "Executable"
|
||||
|
||||
@ -10029,9 +10092,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4261248356"] = "Sources pro
|
||||
-- Pandoc Installation
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T185447014"] = "Pandoc Installation"
|
||||
|
||||
-- Pandoc may be required for importing files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T2596465560"] = "Pandoc may be required for importing files."
|
||||
|
||||
-- The file path is null or empty and the file therefore can not be loaded.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T932243993"] = "The file path is null or empty and the file therefore can not be loaded."
|
||||
|
||||
|
||||
55
app/MindWork AI Studio/Tools/ContentStreamErrorDetails.cs
Normal file
55
app/MindWork AI Studio/Tools/ContentStreamErrorDetails.cs
Normal file
@ -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;
|
||||
}
|
||||
11
app/MindWork AI Studio/Tools/ContentStreamErrorMetadata.cs
Normal file
11
app/MindWork AI Studio/Tools/ContentStreamErrorMetadata.cs
Normal file
@ -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,6 +23,7 @@ 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
|
||||
};
|
||||
|
||||
23
app/MindWork AI Studio/Tools/ContentStreamProcessedEvent.cs
Normal file
23
app/MindWork AI Studio/Tools/ContentStreamProcessedEvent.cs
Normal file
@ -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,15 +16,15 @@ 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;
|
||||
@ -37,11 +37,11 @@ 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(
|
||||
@ -50,17 +50,25 @@ public static class ContentStreamSseHandler
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
86
app/MindWork AI Studio/Tools/FileExtractionErrorCode.cs
Normal file
86
app/MindWork AI Studio/Tools/FileExtractionErrorCode.cs
Normal file
@ -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,
|
||||
}
|
||||
23
app/MindWork AI Studio/Tools/FileExtractionOutcome.cs
Normal file
23
app/MindWork AI Studio/Tools/FileExtractionOutcome.cs
Normal file
@ -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,
|
||||
}
|
||||
47
app/MindWork AI Studio/Tools/FileExtractionResult.cs
Normal file
47
app/MindWork AI Studio/Tools/FileExtractionResult.cs
Normal file
@ -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,7 +92,6 @@ 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);
|
||||
|
||||
return new(false, TB("Was not able to check the Pandoc installation."), false, string.Empty, preparedProcess.IsLocal);
|
||||
@ -102,7 +111,6 @@ 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);
|
||||
|
||||
return new(false, TB("Pandoc is not available on the system or the process had issues."), false, string.Empty, preparedProcess.IsLocal);
|
||||
@ -114,7 +122,6 @@ 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);
|
||||
|
||||
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,7 +148,6 @@ 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());
|
||||
|
||||
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);
|
||||
@ -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.");
|
||||
|
||||
return new(false, TB("Pandoc doesn't seem to be installed."), false, string.Empty, false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
HAS_LOGGED_AVAILABILITY_CHECK_ONCE = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -216,7 +216,11 @@ public sealed class PandocProcessBuilder
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (shouldLog)
|
||||
//
|
||||
// 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);
|
||||
|
||||
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
|
||||
{
|
||||
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();
|
||||
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 string.Empty;
|
||||
|
||||
return FileExtractionResult.Failed(FileExtractionErrorCode.REQUEST_FAILED, $"The runtime answered with the status {(int)response.StatusCode} ({response.ReasonPhrase}).");
|
||||
}
|
||||
|
||||
var resultBuilder = new StringBuilder();
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
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)
|
||||
if (sseEvent is null)
|
||||
continue;
|
||||
|
||||
var processedEvent = ContentStreamSseHandler.ProcessEvent(sseEvent, extractImages);
|
||||
if (processedEvent.Error is not null)
|
||||
{
|
||||
var content = ContentStreamSseHandler.ProcessEvent(sseEvent, extractImages);
|
||||
if (content is not null)
|
||||
resultBuilder.AppendLine(content);
|
||||
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 (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
|
||||
{
|
||||
@ -72,6 +158,24 @@ public sealed partial class RustService
|
||||
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,6 +55,15 @@ 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, _, _) =>
|
||||
@ -54,14 +76,16 @@ public sealed partial class RustService : BackgroundService
|
||||
},
|
||||
};
|
||||
|
||||
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,18 +15,31 @@ 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 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 pandocState = await Pandoc.CheckAvailabilityAsync(rustService, showSuccessMessage: false);
|
||||
if (!pandocState.IsAvailable)
|
||||
{
|
||||
@ -40,12 +54,32 @@ public static class UserFile
|
||||
pandocState = await Pandoc.CheckAvailabilityAsync(rustService, showSuccessMessage: true);
|
||||
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.")));
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,20 @@
|
||||
- Added a priority for configuration plugins. Organizations that deploy several configurations can now decide which one wins: a configuration with a higher priority overrides the settings and providers of a lower one. This allows a company-wide base configuration that each department refines for itself.
|
||||
- Added a way for IT departments to try out a configuration before rolling it out. A configuration placed in the new `.config-tests` directory below the plugins directory acts like one your organization deployed, including the approval of assistant plugins, so a test shows exactly what colleagues will see later. No configuration server is needed for this. AI Studio empties that directory every time it starts, so a test configuration is valid for one session, and the information page reports it while it is active. The Enterprise IT documentation describes the whole procedure.
|
||||
- Improved how your organization's configuration behaves when a configuration plugin is present but cannot be loaded, e.g. because of an error in the plugin. Such a plugin still manages your app, so its settings, providers, data sources, profiles, and chat templates now stay in place instead of being removed.
|
||||
- Added CSV and TSV files to the file types you can attach. AI Studio was already able to read them, but they could not be selected.
|
||||
- Improved reading large files from slow locations such as network drives. AI Studio now waits considerably longer before it gives up, and it tells you when it does.
|
||||
- Changed how approvals for assistant plugins combine when your organization deploys several configurations. They now add up, so a department can approve additional assistant plugins without repeating the approvals of the company-wide configuration. Previously, the last configuration replaced all earlier approvals, which silently required a new security check for those assistants.
|
||||
- Fixed attached files reaching the AI as empty documents when AI Studio could not read them. The AI then answered as if your file had no content, and nothing pointed to a problem. AI Studio now names the cause instead, for example an unavailable network drive, a file another program is blocking, a protected PDF, or a scanned PDF without a text layer, and it no longer attaches such a file.
|
||||
- Fixed files that are open in another program being reported as an unrecognized file type. AI Studio now tells you that the file is currently open elsewhere and asks you to close it. This also works for files on shared network drives, where a colleague might have the file open.
|
||||
- Fixed files with a wrong file extension being reported as empty. AI Studio now recognizes what a file really is by looking at its content, for example a PowerPoint presentation that was renamed to `.txt`, and reads it accordingly. It also points out the wrong extension so you can correct it.
|
||||
- Fixed files whose content is not text being sent as an empty document. AI Studio now tells you that the file is not readable as text, which usually means it carries a wrong file extension.
|
||||
- Fixed executable programs with a harmless file extension being read as text. They are now recognized by their content and refused.
|
||||
- Fixed a single unreadable page of a PDF silently cutting off the rest of the document. The remaining pages are now used, and AI Studio tells you which pages are missing.
|
||||
- Fixed a single unreadable sheet of a spreadsheet silently dropping all remaining sheets.
|
||||
- Fixed PDFs, text files, spreadsheets, and presentations requiring Pandoc. Only Word documents, OpenDocument text files, and HTML files need Pandoc, so every other file can now be attached and read without it.
|
||||
- Fixed attached files that are temporarily unavailable disappearing from your message without a word. This could happen when a file was stored on a network drive.
|
||||
- Fixed the file preview showing an empty document when reading the file failed. It now shows what went wrong, so the preview again answers what AI Studio will hand to the AI.
|
||||
- Fixed problems while reading files being missing from the log file after the first one. This made exactly those issues hard to track down that only appeared later on.
|
||||
- Fixed reset buttons in assistants. As you may have noticed in the Document Analysis Assistant, resetting it could leave content from the previous analysis visible. Reset buttons now clear previous results completely.
|
||||
- Fixed dropping files after you closed a dialog that accepts files itself. Such a dialog takes over dropped files while it is open, but never handed that role back when you closed it. Afterwards, the chat and the assistants silently ignored dropped files until you switched to another page. Each time you opened such a dialog again, the problem got worse.
|
||||
- Fixed configuration-managed settings remaining active after their configuration plugin was removed.
|
||||
@ -20,4 +33,5 @@
|
||||
- Fixed preview features contributed by several configuration plugins at once. Only the most recent contribution was recognized as coming from your organization, so features enabled by another configuration looked as if you had switched them on yourself. Each configuration is now tracked separately, which lets your organization enable one preview feature company-wide and another one for a single department.
|
||||
- Fixed which configuration wins when two configuration plugins collide, e.g. by claiming the same plugin ID, by managing the same setting, or by defining the same provider. Previously, this was down to chance, so a local configuration plugin could take over parts of the configuration your IT department deployed. Configurations from your organization now always win, and every ignored attempt is reported in the log.
|
||||
- Fixed the assistant categories when your organization hides individual assistants. A category heading could stay visible above an empty area, and the Log Viewer could disappear together with the Localization assistant. Each heading now follows the assistants actually shown below it.
|
||||
- Removed the legacy PowerPoint format (`.ppt`) from the selectable file types. AI Studio has no reader for it, so such a file could be attached but never read. The modern `.pptx` format is not affected.
|
||||
- Upgraded dependencies to their latest versions to improve security and stability.
|
||||
19
runtime/Cargo.lock
generated
19
runtime/Cargo.lock
generated
@ -1251,6 +1251,17 @@ dependencies = [
|
||||
"whatlang",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chardetng"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13de944a44b5064ee5d3a5ceccc49a41bfec50f2580e66f82e87703acdb88b53"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"encoding_rs",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@ -2160,9 +2171,9 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.34"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
@ -4062,7 +4073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-targets 0.48.5",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -4249,9 +4260,11 @@ dependencies = [
|
||||
"calamine",
|
||||
"cbc 0.2.1",
|
||||
"cfg-if",
|
||||
"chardetng",
|
||||
"dbus-secret-service",
|
||||
"dbus-secret-service-keyring-store",
|
||||
"dirs",
|
||||
"encoding_rs",
|
||||
"file-format",
|
||||
"flexi_logger",
|
||||
"futures",
|
||||
|
||||
@ -39,7 +39,19 @@ pbkdf2 = "0.13.0"
|
||||
hmac = "0.13.0"
|
||||
sha2 = "0.11.0"
|
||||
rcgen = { version = "0.14.8", features = ["pem"] }
|
||||
file-format = "0.29.0"
|
||||
|
||||
# The readers are needed to identify a file by its content instead of its extension: zip covers
|
||||
# OOXML and ODF, cfb the legacy Office formats, txt tells actual text from binary data, and exe
|
||||
# recognizes executables which carry a harmless extension. Without them, every ZIP-based document
|
||||
# is only detected as a plain archive.
|
||||
file-format = { version = "0.29.0", features = ["reader-zip", "reader-cfb", "reader-txt", "reader-exe"] }
|
||||
|
||||
# Text files are not always UTF-8: on Windows they are frequently encoded in Windows-1252, whose
|
||||
# umlauts are single bytes and therefore invalid UTF-8. chardetng guesses the encoding, encoding_rs
|
||||
# decodes it.
|
||||
chardetng = "1.0.0"
|
||||
encoding_rs = "0.8.35"
|
||||
|
||||
symphonia = { version = "0.6", default-features = false, features = ["aac", "aiff", "alac", "caf", "flac", "isomp4", "mkv", "mp1", "mp2", "mp3", "ogg", "pcm", "vorbis", "wav"] }
|
||||
ropus = "=0.12.18"
|
||||
rubato = { version = "4", default-features = false, features = ["fft_resampler"] }
|
||||
|
||||
@ -8,10 +8,12 @@ use axum::extract::Query;
|
||||
use axum::extract::rejection::QueryRejection;
|
||||
use axum::response::sse::{Event, Sse};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use calamine::{open_workbook_auto, Reader};
|
||||
use calamine::{open_workbook_auto, Error as CalamineError, Reader};
|
||||
use chardetng::{EncodingDetector, Iso2022JpDetection, Utf8Detection};
|
||||
use encoding_rs::Encoding;
|
||||
use file_format::{FileFormat, Kind};
|
||||
use futures::{Stream, StreamExt};
|
||||
use pdfium_render::prelude::Pdfium;
|
||||
use pdfium_render::prelude::{Pdfium, PdfiumError, PdfiumInternalError};
|
||||
use pptx_to_md::{DiagnosticSeverity, ImageHandlingMode, MarkdownOptions, ParserConfig, PresentationContainer, PresentationFormat, PresentationMetadata, ReadingOrder};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde::de::{Error as SerdeError, Visitor};
|
||||
@ -19,7 +21,7 @@ use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
use std::fmt;
|
||||
use log::{debug, error, warn};
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
@ -35,6 +37,22 @@ impl Chunk {
|
||||
Chunk { content, stream_id: String::new(), metadata }
|
||||
}
|
||||
|
||||
/// Creates a chunk which reports a failed extraction. Errors travel through the same
|
||||
/// schema as content chunks, so the .NET app is able to deserialize and surface them
|
||||
/// instead of silently treating a failure as empty file content.
|
||||
pub fn from_error(error: &ExtractionError) -> Self {
|
||||
Chunk {
|
||||
content: String::new(),
|
||||
stream_id: String::new(),
|
||||
metadata: Metadata::Error {
|
||||
code: error.code,
|
||||
message: error.message.clone(),
|
||||
page_number: error.page_number,
|
||||
detected_format: error.detected_format.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_stream_id(&mut self, stream_id: &str) { self.stream_id = stream_id.to_string(); }
|
||||
}
|
||||
|
||||
@ -60,6 +78,135 @@ pub enum Metadata {
|
||||
slide_number: u32,
|
||||
image: Option<Base64Image>,
|
||||
},
|
||||
|
||||
Error {
|
||||
code: ExtractionErrorCode,
|
||||
message: String,
|
||||
page_number: Option<usize>,
|
||||
detected_format: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Classifies why an extraction failed, so the .NET app can tell the user what happened
|
||||
/// instead of showing an empty document.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum ExtractionErrorCode {
|
||||
/// The request itself was malformed, e.g. a missing query parameter.
|
||||
InvalidRequest,
|
||||
|
||||
FileNotFound,
|
||||
FileNotReadable,
|
||||
|
||||
/// Another process holds the file open and denies us reading it.
|
||||
FileLocked,
|
||||
|
||||
FormatDetectionFailed,
|
||||
NotAValidPdf,
|
||||
NotAValidSpreadsheet,
|
||||
PdfiumUnavailable,
|
||||
PdfEncrypted,
|
||||
PageExtractionFailed,
|
||||
NoTextExtracted,
|
||||
|
||||
/// The content does not match the file extension. This is a notice, not a failure: we read
|
||||
/// the file according to its content and only tell the user about the wrong extension.
|
||||
ExtensionMismatch,
|
||||
|
||||
/// The file was read as text, but its bytes are not text.
|
||||
NotTextContent,
|
||||
|
||||
/// The file is an executable, no matter what its extension claims.
|
||||
ExecutableRejected,
|
||||
|
||||
Unsupported,
|
||||
|
||||
/// Any failure which does not carry a code of its own yet.
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// An extraction failure with a machine-readable code. It implements `std::error::Error`,
|
||||
/// so it travels through the existing boxed error channel and `?` keeps working for the
|
||||
/// error types of the underlying crates.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExtractionError {
|
||||
pub code: ExtractionErrorCode,
|
||||
pub message: String,
|
||||
pub page_number: Option<usize>,
|
||||
|
||||
/// The format we identified by looking at the content, e.g. when it contradicts the file
|
||||
/// extension. The app names it so the user learns what the file really is.
|
||||
pub detected_format: Option<String>,
|
||||
}
|
||||
|
||||
impl ExtractionError {
|
||||
pub fn new(code: ExtractionErrorCode, message: impl Into<String>) -> Self {
|
||||
Self { code, message: message.into(), page_number: None, detected_format: None }
|
||||
}
|
||||
|
||||
pub fn on_page(code: ExtractionErrorCode, message: impl Into<String>, page_number: usize) -> Self {
|
||||
Self { code, message: message.into(), page_number: Some(page_number), detected_format: None }
|
||||
}
|
||||
|
||||
/// Creates an error which names the format we identified by looking at the content.
|
||||
pub fn with_detected_format(code: ExtractionErrorCode, message: impl Into<String>, detected_format: &FileFormat) -> Self {
|
||||
Self { code, message: message.into(), page_number: None, detected_format: Some(detected_format.name().to_string()) }
|
||||
}
|
||||
|
||||
/// Recovers the structured error from a boxed error. Errors which do not carry a code
|
||||
/// yet are reported as `Internal`, so every failure reaches the .NET app through the
|
||||
/// same schema.
|
||||
fn from_boxed(error: &(dyn std::error::Error + Send + Sync + 'static)) -> Self {
|
||||
match error.downcast_ref::<ExtractionError>() {
|
||||
Some(extraction_error) => extraction_error.clone(),
|
||||
None => Self::new(ExtractionErrorCode::Internal, error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ExtractionError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self.page_number {
|
||||
Some(page_number) => write!(formatter, "[{:?}] page {page_number}: {}", self.code, self.message),
|
||||
None => write!(formatter, "[{:?}] {}", self.code, self.message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ExtractionError {}
|
||||
|
||||
/// Detects whether a file system error means that another process holds the file open.
|
||||
///
|
||||
/// Windows answers with `ERROR_SHARING_VIOLATION` (32) or `ERROR_LOCK_VIOLATION` (33). This also
|
||||
/// covers files on a network drive, because the SMB server enforces the lock and the client
|
||||
/// surfaces the very same codes.
|
||||
#[cfg(windows)]
|
||||
fn is_locked_error(error: &std::io::Error) -> bool {
|
||||
matches!(error.raw_os_error(), Some(32) | Some(33))
|
||||
}
|
||||
|
||||
/// Detects whether a file system error means that another process holds the file open.
|
||||
///
|
||||
/// Unix has no distinct error for this. A lock held through an SMB share surfaces as a permission
|
||||
/// problem, which we cannot tell apart from an actual permission problem, so we never claim a file
|
||||
/// is locked here.
|
||||
#[cfg(not(windows))]
|
||||
fn is_locked_error(_error: &std::io::Error) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Classifies a file system error, so a file which another program holds open is reported as such
|
||||
/// instead of collapsing into a generic read failure.
|
||||
fn classify_io_error(error: &std::io::Error) -> ExtractionErrorCode {
|
||||
if is_locked_error(error) {
|
||||
return ExtractionErrorCode::FileLocked;
|
||||
}
|
||||
|
||||
match error.kind() {
|
||||
std::io::ErrorKind::NotFound => ExtractionErrorCode::FileNotFound,
|
||||
std::io::ErrorKind::InvalidData => ExtractionErrorCode::FormatDetectionFailed,
|
||||
_ => ExtractionErrorCode::FileNotReadable,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@ -77,10 +224,27 @@ impl Base64Image {
|
||||
}
|
||||
|
||||
const TO_MARKDOWN: &str = "markdown";
|
||||
|
||||
/// Pandoc's markup-free output format. We do not use it as content, only to find out whether a
|
||||
/// conversion produced any readable text at all.
|
||||
const PANDOC_PLAIN: &str = "plain";
|
||||
|
||||
const DOCX: &str = "docx";
|
||||
const ODT: &str = "odt";
|
||||
const HTML: &str = "html";
|
||||
const IMAGE_SEGMENT_SIZE_IN_CHARS: usize = 8_192; // equivalent to ~ 5500 token
|
||||
|
||||
/// Every PDF file starts with this signature.
|
||||
const PDF_MAGIC: &[u8] = b"%PDF-";
|
||||
|
||||
/// How many bytes we probe to verify the PDF signature. The few extra bytes beyond the
|
||||
/// signature itself make the diagnostics useful when the signature does not match.
|
||||
const PDF_HEADER_PROBE_SIZE: u64 = 8;
|
||||
|
||||
/// Last-resort payload used when even an error event cannot be serialized. It keeps the
|
||||
/// chunk schema intact, so the .NET app never has to parse a bare string.
|
||||
const FALLBACK_ERROR_EVENT_JSON: &str = r#"{"content":"","stream_id":"","metadata":{"Error":{"code":"INTERNAL","message":"The extraction error could not be serialized.","page_number":null,"detected_format":null}}}"#;
|
||||
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
||||
type ChunkStream = Pin<Box<dyn Stream<Item = Result<Chunk>> + Send>>;
|
||||
|
||||
@ -124,6 +288,20 @@ where
|
||||
deserializer.deserialize_any(BoolVisitor)
|
||||
}
|
||||
|
||||
/// Reports an extraction failure as a schema-conformant SSE event, so the .NET app is able
|
||||
/// to deserialize it like any other chunk.
|
||||
fn error_event(error: &ExtractionError, stream_id: Option<&str>) -> Event {
|
||||
let mut chunk = Chunk::from_error(error);
|
||||
if let Some(stream_id) = stream_id {
|
||||
chunk.set_stream_id(stream_id);
|
||||
}
|
||||
|
||||
Event::default().json_data(&chunk).unwrap_or_else(|serialization_error| {
|
||||
error!("Failed to serialize an extraction error event: {serialization_error}");
|
||||
Event::default().data(FALLBACK_ERROR_EVENT_JSON)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn extract_data(
|
||||
_token: APIToken,
|
||||
query: std::result::Result<Query<ExtractDataQuery>, QueryRejection>,
|
||||
@ -133,7 +311,7 @@ pub async fn extract_data(
|
||||
Err(e) => {
|
||||
let message = format!("Invalid query for '/retrieval/fs/extract': {e}");
|
||||
warn!("{message}");
|
||||
Err(message)
|
||||
Err(ExtractionError::new(ExtractionErrorCode::InvalidRequest, message))
|
||||
},
|
||||
};
|
||||
|
||||
@ -142,6 +320,7 @@ pub async fn extract_data(
|
||||
Ok(query) => {
|
||||
let stream_result = stream_data(&query.path, query.extract_images).await;
|
||||
let id_ref = &query.stream_id;
|
||||
let path_ref = &query.path;
|
||||
|
||||
match stream_result {
|
||||
Ok(mut stream) => {
|
||||
@ -149,11 +328,16 @@ pub async fn extract_data(
|
||||
match chunk {
|
||||
Ok(mut chunk) => {
|
||||
chunk.set_stream_id(id_ref);
|
||||
yield Ok(Event::default().json_data(&chunk).unwrap_or_else(|e| Event::default().data(format!("Error: {e}"))));
|
||||
yield Ok(Event::default().json_data(&chunk).unwrap_or_else(|e| {
|
||||
error!("Failed to serialize a content chunk for '{path_ref}': {e}");
|
||||
error_event(&ExtractionError::new(ExtractionErrorCode::Internal, format!("Failed to serialize a content chunk: {e}")), Some(id_ref))
|
||||
}));
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
yield Ok(Event::default().json_data(format!("Error: {e}")).unwrap_or_else(|_| Event::default().data(format!("Error: {e}"))));
|
||||
let extraction_error = ExtractionError::from_boxed(e.as_ref());
|
||||
error!("Extraction failed for '{path_ref}': {extraction_error}");
|
||||
yield Ok(error_event(&extraction_error, Some(id_ref)));
|
||||
break;
|
||||
},
|
||||
}
|
||||
@ -161,13 +345,15 @@ pub async fn extract_data(
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
yield Ok(Event::default().json_data(format!("Error starting stream: {e}")).unwrap_or_else(|_| Event::default().data(format!("Error starting stream: {e}"))));
|
||||
let extraction_error = ExtractionError::from_boxed(e.as_ref());
|
||||
error!("Could not start the extraction stream for '{path_ref}': {extraction_error}");
|
||||
yield Ok(error_event(&extraction_error, Some(id_ref)));
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
yield Ok(Event::default().json_data(format!("Error starting stream: {e}")).unwrap_or_else(|_| Event::default().data(format!("Error starting stream: {e}"))));
|
||||
Err(extraction_error) => {
|
||||
yield Ok(error_event(&extraction_error, None));
|
||||
},
|
||||
}
|
||||
};
|
||||
@ -175,18 +361,107 @@ pub async fn extract_data(
|
||||
Sse::new(stream)
|
||||
}
|
||||
|
||||
/// How a file is read.
|
||||
///
|
||||
/// Deriving the route from the extension and from the content separately is what lets us notice
|
||||
/// when the two disagree, instead of trusting a possibly wrong extension blindly.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ExtractionRoute {
|
||||
Pdf,
|
||||
PandocDocx,
|
||||
PandocOdt,
|
||||
PandocHtml,
|
||||
PresentationPptx,
|
||||
PresentationOdp,
|
||||
Spreadsheet,
|
||||
Csv,
|
||||
Text,
|
||||
Image,
|
||||
|
||||
/// The file is an executable and is never read.
|
||||
Executable,
|
||||
|
||||
/// A format we recognize but have no reader for, e.g. the legacy binary Office formats.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// Derives the route from the file extension.
|
||||
fn route_from_extension(ext: &str) -> Option<ExtractionRoute> {
|
||||
match ext {
|
||||
"pdf" => Some(ExtractionRoute::Pdf),
|
||||
DOCX => Some(ExtractionRoute::PandocDocx),
|
||||
ODT => Some(ExtractionRoute::PandocOdt),
|
||||
HTML | "htm" => Some(ExtractionRoute::PandocHtml),
|
||||
"csv" | "tsv" => Some(ExtractionRoute::Csv),
|
||||
"pptx" => Some(ExtractionRoute::PresentationPptx),
|
||||
"odp" => Some(ExtractionRoute::PresentationOdp),
|
||||
"xlsx" | "ods" | "xls" | "xlsm" | "xlsb" | "xla" | "xlam" => Some(ExtractionRoute::Spreadsheet),
|
||||
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "tiff" | "svg" | "webp" | "heic" => Some(ExtractionRoute::Image),
|
||||
|
||||
//
|
||||
// Everything else claims nothing in particular. Text formats end up here on purpose:
|
||||
// their content cannot be identified beyond "this is text", so there is nothing to
|
||||
// contradict. Every extension which does have a reader must be listed above, otherwise
|
||||
// a correctly named file looks like a mismatch.
|
||||
//
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derives the route from the content we identified.
|
||||
///
|
||||
/// `None` means the content does not point at any particular reader. Such a file keeps whatever
|
||||
/// its extension asks for, and the text reader decides whether the bytes are readable at all.
|
||||
fn route_from_content(fmt: FileFormat) -> Option<ExtractionRoute> {
|
||||
match fmt {
|
||||
FileFormat::PortableDocumentFormat => Some(ExtractionRoute::Pdf),
|
||||
FileFormat::OfficeOpenXmlDocument => Some(ExtractionRoute::PandocDocx),
|
||||
FileFormat::OpendocumentText => Some(ExtractionRoute::PandocOdt),
|
||||
FileFormat::HypertextMarkupLanguage => Some(ExtractionRoute::PandocHtml),
|
||||
FileFormat::OfficeOpenXmlPresentation => Some(ExtractionRoute::PresentationPptx),
|
||||
FileFormat::OpendocumentPresentation => Some(ExtractionRoute::PresentationOdp),
|
||||
|
||||
// Calamine reads the legacy binary spreadsheet format as well:
|
||||
FileFormat::OfficeOpenXmlSpreadsheet
|
||||
| FileFormat::OpendocumentSpreadsheet
|
||||
| FileFormat::MicrosoftExcelSpreadsheet => Some(ExtractionRoute::Spreadsheet),
|
||||
|
||||
FileFormat::PlainText => Some(ExtractionRoute::Text),
|
||||
|
||||
//
|
||||
// The legacy binary Word and PowerPoint formats have no reader here: pptx_to_md only
|
||||
// handles PPTX and ODP, and Pandoc cannot read the binary .doc format at all. Saying so
|
||||
// is better than handing the file to a reader which is bound to fail.
|
||||
//
|
||||
FileFormat::MicrosoftWordDocument | FileFormat::MicrosoftPowerpointPresentation => Some(ExtractionRoute::Unsupported),
|
||||
|
||||
_ => match fmt.kind() {
|
||||
Kind::Executable => Some(ExtractionRoute::Executable),
|
||||
Kind::Image => Some(ExtractionRoute::Image),
|
||||
Kind::Ebook | Kind::Archive | Kind::Compressed => Some(ExtractionRoute::Unsupported),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_data(file_path: &str, extract_images: bool) -> Result<ChunkStream> {
|
||||
if !Path::new(file_path).exists() {
|
||||
error!("File does not exist: '{file_path}'");
|
||||
return Err("File does not exist.".into());
|
||||
return Err(ExtractionError::new(ExtractionErrorCode::FileNotFound, format!("The file does not exist: '{file_path}'.")).into());
|
||||
}
|
||||
|
||||
let file_path_clone = file_path.to_owned();
|
||||
let fmt = match FileFormat::from_file(&file_path_clone) {
|
||||
Ok(format) => format,
|
||||
Err(error) => {
|
||||
error!("Failed to determine file format for '{file_path}': {error}");
|
||||
return Err(format!("Failed to determine file format for '{file_path}': {error}").into());
|
||||
//
|
||||
// Detecting the format opens the file, so this is the first place a file which another
|
||||
// program holds open fails. Reporting that as a format problem would send the user
|
||||
// looking in the wrong direction, hence we classify the error instead.
|
||||
//
|
||||
let code = classify_io_error(&error);
|
||||
error!("Failed to read '{file_path}' while determining its file format ({code:?}): {error}");
|
||||
return Err(ExtractionError::new(code, format!("The file could not be read: {error}")).into());
|
||||
},
|
||||
};
|
||||
|
||||
@ -195,82 +470,155 @@ async fn stream_data(file_path: &str, extract_images: bool) -> Result<ChunkStrea
|
||||
.and_then(|extension| extension.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.unwrap_or_default();
|
||||
debug!("Extracting data from file: '{file_path}', format: '{fmt:?}', extension: '{ext}'");
|
||||
|
||||
let stream = match ext.as_str() {
|
||||
DOCX | ODT => {
|
||||
let from = if ext == DOCX { "docx" } else { "odt" };
|
||||
convert_with_pandoc(file_path, from, TO_MARKDOWN).await?
|
||||
}
|
||||
|
||||
"csv" | "tsv" => {
|
||||
stream_text_file(file_path, true, Some("csv".to_string())).await?
|
||||
},
|
||||
|
||||
"pptx" => stream_presentation(file_path, extract_images, PresentationFormat::Pptx).await?,
|
||||
"odp" => stream_presentation(file_path, extract_images, PresentationFormat::Odp).await?,
|
||||
|
||||
"xlsx" | "ods" | "xls" | "xlsm" | "xlsb" | "xla" | "xlam" => {
|
||||
stream_spreadsheet_as_csv(file_path).await?
|
||||
}
|
||||
|
||||
_ => match fmt.kind() {
|
||||
Kind::Document => match fmt {
|
||||
FileFormat::PortableDocumentFormat => stream_pdf(file_path).await?,
|
||||
|
||||
FileFormat::MicrosoftWordDocument => {
|
||||
convert_with_pandoc(file_path, "docx", TO_MARKDOWN).await?
|
||||
},
|
||||
|
||||
FileFormat::OfficeOpenXmlDocument => {
|
||||
convert_with_pandoc(file_path, fmt.extension(), TO_MARKDOWN).await?
|
||||
},
|
||||
|
||||
_ => stream_text_file(file_path, false, None).await?,
|
||||
},
|
||||
|
||||
Kind::Ebook => return Err("Ebooks not yet supported".into()),
|
||||
|
||||
Kind::Image => {
|
||||
if !extract_images {
|
||||
return Err("Image extraction is disabled.".into());
|
||||
}
|
||||
|
||||
chunk_image(file_path).await?
|
||||
},
|
||||
|
||||
Kind::Other => match fmt {
|
||||
FileFormat::HypertextMarkupLanguage => {
|
||||
convert_with_pandoc(file_path, fmt.extension(), TO_MARKDOWN).await?
|
||||
},
|
||||
|
||||
_ => stream_text_file(file_path, false, None).await?,
|
||||
},
|
||||
|
||||
Kind::Presentation => match fmt {
|
||||
FileFormat::OfficeOpenXmlPresentation => {
|
||||
stream_presentation(file_path, extract_images, PresentationFormat::Pptx).await?
|
||||
},
|
||||
FileFormat::OpendocumentPresentation => {
|
||||
stream_presentation(file_path, extract_images, PresentationFormat::Odp).await?
|
||||
}
|
||||
|
||||
_ => stream_text_file(file_path, false, None).await?,
|
||||
},
|
||||
|
||||
Kind::Spreadsheet => stream_spreadsheet_as_csv(file_path).await?,
|
||||
|
||||
_ => stream_text_file(file_path, false, None).await?,
|
||||
},
|
||||
// The size is part of the diagnostics: a truncated or not-yet-available file on a network
|
||||
// share is what tells a broken extraction apart from a document without text.
|
||||
let file_size = match tokio::fs::metadata(file_path).await {
|
||||
Ok(metadata) => format!("{} bytes", metadata.len()),
|
||||
Err(error) => format!("unknown size ({error})"),
|
||||
};
|
||||
|
||||
debug!("Extracting data from file: '{file_path}', {file_size}, format: '{fmt:?}', extension: '{ext}'");
|
||||
|
||||
let extension_route = route_from_extension(ext.as_str());
|
||||
let content_route = route_from_content(fmt);
|
||||
|
||||
//
|
||||
// The content decides whenever it points at a specific reader and contradicts the extension.
|
||||
// `Text` is excluded on purpose: it is the least specific answer, and letting it win would
|
||||
// cost a `.csv` its CSV fence. When the content says nothing, the extension keeps its say and
|
||||
// the text reader decides whether the bytes are readable at all.
|
||||
//
|
||||
let content_is_specific = matches!(content_route, Some(route) if route != ExtractionRoute::Text);
|
||||
let content_contradicts_extension = content_is_specific && content_route != extension_route;
|
||||
|
||||
let route = match (extension_route, content_route) {
|
||||
_ if content_contradicts_extension => content_route.unwrap(),
|
||||
(Some(from_extension), _) => from_extension,
|
||||
(None, Some(from_content)) => from_content,
|
||||
(None, None) => ExtractionRoute::Text,
|
||||
};
|
||||
|
||||
debug!("Reading '{file_path}' via {route:?} (extension: {extension_route:?}, content: {content_route:?}).");
|
||||
|
||||
match route {
|
||||
ExtractionRoute::Executable => {
|
||||
error!("Refused to read '{file_path}': its content is an executable ({name}).", name = fmt.name());
|
||||
return Err(ExtractionError::with_detected_format(
|
||||
ExtractionErrorCode::ExecutableRejected,
|
||||
format!("The file is an executable ({name}), which is never read.", name = fmt.name()),
|
||||
&fmt,
|
||||
).into());
|
||||
},
|
||||
|
||||
ExtractionRoute::Unsupported => {
|
||||
return Err(ExtractionError::with_detected_format(
|
||||
ExtractionErrorCode::Unsupported,
|
||||
format!("The format '{name}' is not supported.", name = fmt.name()),
|
||||
&fmt,
|
||||
).into());
|
||||
},
|
||||
|
||||
ExtractionRoute::Image if !extract_images => {
|
||||
return Err(ExtractionError::new(ExtractionErrorCode::Unsupported, "Image extraction is disabled.").into());
|
||||
},
|
||||
|
||||
_ => {},
|
||||
}
|
||||
|
||||
let stream = match route {
|
||||
ExtractionRoute::Pdf => stream_pdf(file_path).await?,
|
||||
ExtractionRoute::PandocDocx => convert_with_pandoc(file_path, DOCX, TO_MARKDOWN).await?,
|
||||
ExtractionRoute::PandocOdt => convert_with_pandoc(file_path, ODT, TO_MARKDOWN).await?,
|
||||
ExtractionRoute::PandocHtml => convert_with_pandoc(file_path, HTML, TO_MARKDOWN).await?,
|
||||
ExtractionRoute::PresentationPptx => stream_presentation(file_path, extract_images, PresentationFormat::Pptx).await?,
|
||||
ExtractionRoute::PresentationOdp => stream_presentation(file_path, extract_images, PresentationFormat::Odp).await?,
|
||||
ExtractionRoute::Spreadsheet => stream_spreadsheet_as_csv(file_path).await?,
|
||||
ExtractionRoute::Csv => stream_text_file(file_path, true, Some("csv".to_string())).await?,
|
||||
ExtractionRoute::Text => stream_text_file(file_path, false, None).await?,
|
||||
ExtractionRoute::Image => chunk_image(file_path).await?,
|
||||
|
||||
// Handled above, before any reader was chosen:
|
||||
ExtractionRoute::Executable | ExtractionRoute::Unsupported => unreachable!(),
|
||||
};
|
||||
|
||||
//
|
||||
// The file was readable, but not as its extension claims. We prepend a notice so the user
|
||||
// learns what the file really is, while the content itself is read correctly.
|
||||
//
|
||||
if content_contradicts_extension {
|
||||
warn!("The content of '{file_path}' is '{name}', which does not match its extension '{ext}'.", name = fmt.name());
|
||||
|
||||
let notice = Chunk::from_error(&ExtractionError::with_detected_format(
|
||||
ExtractionErrorCode::ExtensionMismatch,
|
||||
format!("The content is '{name}', which does not match the file extension '{ext}'.", name = fmt.name()),
|
||||
&fmt,
|
||||
));
|
||||
|
||||
let notice_stream = stream! { yield Ok(notice); };
|
||||
return Ok(Box::pin(notice_stream.chain(stream)));
|
||||
}
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
/// How many bytes we inspect for NUL bytes to tell binary content from text.
|
||||
const BINARY_PROBE_SIZE: usize = 8_192;
|
||||
|
||||
/// Reads a text file and decodes it, no matter which encoding it uses.
|
||||
///
|
||||
/// Insisting on UTF-8 is not enough in practice: text files written on Windows are frequently
|
||||
/// encoded in Windows-1252, where umlauts are single bytes which UTF-8 rejects. Such a file used
|
||||
/// to look like it was not text at all.
|
||||
async fn read_text_file(file_path: &str) -> Result<String> {
|
||||
let bytes = tokio::fs::read(file_path).await.map_err(|error| ExtractionError::new(
|
||||
classify_io_error(&error),
|
||||
format!("The file could not be read: {error}"),
|
||||
))?;
|
||||
|
||||
//
|
||||
// A byte order mark is authoritative and also covers UTF-16, which the detector below does not
|
||||
// recognize. We therefore check it first and let `decode` act on it.
|
||||
//
|
||||
if let Some((encoding, _)) = Encoding::for_bom(&bytes) {
|
||||
let (text, _, _) = encoding.decode(&bytes);
|
||||
debug!("Decoded '{file_path}' as {name}, chosen by its byte order mark.", name = encoding.name());
|
||||
return Ok(text.into_owned());
|
||||
}
|
||||
|
||||
//
|
||||
// Without a byte order mark, every byte sequence decodes into *something*, so the decoder can
|
||||
// no longer tell us that a file is binary. NUL bytes do: they do not occur in text, and after
|
||||
// the check above no UTF-16 file can reach this point.
|
||||
//
|
||||
let probe_length = min(bytes.len(), BINARY_PROBE_SIZE);
|
||||
if bytes[..probe_length].contains(&0) {
|
||||
return Err(ExtractionError::new(
|
||||
ExtractionErrorCode::NotTextContent,
|
||||
"The file contains binary data and is not a text file.",
|
||||
).into());
|
||||
}
|
||||
|
||||
//
|
||||
// Both options are about untrusted web content which may run scripts, which is not what we
|
||||
// read here: these are local files the user picked, so allowing both guesses gives the better
|
||||
// detection.
|
||||
//
|
||||
let mut detector = EncodingDetector::new(Iso2022JpDetection::Allow);
|
||||
detector.feed(&bytes, true);
|
||||
|
||||
let (text, encoding, had_errors) = detector.guess(None, Utf8Detection::Allow).decode(&bytes);
|
||||
if had_errors {
|
||||
warn!("Decoding '{file_path}' as {name} replaced malformed sequences.", name = encoding.name());
|
||||
} else {
|
||||
debug!("Decoded '{file_path}' as {name}.", name = encoding.name());
|
||||
}
|
||||
|
||||
Ok(text.into_owned())
|
||||
}
|
||||
|
||||
async fn stream_text_file(file_path: &str, use_md_fences: bool, fence_language: Option<String>) -> Result<ChunkStream> {
|
||||
let file = tokio::fs::File::open(file_path).await?;
|
||||
let reader = tokio::io::BufReader::new(file);
|
||||
let mut lines = reader.lines();
|
||||
let text = read_text_file(file_path).await?;
|
||||
let mut line_number = 0;
|
||||
|
||||
let stream = stream! {
|
||||
@ -291,10 +639,10 @@ async fn stream_text_file(file_path: &str, use_md_fences: bool, fence_language:
|
||||
};
|
||||
}
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
for line in text.lines() {
|
||||
line_number += 1;
|
||||
yield Ok(Chunk::new(
|
||||
line,
|
||||
line.to_string(),
|
||||
Metadata::Text { line_number }
|
||||
));
|
||||
}
|
||||
@ -307,7 +655,62 @@ async fn stream_text_file(file_path: &str, use_md_fences: bool, fence_language:
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
/// Verifies the file really is a PDF before handing it to PDFium. Without this check, a file
|
||||
/// which only carries the `.pdf` extension, or whose bytes are not available, would end up in
|
||||
/// the text branch and silently produce empty content.
|
||||
async fn ensure_pdf_header(file_path: &str) -> Result<()> {
|
||||
let file = tokio::fs::File::open(file_path).await.map_err(|error| ExtractionError::new(
|
||||
classify_io_error(&error),
|
||||
format!("The file could not be opened: {error}"),
|
||||
))?;
|
||||
|
||||
let file_size = file.metadata().await.map_err(|error| ExtractionError::new(
|
||||
classify_io_error(&error),
|
||||
format!("The file size could not be read: {error}"),
|
||||
))?.len();
|
||||
|
||||
let mut header = Vec::with_capacity(PDF_HEADER_PROBE_SIZE as usize);
|
||||
file.take(PDF_HEADER_PROBE_SIZE).read_to_end(&mut header).await.map_err(|error| ExtractionError::new(
|
||||
classify_io_error(&error),
|
||||
format!("The first bytes of the file could not be read: {error}"),
|
||||
))?;
|
||||
|
||||
if header.starts_with(PDF_MAGIC) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let header_hex = header.iter().map(|byte| format!("{byte:02x}")).collect::<Vec<_>>().join(" ");
|
||||
error!("The file '{file_path}' does not start with the PDF signature; size: {file_size} bytes, first bytes: [{header_hex}].");
|
||||
|
||||
Err(ExtractionError::new(
|
||||
ExtractionErrorCode::NotAValidPdf,
|
||||
format!("The file does not start with the PDF signature. Size: {file_size} bytes, first bytes: [{header_hex}]."),
|
||||
).into())
|
||||
}
|
||||
|
||||
/// Classifies why PDFium refused to open a document, so the cause reaches the user instead of
|
||||
/// collapsing into a generic failure.
|
||||
fn classify_pdf_load_error(error: &PdfiumError) -> ExtractionError {
|
||||
let code = match error {
|
||||
PdfiumError::PdfiumLibraryInternalError(internal_error) => match internal_error {
|
||||
// The document is encrypted or its security settings forbid access:
|
||||
PdfiumInternalError::PasswordError | PdfiumInternalError::SecurityError => ExtractionErrorCode::PdfEncrypted,
|
||||
|
||||
// Pdfium could not read the file itself, e.g. because a network share went away:
|
||||
PdfiumInternalError::FileError => ExtractionErrorCode::FileNotReadable,
|
||||
|
||||
_ => ExtractionErrorCode::NotAValidPdf,
|
||||
},
|
||||
|
||||
_ => ExtractionErrorCode::NotAValidPdf,
|
||||
};
|
||||
|
||||
ExtractionError::new(code, format!("The PDF could not be opened: {error}"))
|
||||
}
|
||||
|
||||
async fn stream_pdf(file_path: &str) -> Result<ChunkStream> {
|
||||
ensure_pdf_header(file_path).await?;
|
||||
|
||||
let path = file_path.to_owned();
|
||||
let (tx, rx) = mpsc::channel(10);
|
||||
|
||||
@ -315,39 +718,98 @@ async fn stream_pdf(file_path: &str) -> Result<ChunkStream> {
|
||||
let pdfium = match Pdfium::ai_studio_init() {
|
||||
Ok(pdfium) => pdfium,
|
||||
Err(e) => {
|
||||
let _ = tx.blocking_send(Err(e));
|
||||
let _ = tx.blocking_send(Err(ExtractionError::new(
|
||||
ExtractionErrorCode::PdfiumUnavailable,
|
||||
format!("The PDF engine could not be initialized: {e}"),
|
||||
).into()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let doc = match pdfium.load_pdf_from_file(&path, None) {
|
||||
Ok(document) => document,
|
||||
Err(e) => {
|
||||
let _ = tx.blocking_send(Err(e.into()));
|
||||
let _ = tx.blocking_send(Err(classify_pdf_load_error(&e).into()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut number_of_pages = 0;
|
||||
let mut number_of_characters = 0;
|
||||
let mut number_of_failed_pages = 0;
|
||||
let mut receiver_gone = false;
|
||||
|
||||
for (num_page, page) in doc.pages().iter().enumerate() {
|
||||
let page_number = num_page + 1;
|
||||
number_of_pages = page_number;
|
||||
|
||||
let content = match page.text().map(|t| t.all()) {
|
||||
Ok(text_content) => text_content,
|
||||
Err(e) => {
|
||||
let _ = tx.blocking_send(Err(e.into()));
|
||||
//
|
||||
// A single unreadable page must not end the document: we report it as a
|
||||
// non-fatal error chunk and continue with the next page. Sending it as an
|
||||
// `Err` would stop the consumer and silently truncate everything after it.
|
||||
//
|
||||
number_of_failed_pages += 1;
|
||||
warn!("The text of page {page_number} of '{path}' could not be extracted: {e}");
|
||||
|
||||
if tx.blocking_send(Ok(Chunk::from_error(&ExtractionError::on_page(
|
||||
ExtractionErrorCode::PageExtractionFailed,
|
||||
format!("The text of page {page_number} could not be extracted: {e}"),
|
||||
page_number,
|
||||
)))).is_err() {
|
||||
receiver_gone = true;
|
||||
break;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
number_of_characters += content.chars().count();
|
||||
|
||||
if tx.blocking_send(Ok(Chunk::new(
|
||||
content,
|
||||
Metadata::Pdf { page_number: num_page + 1 }
|
||||
Metadata::Pdf { page_number }
|
||||
))).is_err() {
|
||||
receiver_gone = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if receiver_gone {
|
||||
debug!("The consumer stopped reading the PDF stream of '{path}' after {number_of_pages} page(s).");
|
||||
return;
|
||||
}
|
||||
|
||||
debug!("Extracted {number_of_characters} character(s) from {number_of_pages} page(s) of '{path}'; failed pages: {number_of_failed_pages}.");
|
||||
|
||||
//
|
||||
// Without this marker, a PDF without a text layer and a broken extraction both arrive as
|
||||
// an empty document, and the AI would answer as if the file had no content at all.
|
||||
//
|
||||
if number_of_characters == 0 {
|
||||
warn!("No text could be extracted from '{path}': {number_of_pages} page(s), {number_of_failed_pages} failed page(s). The PDF may consist of scanned images without a text layer.");
|
||||
|
||||
let _ = tx.blocking_send(Ok(Chunk::from_error(&ExtractionError::new(
|
||||
ExtractionErrorCode::NoTextExtracted,
|
||||
format!("No text could be extracted from {number_of_pages} page(s). The PDF may consist of scanned images without a text layer."),
|
||||
))));
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Box::pin(ReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
/// Classifies a spreadsheet failure, so an unreadable file, e.g. on a network share which went
|
||||
/// away, is not reported as a corrupt workbook.
|
||||
fn classify_spreadsheet_error_code(error: &CalamineError) -> ExtractionErrorCode {
|
||||
match error {
|
||||
CalamineError::Io(io_error) => classify_io_error(io_error),
|
||||
_ => ExtractionErrorCode::NotAValidSpreadsheet,
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_spreadsheet_as_csv(file_path: &str) -> Result<ChunkStream> {
|
||||
let path = file_path.to_owned();
|
||||
let (tx, rx) = mpsc::channel(10);
|
||||
@ -356,7 +818,10 @@ async fn stream_spreadsheet_as_csv(file_path: &str) -> Result<ChunkStream> {
|
||||
let mut workbook = match open_workbook_auto(&path) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
let _ = tx.blocking_send(Err(e.into()));
|
||||
let _ = tx.blocking_send(Err(ExtractionError::new(
|
||||
classify_spreadsheet_error_code(&e),
|
||||
format!("The spreadsheet could not be opened: {e}"),
|
||||
).into()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@ -365,7 +830,20 @@ async fn stream_spreadsheet_as_csv(file_path: &str) -> Result<ChunkStream> {
|
||||
let range = match workbook.worksheet_range(&sheet_name) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let _ = tx.blocking_send(Err(e.into()));
|
||||
//
|
||||
// One unreadable sheet must not end the workbook: we report it as a non-fatal
|
||||
// error chunk and continue with the next sheet. Sending it as an `Err` would
|
||||
// stop the consumer and silently drop all remaining sheets.
|
||||
//
|
||||
warn!("The sheet '{sheet_name}' of '{path}' could not be read: {e}");
|
||||
|
||||
if tx.blocking_send(Ok(Chunk::from_error(&ExtractionError::new(
|
||||
classify_spreadsheet_error_code(&e),
|
||||
format!("The sheet '{sheet_name}' could not be read: {e}"),
|
||||
)))).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@ -422,26 +900,92 @@ async fn convert_with_pandoc(
|
||||
.build()
|
||||
.command.output().await?;
|
||||
|
||||
let stream = stream! {
|
||||
if output.status.success() {
|
||||
match String::from_utf8(output.stdout.clone()) {
|
||||
Ok(content) => yield Ok(Chunk::new(
|
||||
content,
|
||||
Metadata::Document {}
|
||||
)),
|
||||
Err(e) => yield Err(e.into()),
|
||||
let exit_code = output.status.code();
|
||||
let stderr_text = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
debug!("Pandoc converted '{file_path}' from '{from}' to '{to}': exit={exit_code:?}, {stdout_length} byte(s) of output.", stdout_length = output.stdout.len());
|
||||
|
||||
if !stderr_text.is_empty() {
|
||||
warn!("Pandoc reported while converting '{file_path}': {stderr_text}");
|
||||
}
|
||||
} else {
|
||||
yield Err(format!(
|
||||
"Pandoc error: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(ExtractionError::new(
|
||||
ExtractionErrorCode::Internal,
|
||||
format!("Pandoc failed with exit code {exit_code:?}: {stderr_text}"),
|
||||
).into());
|
||||
}
|
||||
|
||||
let content = String::from_utf8(output.stdout).map_err(|e| ExtractionError::new(
|
||||
ExtractionErrorCode::Internal,
|
||||
format!("The output of Pandoc was not valid UTF-8: {e}"),
|
||||
))?;
|
||||
|
||||
//
|
||||
// Pandoc succeeded, yet nothing came out. Passing that on as content would hand an empty
|
||||
// document to the AI, which is exactly what this whole path must not do.
|
||||
//
|
||||
if content.trim().is_empty() || !pandoc_found_readable_text(file_path, from, &content).await {
|
||||
return Err(ExtractionError::new(
|
||||
ExtractionErrorCode::NoTextExtracted,
|
||||
format!("Pandoc read the file without finding any readable text{separator}{stderr_text}", separator = if stderr_text.is_empty() { "." } else { ": " }),
|
||||
).into());
|
||||
}
|
||||
|
||||
let stream = stream! {
|
||||
yield Ok(Chunk::new(
|
||||
content,
|
||||
Metadata::Document {}
|
||||
));
|
||||
};
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
/// Decides whether a conversion produced actual text rather than just structure.
|
||||
///
|
||||
/// HTML is the one input where markup can masquerade as content: a page which builds its text with
|
||||
/// scripts converts into nothing but fenced divs and class names. That looks like content, yet it
|
||||
/// says nothing, and the AI would be asked to work with it. Pandoc's plain output settles the
|
||||
/// question, because it carries no markup at all. Documents such as `.docx` carry their text
|
||||
/// statically, so the check above is enough for them and they are spared the extra conversion.
|
||||
async fn pandoc_found_readable_text(file_path: &str, from: &str, content: &str) -> bool {
|
||||
if from != HTML {
|
||||
return true;
|
||||
}
|
||||
|
||||
let output = PandocProcessBuilder::new()
|
||||
.with_input_file(file_path)
|
||||
.with_input_format(from)
|
||||
.with_output_format(PANDOC_PLAIN)
|
||||
.build()
|
||||
.command.output().await;
|
||||
|
||||
match output {
|
||||
Ok(output) if output.status.success() => {
|
||||
let has_text = !String::from_utf8_lossy(&output.stdout).trim().is_empty();
|
||||
if !has_text {
|
||||
warn!("'{file_path}' converted into {length} character(s) of pure structure without any readable text.", length = content.trim().len());
|
||||
}
|
||||
|
||||
has_text
|
||||
},
|
||||
|
||||
//
|
||||
// We could not find out, so we do not claim the file is empty. The content we already have
|
||||
// is the better answer than an error we cannot justify.
|
||||
//
|
||||
Ok(output) => {
|
||||
warn!("Could not check '{file_path}' for readable text, Pandoc exited with {code:?}.", code = output.status.code());
|
||||
true
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
warn!("Could not check '{file_path}' for readable text: {e}");
|
||||
true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn chunk_image(file_path: &str) -> Result<ChunkStream> {
|
||||
let data = tokio::fs::read(file_path).await?;
|
||||
let base64 = general_purpose::STANDARD.encode(&data);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user