+
@if (!this.DocumentPaths.Any())
{
@@ -18,7 +31,7 @@
@{
var currentFolder = string.Empty;
- foreach (var fileAttachment in this.DocumentPaths)
+ foreach (var fileAttachment in this.OrderedAttachments)
{
var folderPath = Path.GetDirectoryName(fileAttachment.FilePath);
if (folderPath != currentFolder)
@@ -91,6 +104,7 @@
}
}
+
diff --git a/app/MindWork AI Studio/Dialogs/ReviewAttachmentsDialog.razor.cs b/app/MindWork AI Studio/Dialogs/ReviewAttachmentsDialog.razor.cs
index aa12f128..daccf3c6 100644
--- a/app/MindWork AI Studio/Dialogs/ReviewAttachmentsDialog.razor.cs
+++ b/app/MindWork AI Studio/Dialogs/ReviewAttachmentsDialog.razor.cs
@@ -16,18 +16,123 @@ public partial class ReviewAttachmentsDialog : MSGComponentBase
[Parameter]
public HashSet DocumentPaths { get; set; } = new();
+ ///
+ /// Attaches the files the user drops onto this dialog, and answers which of them it attached.
+ ///
+ ///
+ /// Null when this dialog only shows attachments, which is the case for a message that was
+ /// already sent: there is nothing left to attach to. Without this, the dialog behaves as it
+ /// always did and swallows every drop.
+ ///
+ [Parameter]
+ public Func, Task>>? AttachPaths { get; set; }
+
+ ///
+ /// Decides, at the moment a drop arrives, whether attaching is possible right now.
+ ///
+ ///
+ /// Asked rather than passed as a value, because the answer changes while this dialog is open:
+ /// dropping a media file here starts a transcription, and nothing else may be attached until
+ /// that one is through.
+ ///
+ [Parameter]
+ public Func? IsAttachingUnavailable { get; set; }
+
[Inject]
private IDialogService DialogService { get; set; } = null!;
private void Close() => this.MudDialog.Close(DialogResult.Ok(this.DocumentPaths));
- public static async Task> OpenDialogAsync(IDialogService dialogService, params HashSet documentPaths)
+ /// Whether this dialog takes files at all, which decides what it says and shows.
+ private bool CanAttach => this.AttachPaths is not null;
+
+ private bool IsZoneDisabled() => this.IsAttachingUnavailable?.Invoke() ?? false;
+
+ ///
+ /// Binds the drop zone only when there is something to attach to. An area which reports a
+ /// delegate claims the role of its own default target, and claiming it without being able to
+ /// use it would swallow drops with no reason the user could see.
+ ///
+ private EventCallback> DropCallback => this.AttachPaths is null
+ ? default
+ : EventCallback.Factory.Create>(this, this.PathsDropped);
+
+ ///
+ /// Marks the list of attachments while a file hovers over this dialog, so it is visible where
+ /// the file would land. The frame keeps its width in both states; only its color changes, or
+ /// the list would jump by a few pixels with every drag.
+ ///
+ /// Whether this dialog is the target of the drop being aimed right now.
+ private string AttachmentListClass(bool isDropTarget)
+ {
+ if (!this.CanAttach)
+ return "pa-2";
+
+ return isDropTarget && !this.IsZoneDisabled()
+ ? "border-dashed border-2 rounded-lg pa-2 mud-border-primary"
+ : "border-dashed border-2 rounded-lg pa-2 mud-border-lines-default";
+ }
+
+ ///
+ /// The attachments, sorted by their folder and, within it, by their file name.
+ ///
+ ///
+ /// The list below starts a new heading whenever the folder changes from one attachment to the
+ /// next, which names every folder exactly once -- but only as long as the attachments of a
+ /// folder arrive together. The set behind them keeps no order of its own to guarantee that:
+ /// removing one attachment already scrambles it, and one attached while this dialog is open
+ /// lands at its end, giving its folder a second heading further down. Sorting here is what that
+ /// list assumes anyway.
+ ///
+ private IEnumerable OrderedAttachments => this.DocumentPaths
+ .OrderBy(attachment => Path.GetDirectoryName(attachment.FilePath) ?? string.Empty, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(attachment => attachment.FileName, StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Attaches what the user dropped onto this dialog and answers which files that became.
+ ///
+ ///
+ /// Every drop takes this way, the ones aimed at the document preview above this dialog
+ /// included. That is why the list is refreshed here and nowhere else.
+ ///
+ /// The dropped paths, in the order the runtime delivered them.
+ /// The files which were attached, in the order they were dropped.
+ private async Task> AttachPathsAsync(List paths)
+ {
+ if (this.AttachPaths is null)
+ return [];
+
+ var attached = await this.AttachPaths(paths);
+ this.StateHasChanged();
+
+ //
+ // The list scrolls, so a newly attached file may well sit outside the visible part of it.
+ // Saying so is cheaper than scrolling there, and the snackbar is skipped by the hit test,
+ // so it never gets in the way of the next drop.
+ //
+ if (attached.Count > 0)
+ await this.MessageBus.SendSuccess(new(Icons.Material.Filled.AttachFile, attached.Count is 1
+ ? string.Format(T("Attached {0}."), attached[0].FileName)
+ : string.Format(T("Attached {0} files."), attached.Count)));
+
+ return attached;
+ }
+
+ private async Task PathsDropped(List paths) => await this.AttachPathsAsync(paths);
+
+ public static async Task> OpenDialogAsync(IDialogService dialogService, HashSet documentPaths, Func, Task>>? attachPaths = null, Func? isAttachingUnavailable = null)
{
var dialogParameters = new DialogParameters
{
{ x => x.DocumentPaths, documentPaths }
};
+ if (attachPaths is not null)
+ dialogParameters.Add(x => x.AttachPaths, attachPaths);
+
+ if (isAttachingUnavailable is not null)
+ dialogParameters.Add(x => x.IsAttachingUnavailable, isAttachingUnavailable);
+
var dialogReference = await dialogService.ShowAsync(TB("Your attached files"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
@@ -58,6 +163,19 @@ public partial class ReviewAttachmentsDialog : MSGComponentBase
{ x => x.Document, fileAttachment },
};
+ //
+ // Give the preview our own way of attaching, so a file dropped onto it lands in this list
+ // as well. Not when we cannot attach anything ourselves: the preview would then claim every
+ // drop and do nothing with it.
+ //
+ if (this.CanAttach)
+ {
+ dialogParameters.Add(x => x.AttachPaths, this.AttachPathsAsync);
+
+ if (this.IsAttachingUnavailable is not null)
+ dialogParameters.Add(x => x.IsAttachingUnavailable, this.IsAttachingUnavailable);
+ }
+
await this.DialogService.ShowAsync(T("Document Preview"), dialogParameters, DialogOptions.FULLSCREEN);
}
}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua
index 157fe3f2..80ec3969 100644
--- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua
+++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua
@@ -6399,6 +6399,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2199753423"] = "Bildan
-- Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2468296835"] = "Ihr Dokument ist groß, daher zeigen wir Ihnen hier nur den Anfang. Die verbleibenden {0:N0} Zeichen werden ausgeblendet. Keine Sorge: Die KI erhält trotzdem Ihr gesamtes Dokument."
+-- You can drag another file into this window. We attach it right away and show it here.
+UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2822249202"] = "Sie können eine weitere Datei in dieses Fenster ziehen. Wir hängen sie sofort an und zeigen sie Ihnen hier."
+
-- See how we load your file. Review the content before we process it further.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T3271853346"] = "So wird Ihre Datei geladen. Überprüfen Sie den Inhalt, bevor wir ihn weiterverarbeiten."
@@ -7260,12 +7263,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T900713019"] = "Abbr
-- Embeddings
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T951463987"] = "Einbettungen"
+-- Attached {0} files.
+UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1736997462"] = "{0} Dateien angehängt."
+
-- Here you can see all attached files. Files that can no longer be found (deleted, renamed, or moved) are marked with a warning icon and a strikethrough name. You can remove any attachment using the trash can icon.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1746160064"] = "Hier sehen Sie alle angehängten Dateien. Dateien, die nicht mehr gefunden werden können (gelöscht, umbenannt oder verschoben), sind mit einem Warnsymbol und einem durchgestrichenen Namen markiert. Sie können jeden Anhang über das Papierkorbsymbol entfernen."
+-- Attached {0}.
+UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1839536175"] = "Angehängt: {0}."
+
-- There aren't any file attachments right now.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T2111340711"] = "Derzeit sind keine Dateianhänge vorhanden."
+-- You can drag more files into this window to attach them right away.
+UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T2653077974"] = "Du kannst weitere Dateien in dieses Fenster ziehen, um sie sofort anzuhängen."
+
-- Document Preview
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T285154968"] = "Dokumentvorschau"
diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua
index 15918f1a..640e220c 100644
--- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua
+++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua
@@ -6399,6 +6399,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2199753423"] = "Image
-- Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2468296835"] = "Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document."
+-- You can drag another file into this window. We attach it right away and show it here.
+UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2822249202"] = "You can drag another file into this window. We attach it right away and show it here."
+
-- See how we load your file. Review the content before we process it further.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T3271853346"] = "See how we load your file. Review the content before we process it further."
@@ -7260,12 +7263,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T900713019"] = "Canc
-- Embeddings
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T951463987"] = "Embeddings"
+-- Attached {0} files.
+UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1736997462"] = "Attached {0} files."
+
-- Here you can see all attached files. Files that can no longer be found (deleted, renamed, or moved) are marked with a warning icon and a strikethrough name. You can remove any attachment using the trash can icon.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1746160064"] = "Here you can see all attached files. Files that can no longer be found (deleted, renamed, or moved) are marked with a warning icon and a strikethrough name. You can remove any attachment using the trash can icon."
+-- Attached {0}.
+UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1839536175"] = "Attached {0}."
+
-- There aren't any file attachments right now.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T2111340711"] = "There aren't any file attachments right now."
+-- You can drag more files into this window to attach them right away.
+UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T2653077974"] = "You can drag more files into this window to attach them right away."
+
-- Document Preview
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T285154968"] = "Document Preview"
diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
index b3fa66b5..81ec9511 100644
--- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
+++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
@@ -23,12 +23,16 @@
- Improved loading web content in the assistants: it now uses the same reader as the Read Web Page tool, which extracts the main content of a page more reliably and skips navigation and boilerplate. Pages from your own network, including local servers, keep working as before. When a page cannot be read, AI Studio now says why instead of leaving the field empty.
- Improved the app icon. The previous one was generated by an image model; the new one was created based on it and keeps the familiar green landscape with the chat bubble. Because it is now a vector drawing, it stays sharp everywhere it appears: in your taskbar or dock, in the window list, and on the start screen while AI Studio is loading.
- Improved how AI Studio works out what a model can do. Every model family now stands on its own, together with the page it was read from, and our build refuses rules which contradict each other or name no source. That way, mistakes are caught before they ever reach you.
+- Improved the list of your attached files: every file now appears under the folder it came from, and each folder is named only once, no matter in which order you attached your files.
- Changed how provider trust and provider confidence work together. Marking a provider as trustworthy in a configuration no longer also satisfies a required confidence level: one says who runs the provider, the other how confidential it is. Organizations raise a provider's level in their own confidence scheme instead. This applies beyond local data sources, for example, when a model reads a page from your intranet.
- Fixed the abilities AI Studio assumed for many models. We checked the families against their documentation: some models gained image input, reasoning, or tool calling, others lost an ability they never had.
- Fixed model names that a provider writes in its own way not being recognized at all, such as the colon Ollama puts before the variant. Those models were treated as plain text models and lost every other ability.
- Fixed a model resold under a plain name not getting the abilities it really has.
- Fixed image and video generation models showing up among the chat models.
- Fixed a dropped file being processed several times, e.g., after the computer woke up from sleep.
+- Fixed nothing happening when you dropped a file onto the list of your attached files. You can now add files to that list while it is open.
+- Fixed the preview of an attached file ignoring dropped files. Drop another file onto the preview, and it is attached and shown right away.
+- Fixed AI Studio shutting down without warning when two PDF files were read at the same time, e.g., when you previewed one while another was still being read in the background.
- Fixed the web address staying in the field when you reset an assistant that loads content from a web page.
- Fixed the web address being gone when you leave such an assistant and come back to it later.
- Fixed the Visual Briefing Assistant (in preview) not scrolling, which put everything below the window edge out of reach and made the assistant unusable. The briefing preview is now shown at its intended size inside its frame, and switching between the desktop, tablet, and mobile view changes its width as it should.
diff --git a/runtime/src/file_data.rs b/runtime/src/file_data.rs
index cdf0de63..0978bc65 100644
--- a/runtime/src/file_data.rs
+++ b/runtime/src/file_data.rs
@@ -3,7 +3,7 @@ use std::collections::VecDeque;
use std::convert::Infallible;
use crate::api_token::APIToken;
use crate::pandoc::PandocProcessBuilder;
-use crate::pdfium::PdfiumInit;
+use crate::pdfium::{with_pdfium_access, PdfiumInit};
use crate::prompt_injection::{Finding as PromptInjectionFinding, Sanitizer};
use async_stream::stream;
use axum::extract::Query;
@@ -1139,7 +1139,7 @@ async fn stream_pdf(file_path: &str) -> Result {
return;
}
};
- let doc = match pdfium.load_pdf_from_file(&path, None) {
+ let doc = match with_pdfium_access(|| pdfium.load_pdf_from_file(&path, None)) {
Ok(document) => document,
Err(e) => {
let _ = tx.blocking_send(Err(classify_pdf_load_error(&e).into()));
@@ -1152,11 +1152,27 @@ async fn stream_pdf(file_path: &str) -> Result {
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;
+ //
+ // One page at a time, rather than the whole document: somebody else may be reading a PDF
+ // of their own, and holding PDFium for a thousand-page manual would make them wait for all
+ // of it. Between two pages, their pages get their turn.
+ //
+ let page_count = with_pdfium_access(|| doc.pages().len());
+
+ for page_index in 0..page_count {
+ let page_number = page_index as usize + 1;
number_of_pages = page_number;
- let content = match page.text().map(|t| t.all()) {
+ //
+ // The page and its text are opened and closed inside this call. Letting them outlive
+ // it would close them without PDFium to ourselves, which is a call like any other.
+ //
+ let extracted = with_pdfium_access(|| doc
+ .pages()
+ .get(page_index)
+ .and_then(|page| page.text().map(|text| text.all())));
+
+ let content = match extracted {
Ok(text_content) => text_content,
Err(e) => {
//
@@ -1193,23 +1209,29 @@ async fn stream_pdf(file_path: &str) -> Result {
if receiver_gone {
debug!("The consumer stopped reading the PDF stream of '{path}' after {number_of_pages} page(s).");
- return;
+ } else {
+ debug!("Extracted {number_of_characters} readable 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."),
+ ))));
+ }
}
- debug!("Extracted {number_of_characters} readable 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.
+ // Closing the document calls PDFium as well, so it waits for its turn like everything else.
+ // This is why the code above says what it has to say instead of returning early: the
+ // document has to be closed on every way out of here.
//
- 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."),
- ))));
- }
+ with_pdfium_access(move || drop(doc));
});
Ok(Box::pin(ReceiverStream::new(rx)))
diff --git a/runtime/src/pdfium.rs b/runtime/src/pdfium.rs
index be4d9cf6..616d301c 100644
--- a/runtime/src/pdfium.rs
+++ b/runtime/src/pdfium.rs
@@ -7,6 +7,30 @@ use log::{error, info, warn};
pub static PDFIUM_LIB_PATH: Lazy>> = Lazy::new(|| Mutex::new(None));
static PDFIUM: OnceCell = OnceCell::new();
+/// Grants one caller at a time the right to talk to PDFium.
+static PDFIUM_ACCESS: Mutex<()> = Mutex::new(());
+
+/// Runs the given action with PDFium all to itself.
+///
+/// PDFium is not thread-safe, and nothing else guarantees that for us: the `thread_safe` feature of
+/// `pdfium-render` has only granted `Send` and `Sync` since its release 0.9.0 and no longer locks
+/// anything, although its documentation still says so. Two documents read at the same time -- a
+/// chat attachment while a data source is being indexed, say -- therefore corrupt PDFium's memory
+/// and take the whole runtime down with a segmentation fault.
+///
+/// Every call to PDFium belongs in here, and so does everything holding a page or a document open:
+/// closing them calls PDFium as well. What does not belong in here is anything that waits, our own
+/// work on the extracted text above all, because everybody else waits along with it.
+pub fn with_pdfium_access(action: impl FnOnce() -> T) -> T {
+ //
+ // A panic while reading a document poisons this lock. Refusing every PDF from then on would
+ // turn one broken document into a broken feature, so we take the lock either way: what the
+ // panic left behind is inside PDFium, not inside the unit value we guard with.
+ //
+ let _access = PDFIUM_ACCESS.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
+ action()
+}
+
pub trait PdfiumInit {
fn ai_studio_init() -> Result<&'static Pdfium, Box>;
}