From 7a343ae8335fae2219661286f88bb8daffc9e309 Mon Sep 17 00:00:00 2001 From: Nils Kruthoff Date: Mon, 27 Jul 2026 19:31:34 +0200 Subject: [PATCH 01/14] added crate [docx-to-md](https://github.com/nilskruthoff/docx-parser) to cargo.toml --- runtime/Cargo.lock | 15 +++++++++++++++ runtime/Cargo.toml | 1 + 2 files changed, 16 insertions(+) diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index 93e03d05c..3da20454a 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -1990,6 +1990,20 @@ dependencies = [ "strsim 0.10.0", ] +[[package]] +name = "docx-to-md" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ff66168dc94c192d9372fa8fa8201900bd6f2b1edfbbdd2674a2e69c98073b6" +dependencies = [ + "base64 0.22.1", + "image", + "quick-xml 0.41.0", + "thiserror 2.0.18", + "url", + "zip 8.6.0", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -4254,6 +4268,7 @@ dependencies = [ "dbus-secret-service", "dbus-secret-service-keyring-store", "dirs", + "docx-to-md", "file-format", "flexi_logger", "futures", diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 4e3e70b46..1a1a44712 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -50,6 +50,7 @@ sys-locale = "0.3.2" whoami = "2.1.2" cfg-if = "1.0.4" pptx-to-md = "1.0.0" +docx-to-md = "0.1.0" tempfile = "3.27.0" strum_macros = "0.28.0" sysinfo = "0.39.6" From ed26fbd9dba70f8556bd112d5bae06e9bea2706b Mon Sep 17 00:00:00 2001 From: Nils Kruthoff Date: Mon, 27 Jul 2026 19:33:30 +0200 Subject: [PATCH 02/14] replaced pandoc call with parsing logic of document file types .docx and .odt --- runtime/src/file_data.rs | 147 ++++++++++++++++++++++++++++++++++----- 1 file changed, 130 insertions(+), 17 deletions(-) diff --git a/runtime/src/file_data.rs b/runtime/src/file_data.rs index ca8a1671c..820118c5f 100644 --- a/runtime/src/file_data.rs +++ b/runtime/src/file_data.rs @@ -9,6 +9,7 @@ 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 docx_to_md::{DocumentContainer, ImageHandlingMode as DocumentImageHandlingMode, Metadata as DocumentMetadata, ParserConfig as DocumentParserConfig}; use file_format::{FileFormat, Kind}; use futures::{Stream, StreamExt}; use pdfium_render::prelude::Pdfium; @@ -53,7 +54,10 @@ pub enum Metadata { row_number: usize, }, - Document {}, + Document { + page_number: Option, + image: Option, + }, Image {}, Presentation { @@ -67,12 +71,13 @@ pub struct Base64Image { pub id: String, pub content: String, pub segment: usize, - pub is_end: bool + pub is_end: bool, + pub media_type: Option, } impl Base64Image { - fn new(id: String, content: String, segment: usize, is_end: bool) -> Self { - Self { id, content, segment, is_end } + fn new(id: String, content: String, segment: usize, is_end: bool, media_type: Option) -> Self { + Self { id, content, segment, is_end, media_type } } } @@ -140,7 +145,7 @@ pub async fn extract_data( let stream = stream! { match query { Ok(query) => { - let stream_result = stream_data(&query.path, query.extract_images).await; + let stream_result = stream_data(&query.path, query.extract_images, &query.stream_id).await; let id_ref = &query.stream_id; match stream_result { @@ -175,7 +180,7 @@ pub async fn extract_data( Sse::new(stream) } -async fn stream_data(file_path: &str, extract_images: bool) -> Result { +async fn stream_data(file_path: &str, extract_images: bool, stream_id: &str) -> Result { if !Path::new(file_path).exists() { error!("File does not exist: '{file_path}'"); return Err("File does not exist.".into()); @@ -198,10 +203,7 @@ async fn stream_data(file_path: &str, extract_images: bool) -> Result { - let from = if ext == DOCX { "docx" } else { "odt" }; - convert_with_pandoc(file_path, from, TO_MARKDOWN).await? - } + DOCX | ODT => stream_document(file_path, extract_images, stream_id).await?, "csv" | "tsv" => { stream_text_file(file_path, true, Some("csv".to_string())).await? @@ -219,11 +221,11 @@ async fn stream_data(file_path: &str, extract_images: bool) -> Result stream_pdf(file_path).await?, FileFormat::MicrosoftWordDocument => { - convert_with_pandoc(file_path, "docx", TO_MARKDOWN).await? + stream_document(file_path, extract_images, stream_id).await? }, FileFormat::OfficeOpenXmlDocument => { - convert_with_pandoc(file_path, fmt.extension(), TO_MARKDOWN).await? + stream_document(file_path, extract_images, stream_id).await? }, _ => stream_text_file(file_path, false, None).await?, @@ -427,7 +429,10 @@ async fn convert_with_pandoc( match String::from_utf8(output.stdout.clone()) { Ok(content) => yield Ok(Chunk::new( content, - Metadata::Document {} + Metadata::Document { + page_number: None, + image: None, + } )), Err(e) => yield Err(e.into()), } @@ -456,6 +461,95 @@ async fn chunk_image(file_path: &str) -> Result { Ok(Box::pin(stream)) } +async fn stream_document(file_path: &str, extract_images: bool, stream_id: &str) -> Result { + let path = Path::new(file_path).to_owned(); + let stream_id = stream_id.to_owned(); + let parser_config = DocumentParserConfig::builder() + .extract_images(extract_images) + .compress_images(true) + .quality(75) + .image_handling_mode(DocumentImageHandlingMode::Manually) + .include_document_metadata(true) + .include_headers_footers(true) + .include_footnotes(true) + .include_endnotes(true) + .include_comments(true) + .include_page_number_as_comment(false) + .build(); + let (tx, rx) = mpsc::channel(32); + let worker_error_tx = tx.clone(); + + let worker = tokio::task::spawn_blocking(move || { + let document = match DocumentContainer::open(&path, parser_config) { + Ok(document) => document, + Err(e) => { + let _ = tx.blocking_send(Err(Box::new(e) as Box)); + return; + }, + }; + let mut metadata_md = document_metadata_to_markdown(document.metadata()); + let pages = match document.iter_pages() { + Ok(pages) => pages, + Err(e) => { + let _ = tx.blocking_send(Err(Box::new(e) as Box)); + return; + }, + }; + + for page_result in pages { + let page = match page_result { + Ok(page) => page, + Err(e) => { + let _ = tx.blocking_send(Err(Box::new(e) as Box)); + return; + }, + }; + let mut content = match page.to_markdown() { + Ok(content) => content, + Err(e) => { + let _ = tx.blocking_send(Err(Box::new(e) as Box)); + return; + }, + }; + if let Some(metadata) = metadata_md.take() { + content = format!("{metadata}\n\n{content}"); + } + if tx.blocking_send(Ok(Chunk::new(content, Metadata::Document { + page_number: Some(page.page_number), + image: None, + }))).is_err() { + return; + } + + for image in page.images.values() { + let base64_data = image.base64(); + let image_id = format!("{stream_id}-{}-{}", page.page_number, image.id); + let mut offset = 0; + let mut segment_index = 0; + while offset < base64_data.len() { + let end = min(offset + IMAGE_SEGMENT_SIZE_IN_CHARS, base64_data.len()); + let base64_image = Base64Image::new(image_id.clone(), base64_data[offset..end].to_string(), segment_index, end == base64_data.len(), Some(image.media_type.clone())); + if tx.blocking_send(Ok(Chunk::new(String::new(), Metadata::Document { + page_number: Some(page.page_number), + image: Some(base64_image), + }))).is_err() { + return; + } + offset = end; + segment_index += 1; + } + } + } + }); + + tokio::spawn(async move { + if let Err(e) = worker.await { + let _ = worker_error_tx.send(Err(format!("Document parser task failed: {e}").into())).await; + } + }); + Ok(Box::pin(ReceiverStream::new(rx))) +} + async fn stream_presentation(file_path: &str, extract_images: bool, format: PresentationFormat) -> Result { let path = Path::new(file_path).to_owned(); @@ -551,10 +645,11 @@ async fn stream_presentation(file_path: &str, extract_images: bool, format: Pres let is_end = end == total_length; let base64_image = Base64Image::new( - image.img_ref.id.clone(), - segment_content.to_string(), - segment_index, - is_end + image.img_ref.id.clone(), + segment_content.to_string(), + segment_index, + is_end, + None, ); let chunk = Chunk::new( @@ -612,6 +707,24 @@ fn presentation_metadata_to_markdown(metadata: &PresentationMetadata) -> Option< } } +fn document_metadata_to_markdown(metadata: &DocumentMetadata) -> Option { + let mut fields = Vec::new(); + push_presentation_metadata_field(&mut fields, "Title", metadata.title.as_deref()); + push_presentation_metadata_field(&mut fields, "Subject", metadata.subject.as_deref()); + push_presentation_metadata_field(&mut fields, "Author", metadata.author.as_deref()); + push_presentation_metadata_field(&mut fields, "Last Modified By", metadata.last_modified_by.as_deref()); + push_presentation_metadata_field(&mut fields, "Description", metadata.description.as_deref()); + if !metadata.keywords.is_empty() { + fields.push(format!("Keywords: {}", sanitize_presentation_metadata_value(&metadata.keywords.join("; ")))); + } + push_presentation_metadata_field(&mut fields, "Created", metadata.created_at.as_deref()); + push_presentation_metadata_field(&mut fields, "Modified", metadata.modified_at.as_deref()); + for (name, value) in &metadata.custom { + fields.push(format!("Custom {name}: {}", sanitize_presentation_metadata_value(value))); + } + if fields.is_empty() { None } else { Some(format!("", fields.join("\n"))) } +} + fn push_presentation_metadata_field(fields: &mut Vec, label: &str, value: Option<&str>) { if let Some(value) = value { fields.push(format!( From 35829302e935d86d4da45ef4c0f8ed99e4920016 Mon Sep 17 00:00:00 2001 From: Nils Kruthoff Date: Mon, 27 Jul 2026 19:34:29 +0200 Subject: [PATCH 03/14] added a DocumentManager.cs to buffer only the active document page and its images --- .../Tools/DocumentManager.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 app/MindWork AI Studio/Tools/DocumentManager.cs diff --git a/app/MindWork AI Studio/Tools/DocumentManager.cs b/app/MindWork AI Studio/Tools/DocumentManager.cs new file mode 100644 index 000000000..8a033b419 --- /dev/null +++ b/app/MindWork AI Studio/Tools/DocumentManager.cs @@ -0,0 +1,58 @@ +using System.Text; + +namespace AIStudio.Tools; + +/// +/// Buffers only the active document page so that its image segments can follow +/// the page Markdown without retaining the complete document in memory. +/// +public sealed class DocumentManager +{ + private int currentPageNumber; + private StringBuilder? currentPageContent; + + public string? AddPage(ContentStreamDocumentMetadata metadata, string? content, bool extractImages) + { + var pageNumber = metadata.Document?.PageNumber ?? 0; + if (pageNumber == 0) + return content; + + var image = metadata.Document?.Image; + if (image is null) + { + var completedPage = this.Flush(); + this.currentPageNumber = pageNumber; + this.currentPageContent = new StringBuilder(); + this.currentPageContent.AppendLine($"# Page {pageNumber}"); + this.currentPageContent.Append(content); + return completedPage; + } + + if (!extractImages || this.currentPageContent is null || string.IsNullOrWhiteSpace(image.Id)) + return null; + + if (ContentStreamSseHandler.ProcessImageSegment(image.Id, image)) + { + var base64 = ContentStreamSseHandler.BuildImage(image.Id); + if (!string.IsNullOrWhiteSpace(base64)) + { + var mediaType = string.IsNullOrWhiteSpace(image.MediaType) ? "image/jpeg" : image.MediaType; + this.currentPageContent.AppendLine(); + this.currentPageContent.AppendLine($"![Image](data:{mediaType};base64,{base64})"); + } + } + + return null; + } + + public string? Flush() + { + if (this.currentPageContent is null) + return null; + + var result = this.currentPageContent.ToString(); + this.currentPageContent = null; + this.currentPageNumber = 0; + return string.IsNullOrWhiteSpace(result) ? null : result; + } +} From 5b737543ce86d389c5463790f091049b4997c5cb Mon Sep 17 00:00:00 2001 From: Nils Kruthoff Date: Mon, 27 Jul 2026 19:35:57 +0200 Subject: [PATCH 04/14] included a data container for document image data and page number --- .../Tools/ContentStreamDocumentDetails.cs | 12 ++++++++++++ .../Tools/ContentStreamDocumentMetadata.cs | 8 +++++++- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 app/MindWork AI Studio/Tools/ContentStreamDocumentDetails.cs diff --git a/app/MindWork AI Studio/Tools/ContentStreamDocumentDetails.cs b/app/MindWork AI Studio/Tools/ContentStreamDocumentDetails.cs new file mode 100644 index 000000000..44adaf0cd --- /dev/null +++ b/app/MindWork AI Studio/Tools/ContentStreamDocumentDetails.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace AIStudio.Tools; + +public sealed class ContentStreamDocumentDetails +{ + [JsonPropertyName("page_number")] + public int? PageNumber { get; init; } + + [JsonPropertyName("image")] + public ContentStreamPptxImageData? Image { get; init; } +} diff --git a/app/MindWork AI Studio/Tools/ContentStreamDocumentMetadata.cs b/app/MindWork AI Studio/Tools/ContentStreamDocumentMetadata.cs index 4b21faebf..7a47d4728 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamDocumentMetadata.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamDocumentMetadata.cs @@ -1,4 +1,10 @@ +using System.Text.Json.Serialization; + namespace AIStudio.Tools; // ReSharper disable ClassNeverInstantiated.Global -public sealed class ContentStreamDocumentMetadata : ContentStreamSseMetadata; \ No newline at end of file +public sealed class ContentStreamDocumentMetadata : ContentStreamSseMetadata +{ + [JsonPropertyName("Document")] + public ContentStreamDocumentDetails? Document { get; init; } +} From 40cfb77cb3c0bf869cd723dad7d64571f4aa838f Mon Sep 17 00:00:00 2001 From: Nils Kruthoff Date: Mon, 27 Jul 2026 19:37:10 +0200 Subject: [PATCH 05/14] added a media type to image data to distingush between .png and .jpg images for documents --- app/MindWork AI Studio/Tools/ContentStreamPptxImageData.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/MindWork AI Studio/Tools/ContentStreamPptxImageData.cs b/app/MindWork AI Studio/Tools/ContentStreamPptxImageData.cs index 9cc85eab9..b036fe022 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamPptxImageData.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamPptxImageData.cs @@ -15,4 +15,7 @@ public sealed class ContentStreamPptxImageData [JsonPropertyName("is_end")] public bool IsEnd { get; init; } -} \ No newline at end of file + + [JsonPropertyName("media_type")] + public string? MediaType { get; init; } +} From d865ffa50380a6fb486285fa689f932c5f2ee3e5 Mon Sep 17 00:00:00 2001 From: Nils Kruthoff Date: Mon, 27 Jul 2026 19:37:51 +0200 Subject: [PATCH 06/14] wired up the logic to parse the server sent events into a full markdown document --- .../Tools/ContentStreamSseHandler.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/app/MindWork AI Studio/Tools/ContentStreamSseHandler.cs b/app/MindWork AI Studio/Tools/ContentStreamSseHandler.cs index 247d3ebfa..a59d961e3 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamSseHandler.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamSseHandler.cs @@ -7,6 +7,7 @@ public static class ContentStreamSseHandler { private static readonly ConcurrentDictionary> CHUNKED_IMAGES = new(); private static readonly ConcurrentDictionary SLIDE_MANAGERS = new(); + private static readonly ConcurrentDictionary DOCUMENT_MANAGERS = new(); public static string? ProcessEvent(ContentStreamSseEvent? sseEvent, bool extractImages = true) { @@ -39,7 +40,12 @@ public static class ContentStreamSseHandler spreadSheetResult.Append(sseEvent.Content); return spreadSheetResult.ToString(); - case ContentStreamDocumentMetadata: + case ContentStreamDocumentMetadata documentMetadata: + if (documentMetadata.Document?.PageNumber is not > 0) + return sseEvent.Content; + var documentManager = DOCUMENT_MANAGERS.GetOrAdd(sseEvent.StreamId!, _ => new()); + return documentManager.AddPage(documentMetadata, sseEvent.Content, extractImages); + case ContentStreamImageMetadata: return sseEvent.Content; @@ -79,6 +85,7 @@ public static bool ProcessImageSegment(string imageId, ContentStreamPptxImageDat Content = content, Segment = segment, IsEnd = isEnd, + MediaType = contentStreamPptxImageData.MediaType, }; CHUNKED_IMAGES.AddOrUpdate( @@ -123,12 +130,20 @@ public static string BuildImage(string id) if (!string.IsNullOrWhiteSpace(result)) finalContentChunk.Append(result); } + + if (DOCUMENT_MANAGERS.TryGetValue(streamId, out var documentManager)) + { + var result = documentManager.Flush(); + if (!string.IsNullOrWhiteSpace(result)) + finalContentChunk.Append(result); + } SLIDE_MANAGERS.TryRemove(streamId, out _); + DOCUMENT_MANAGERS.TryRemove(streamId, out _); var imageIdPrefix = $"{streamId}-"; foreach (var key in CHUNKED_IMAGES.Keys.Where(k => k.StartsWith(imageIdPrefix, StringComparison.InvariantCultureIgnoreCase))) CHUNKED_IMAGES.TryRemove(key, out _); return finalContentChunk.Length > 0 ? finalContentChunk.ToString() : null; } -} \ No newline at end of file +} From cbed9d54174e5ed7135a5f943bf07bc3a7411aaf Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 10 Aug 2026 19:46:16 +0200 Subject: [PATCH 07/14] Removed the Pandoc dependency for Word and OpenDocument text files --- app/MindWork AI Studio/Tools/Rust/FileTypes.cs | 6 ++++-- app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md | 3 ++- documentation/Setup.md | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/MindWork AI Studio/Tools/Rust/FileTypes.cs b/app/MindWork AI Studio/Tools/Rust/FileTypes.cs index 57f582021..aa71bda8d 100644 --- a/app/MindWork AI Studio/Tools/Rust/FileTypes.cs +++ b/app/MindWork AI Studio/Tools/Rust/FileTypes.cs @@ -94,9 +94,11 @@ public static class FileTypes /// /// 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. + /// on a Pandoc installation. Word and OpenDocument text files (.docx, .odt) used to be listed + /// here as well; the runtime reads them on its own now. The name is not localized because it + /// is never shown. /// - private static readonly FileTypeFilter PANDOC_CONVERTED = FileTypeFilter.Leaf("Pandoc conversion", "docx", "odt", "html", "htm"); + private static readonly FileTypeFilter PANDOC_CONVERTED = FileTypeFilter.Leaf("Pandoc conversion", "html", "htm"); /// /// Determines whether reading the given file needs Pandoc. diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md index 153072c4b..47f1a1bf4 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md @@ -10,6 +10,7 @@ - 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 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. - 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. +- Improved how Word documents (`.docx`) and OpenDocument text files (`.odt`) are read. AI Studio now reads them itself instead of handing them to Pandoc, so these documents no longer need a Pandoc installation. It reads them page by page, which keeps even large documents responsive, and it now picks up more of the document: the title, the author, headers and footers, footnotes, endnotes, and comments. - 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. @@ -18,7 +19,7 @@ - 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 PDFs, text files, spreadsheets, and presentations requiring Pandoc. Only HTML files need Pandoc now, so every other file can 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 the file preview looking like an empty file while AI Studio was still reading it. Larger documents and PDFs need a moment to be read, and until now that moment looked like a file without any content. The preview now says that it is still loading and shows the content as soon as it is ready. diff --git a/documentation/Setup.md b/documentation/Setup.md index 0b0630a99..039952d05 100644 --- a/documentation/Setup.md +++ b/documentation/Setup.md @@ -102,7 +102,7 @@ Confirm the installation of the required GNOME runtime from Flathub when Flatpak #### Pandoc Extension (Strongly Recommended) -Pandoc is required for essential file features, including regular file attachments in chats, importing and converting Office documents, and other document-based functionality. We therefore strongly recommend installing the Pandoc extension. AI Studio checks whether a compatible Pandoc version is already available. +Pandoc is required for some file features, namely attaching HTML files and exporting chats as a Word document. Every other file type, PDFs, Word and OpenDocument text files, spreadsheets, and presentations among them, is read by AI Studio itself and works without Pandoc. We still recommend installing the Pandoc extension so that all file features are available. AI Studio checks whether a compatible Pandoc version is already available. For Intel/AMD, download `MindWork.AI.Studio.Plugin.Pandoc_x86_64.flatpak` and run: From ea5efca69d703a81732e35866c4c4ac77c2b8fd9 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 10 Aug 2026 19:48:12 +0200 Subject: [PATCH 08/14] Removed an unused field from the document manager --- app/MindWork AI Studio/Tools/DocumentManager.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/MindWork AI Studio/Tools/DocumentManager.cs b/app/MindWork AI Studio/Tools/DocumentManager.cs index 8a033b419..875a4771f 100644 --- a/app/MindWork AI Studio/Tools/DocumentManager.cs +++ b/app/MindWork AI Studio/Tools/DocumentManager.cs @@ -8,7 +8,6 @@ namespace AIStudio.Tools; /// public sealed class DocumentManager { - private int currentPageNumber; private StringBuilder? currentPageContent; public string? AddPage(ContentStreamDocumentMetadata metadata, string? content, bool extractImages) @@ -21,7 +20,6 @@ public sealed class DocumentManager if (image is null) { var completedPage = this.Flush(); - this.currentPageNumber = pageNumber; this.currentPageContent = new StringBuilder(); this.currentPageContent.AppendLine($"# Page {pageNumber}"); this.currentPageContent.Append(content); @@ -52,7 +50,6 @@ public sealed class DocumentManager var result = this.currentPageContent.ToString(); this.currentPageContent = null; - this.currentPageNumber = 0; return string.IsNullOrWhiteSpace(result) ? null : result; } } From e739468a854e6817cb79889677075b9d8c80a081 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 10 Aug 2026 19:58:39 +0200 Subject: [PATCH 09/14] Fixed slide images reaching the AI as raw Base64 data --- .../Tools/ContentStreamSseHandler.cs | 27 ++++++++++++++++++- .../Tools/DocumentManager.cs | 7 +++-- .../Tools/SlideImageContent.cs | 10 ++++--- app/MindWork AI Studio/Tools/SlideManager.cs | 16 +++++------ 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/app/MindWork AI Studio/Tools/ContentStreamSseHandler.cs b/app/MindWork AI Studio/Tools/ContentStreamSseHandler.cs index d333b7b1b..564186be9 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamSseHandler.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamSseHandler.cs @@ -132,7 +132,32 @@ public static string BuildImage(string id) CHUNKED_IMAGES.Remove(id, out _); return base64Image; } - + + /// + /// Assembles the collected segments of an image into a Markdown image. + /// + /// + /// Handing the naked Base64 data to the AI says nothing: it is neither readable text nor an + /// image it could look at. Only the data URI makes it one, so every reader must embed its + /// images this way. + /// + /// The ID of the image to assemble. + /// The media type the runtime reported, if any. + /// The Markdown image, or null when no data was collected for that ID. + public static string? BuildImageMarkdown(string id, string? mediaType) + { + var base64Image = BuildImage(id); + if (string.IsNullOrWhiteSpace(base64Image)) + return null; + + // + // Both readers compress their images, and that compression produces JPEG. A runtime which + // does not report the media type therefore delivered JPEG as well. + // + var imageMediaType = string.IsNullOrWhiteSpace(mediaType) ? "image/jpeg" : mediaType; + return $"![Image](data:{imageMediaType};base64,{base64Image})"; + } + public static string? Clear(string streamId) { if (string.IsNullOrWhiteSpace(streamId)) diff --git a/app/MindWork AI Studio/Tools/DocumentManager.cs b/app/MindWork AI Studio/Tools/DocumentManager.cs index 875a4771f..8183987e4 100644 --- a/app/MindWork AI Studio/Tools/DocumentManager.cs +++ b/app/MindWork AI Studio/Tools/DocumentManager.cs @@ -31,12 +31,11 @@ public sealed class DocumentManager if (ContentStreamSseHandler.ProcessImageSegment(image.Id, image)) { - var base64 = ContentStreamSseHandler.BuildImage(image.Id); - if (!string.IsNullOrWhiteSpace(base64)) + var markdownImage = ContentStreamSseHandler.BuildImageMarkdown(image.Id, image.MediaType); + if (markdownImage is not null) { - var mediaType = string.IsNullOrWhiteSpace(image.MediaType) ? "image/jpeg" : image.MediaType; this.currentPageContent.AppendLine(); - this.currentPageContent.AppendLine($"![Image](data:{mediaType};base64,{base64})"); + this.currentPageContent.AppendLine(markdownImage); } } diff --git a/app/MindWork AI Studio/Tools/SlideImageContent.cs b/app/MindWork AI Studio/Tools/SlideImageContent.cs index ec261436a..10dc6addc 100644 --- a/app/MindWork AI Studio/Tools/SlideImageContent.cs +++ b/app/MindWork AI Studio/Tools/SlideImageContent.cs @@ -1,8 +1,10 @@ -using System.Text; - namespace AIStudio.Tools; -public sealed class SlideImageContent(string base64Image) : ISlideContent +/// +/// An image of a slide, ready to be appended to the slide's Markdown. +/// +/// The image as a Markdown image with an embedded data URI. +public sealed class SlideImageContent(string markdownImage) : ISlideContent { - public StringBuilder Base64Image => new(base64Image); + public string MarkdownImage => markdownImage; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/SlideManager.cs b/app/MindWork AI Studio/Tools/SlideManager.cs index 47407bd11..f6ed1ea6c 100644 --- a/app/MindWork AI Studio/Tools/SlideManager.cs +++ b/app/MindWork AI Studio/Tools/SlideManager.cs @@ -52,11 +52,11 @@ public void AddSlide(ContentStreamPresentationMetadata metadata, string? content // if (addImage) { - var img = ContentStreamSseHandler.BuildImage(image!.Id!); - var slideImage = new SlideImageContent(img); - createdSlide.Content.Add(slideImage); + var markdownImage = ContentStreamSseHandler.BuildImageMarkdown(image!.Id!, image.MediaType); + if (markdownImage is not null) + createdSlide.Content.Add(new SlideImageContent(markdownImage)); } - + this.slides[slideNumber] = createdSlide; } else @@ -75,9 +75,9 @@ public void AddSlide(ContentStreamPresentationMetadata metadata, string? content // Add any image content? if (addImage) { - var img = ContentStreamSseHandler.BuildImage(image!.Id!); - var slideImage = new SlideImageContent(img); - slide.Content.Add(slideImage); + var markdownImage = ContentStreamSseHandler.BuildImageMarkdown(image!.Id!, image.MediaType); + if (markdownImage is not null) + slide.Content.Add(new SlideImageContent(markdownImage)); } } } @@ -96,7 +96,7 @@ public void AddSlide(ContentStreamPresentationMetadata metadata, string? content foreach (var image in slide.Content.OfType()) { - content.AppendLine(image.Base64Image.ToString()); + content.AppendLine(image.MarkdownImage); content.AppendLine(); } } From 96cc4c4976567fba61f0ef77b022c99e435f4858 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 10 Aug 2026 20:02:20 +0200 Subject: [PATCH 10/14] Fixed additional slide text being written into a throwaway buffer --- app/MindWork AI Studio/Tools/SlideTextContent.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/MindWork AI Studio/Tools/SlideTextContent.cs b/app/MindWork AI Studio/Tools/SlideTextContent.cs index 0a6575596..c9fd8d909 100644 --- a/app/MindWork AI Studio/Tools/SlideTextContent.cs +++ b/app/MindWork AI Studio/Tools/SlideTextContent.cs @@ -4,5 +4,10 @@ namespace AIStudio.Tools; public sealed class SlideTextContent(string textContent) : ISlideContent { - public StringBuilder Text => new(textContent); + // + // One builder per slide, created once: an expression-bodied property would hand out a fresh + // builder on every access, so appending further text to a slide would write into a throwaway + // object and the text would never reach the slide. + // + public StringBuilder Text { get; } = new(textContent); } \ No newline at end of file From 791d623d679ec41435339de409eb07c756e5beb4 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 10 Aug 2026 20:28:36 +0200 Subject: [PATCH 11/14] Fixed a broken document being sent to the AI as a partially read file --- runtime/src/file_data.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/runtime/src/file_data.rs b/runtime/src/file_data.rs index 178a1a7f0..932cbcaab 100644 --- a/runtime/src/file_data.rs +++ b/runtime/src/file_data.rs @@ -1055,13 +1055,20 @@ async fn stream_document(file_path: &str, extract_images: bool, stream_id: &str) let mut number_of_pages = 0; let mut number_of_characters = 0; + // + // A failing page ends the whole document here, unlike a PDF page: the page iterator gives + // up for good once it hit an error, so everything behind that page is lost as well. This + // is why neither failure below reports `PageExtractionFailed`. That code means that a + // single page is missing while the rest stays usable, and the app would hand the truncated + // document to the AI on those grounds. + // for page_result in pages { let page = match page_result { Ok(page) => page, Err(e) => { error!("A page of the document '{path:?}' could not be read: {e}"); let _ = tx.blocking_send(Err(ExtractionError::new( - ExtractionErrorCode::PageExtractionFailed, + ExtractionErrorCode::Internal, format!("A page of the document could not be read: {e}"), ).into())); return; @@ -1071,10 +1078,9 @@ async fn stream_document(file_path: &str, extract_images: bool, stream_id: &str) Ok(content) => content, Err(e) => { error!("Page {page_number} of the document '{path:?}' could not be converted: {e}", page_number = page.page_number); - let _ = tx.blocking_send(Err(ExtractionError::on_page( - ExtractionErrorCode::PageExtractionFailed, + let _ = tx.blocking_send(Err(ExtractionError::new( + ExtractionErrorCode::Internal, format!("Page {page_number} of the document could not be converted: {e}", page_number = page.page_number), - page.page_number, ).into())); return; }, From 9f343c12a565c52ef3c22217e980994aa10764a7 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 10 Aug 2026 20:30:21 +0200 Subject: [PATCH 12/14] Changed document sections to no longer look like real page numbers --- app/MindWork AI Studio/Tools/DocumentManager.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/MindWork AI Studio/Tools/DocumentManager.cs b/app/MindWork AI Studio/Tools/DocumentManager.cs index 8183987e4..359ac8265 100644 --- a/app/MindWork AI Studio/Tools/DocumentManager.cs +++ b/app/MindWork AI Studio/Tools/DocumentManager.cs @@ -21,7 +21,14 @@ public sealed class DocumentManager { var completedPage = this.Flush(); this.currentPageContent = new StringBuilder(); - this.currentPageContent.AppendLine($"# Page {pageNumber}"); + + // + // Sections, not pages: a Word or OpenDocument file carries no fixed page layout, so the + // runtime derives these boundaries from page breaks and heuristics. Calling them pages, + // as the PDF reader does with its real ones, would invite the AI to cite page numbers + // which do not exist in the document. + // + this.currentPageContent.AppendLine($"# Section {pageNumber}"); this.currentPageContent.Append(content); return completedPage; } From d11de0b5be0561db49a869bdf094185d8913fe76 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 10 Aug 2026 20:32:18 +0200 Subject: [PATCH 13/14] Added a note of thanks for the contributed document reader --- app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md index 47f1a1bf4..76bcba7aa 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md @@ -10,7 +10,7 @@ - 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 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. - 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. -- Improved how Word documents (`.docx`) and OpenDocument text files (`.odt`) are read. AI Studio now reads them itself instead of handing them to Pandoc, so these documents no longer need a Pandoc installation. It reads them page by page, which keeps even large documents responsive, and it now picks up more of the document: the title, the author, headers and footers, footnotes, endnotes, and comments. +- Improved how Word documents (`.docx`) and OpenDocument text files (`.odt`) are read. AI Studio now reads them itself instead of handing them to Pandoc, so these documents no longer need a Pandoc installation. It reads them section by section, which keeps even large documents responsive, and it now picks up more of the document: the title, the author, headers and footers, footnotes, endnotes, and comments. This was contributed by Nils Kruthoff (`nilskruthoff`), who also wrote the library behind it. Thank you, Nils, for this great contribution. - 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. From 8a2414720b10c4d41562f050625a2c78a164791d Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 10 Aug 2026 20:34:02 +0200 Subject: [PATCH 14/14] Improved the log output while reading documents --- runtime/src/file_data.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/runtime/src/file_data.rs b/runtime/src/file_data.rs index 932cbcaab..4ca4446f5 100644 --- a/runtime/src/file_data.rs +++ b/runtime/src/file_data.rs @@ -1028,10 +1028,13 @@ async fn stream_document(file_path: &str, extract_images: bool, stream_id: &str) // Page iteration performs synchronous ZIP/XML work and image compression, // so the complete producer must stay outside Tokio's asynchronous workers. let worker = tokio::task::spawn_blocking(move || { + // + // Failures travel through the error channel, which logs them with the file path and the + // classified code once they arrive. Logging them here as well would only duplicate that. + // let document = match DocumentContainer::open(&path, parser_config) { Ok(document) => document, Err(e) => { - error!("The document '{path:?}' could not be opened: {e}"); let _ = tx.blocking_send(Err(ExtractionError::new( ExtractionErrorCode::FileNotReadable, format!("The document could not be read: {e}"), @@ -1043,7 +1046,6 @@ async fn stream_document(file_path: &str, extract_images: bool, stream_id: &str) let pages = match document.iter_pages() { Ok(pages) => pages, Err(e) => { - error!("The pages of the document '{path:?}' could not be read: {e}"); let _ = tx.blocking_send(Err(ExtractionError::new( ExtractionErrorCode::FileNotReadable, format!("The pages of the document could not be read: {e}"), @@ -1066,7 +1068,6 @@ async fn stream_document(file_path: &str, extract_images: bool, stream_id: &str) let page = match page_result { Ok(page) => page, Err(e) => { - error!("A page of the document '{path:?}' could not be read: {e}"); let _ = tx.blocking_send(Err(ExtractionError::new( ExtractionErrorCode::Internal, format!("A page of the document could not be read: {e}"), @@ -1077,7 +1078,6 @@ async fn stream_document(file_path: &str, extract_images: bool, stream_id: &str) let mut content = match page.to_markdown() { Ok(content) => content, Err(e) => { - error!("Page {page_number} of the document '{path:?}' could not be converted: {e}", page_number = page.page_number); let _ = tx.blocking_send(Err(ExtractionError::new( ExtractionErrorCode::Internal, format!("Page {page_number} of the document could not be converted: {e}", page_number = page.page_number), @@ -1119,14 +1119,14 @@ async fn stream_document(file_path: &str, extract_images: bool, stream_id: &str) } } - debug!("Extracted {number_of_characters} character(s) from {number_of_pages} page(s) of '{path:?}'."); + debug!("Extracted {number_of_characters} character(s) from {number_of_pages} page(s) of '{path}'.", path = path.display()); // // Without this marker, a document without any text 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)."); + warn!("No text could be extracted from '{path}': {number_of_pages} page(s).", path = path.display()); let _ = tx.blocking_send(Ok(Chunk::from_error(&ExtractionError::new( ExtractionErrorCode::NoTextExtracted,