diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor b/app/MindWork AI Studio/Assistants/AssistantBase.razor
index 3a866034..95e18ffe 100644
--- a/app/MindWork AI Studio/Assistants/AssistantBase.razor
+++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor
@@ -79,7 +79,7 @@
@if (this.ShowResult && !this.ShowEntireChatThread && this.ResultingContentBlock?.Content != null)
{
-
+
}
@if(this.ShowResult && this.ShowEntireChatThread && this.ChatThread is not null)
@@ -88,7 +88,7 @@
{
@if (block is { HideFromUser: false, Content: not null })
{
-
+
}
}
}
diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs
index f655ea77..77cb7f65 100644
--- a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs
+++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs
@@ -67,6 +67,12 @@ public abstract partial class AssistantBase : AssistantLowerBase wher
_ => string.Empty,
};
+ ///
+ /// What an export of the result is named after, which the save dialog suggests as file name.
+ /// An assistant whose result is about something more specific than the assistant itself names that.
+ ///
+ protected virtual string ExportFileName => this.Title;
+
protected abstract void ResetForm();
protected abstract bool MightPreselectValues();
diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs
index a6ef3bab..e69dbc12 100644
--- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs
+++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs
@@ -36,7 +36,12 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore AssistantManagedToolIds => this.policyAllowedToolIds;
protected override string Title => T("Document Analysis Assistant");
-
+
+ ///
+ /// An analysis is named after its policy, which says far more than the name of the assistant.
+ ///
+ protected override string ExportFileName => string.IsNullOrWhiteSpace(this.analyzedPolicyName) ? this.Title : this.analyzedPolicyName;
+
protected override string Description => T("The document analysis assistant helps you to analyze and extract information from documents based on predefined policies. You can create, edit, and manage document analysis policies that define how documents should be processed and what information should be extracted. Some policies might be protected by your organization and cannot be modified or deleted.");
protected override string SystemPrompt =>
@@ -368,6 +373,15 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore loadedDocumentPaths = [];
+
+ ///
+ /// The name of the policy the result on screen was produced with.
+ ///
+ ///
+ /// Switching to another policy keeps the result, so the selected policy may no longer be the
+ /// one behind it. An export has to be named after the analysis it holds.
+ ///
+ private string analyzedPolicyName = string.Empty;
private readonly List> availableLLMProviders = new();
private static readonly AssistantSessionStateKey SELECTED_POLICY_STATE_KEY = new(nameof(selectedPolicy));
private static readonly AssistantSessionStateKey POLICY_IS_PROTECTED_STATE_KEY = new(nameof(policyIsProtected));
@@ -381,6 +395,7 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore POLICY_PRESELECTED_PROVIDER_ID_STATE_KEY = new(nameof(policyPreselectedProviderId));
private static readonly AssistantSessionStateKey POLICY_PRESELECTED_PROFILE_STATE_KEY = new(nameof(policyPreselectedProfile));
private static readonly AssistantSessionStateKey> LOADED_DOCUMENT_PATHS_STATE_KEY = new(nameof(loadedDocumentPaths));
+ private static readonly AssistantSessionStateKey ANALYZED_POLICY_NAME_STATE_KEY = new(nameof(analyzedPolicyName));
private static readonly AssistantSessionStateKey>> AVAILABLE_LLM_PROVIDERS_STATE_KEY = new(nameof(availableLLMProviders));
///
@@ -398,6 +413,7 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore this.policyPreselectedProviderId = value);
state.Restore(POLICY_PRESELECTED_PROFILE_STATE_KEY, value => this.policyPreselectedProfile = value);
state.RestoreHashSet(LOADED_DOCUMENT_PATHS_STATE_KEY, this.loadedDocumentPaths);
+ state.Restore(ANALYZED_POLICY_NAME_STATE_KEY, value => this.analyzedPolicyName = value);
state.RestoreList(AVAILABLE_LLM_PROVIDERS_STATE_KEY, this.availableLLMProviders);
}
@@ -926,7 +943,8 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore
[Parameter]
public string? ExportTitle { get; set; }
+
+ ///
+ /// What an export of this block is named after, which the save dialog suggests as file name.
+ ///
+ ///
+ /// In the chat that is the name of the chat, in an assistant whatever the assistant says its
+ /// result is about. Whoever renders this block knows which of the two it is. A table or a code
+ /// block with a heading above it is named after that heading instead. Null falls back to a
+ /// generic name.
+ ///
+ [Parameter]
+ public string? ExportFileName { get; set; }
[Inject]
private IDialogService DialogService { get; init; } = null!;
@@ -746,9 +758,9 @@ public partial class ContentBlockComponent : MSGComponentBase
// here which would fall out of sync with the one in FileExportFormatExtensions.
//
if (format.UsesPandoc())
- await PandocExport.ToDocument(this.RustService, this.PandocAvailability, this.EffectiveExportTitle, format, this.Content);
+ await PandocExport.ToDocument(this.RustService, this.PandocAvailability, this.EffectiveExportTitle, format, this.Content, this.ExportFileName);
else if (this.Content.TryGetExportMarkdown(out var markdown))
- await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, markdown);
+ await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, markdown, this.ExportFileName);
}
catch (ArgumentOutOfRangeException e)
{
@@ -764,7 +776,8 @@ public partial class ContentBlockComponent : MSGComponentBase
{
try
{
- await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, file.Format, this.Content.ToExportContent(file), file.Caption);
+ var fileName = string.IsNullOrWhiteSpace(file.Caption) ? this.ExportFileName : file.Caption;
+ await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, file.Format, this.Content.ToExportContent(file), fileName);
}
catch (ArgumentOutOfRangeException e)
{
diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor b/app/MindWork AI Studio/Components/ChatComponent.razor
index c27291be..dcdcfc31 100644
--- a/app/MindWork AI Studio/Components/ChatComponent.razor
+++ b/app/MindWork AI Studio/Components/ChatComponent.razor
@@ -21,6 +21,7 @@
Type="@block.ContentType"
Time="@block.Time"
Content="@block.Content"
+ ExportFileName="@this.ChatThread.Name"
RemoveBlockFunc="@this.RemoveBlock"
IsLastContentBlock="@isLastBlock"
IsSecondToLastBlock="@isSecondLastBlock"
diff --git a/app/MindWork AI Studio/Tools/PandocExport.cs b/app/MindWork AI Studio/Tools/PandocExport.cs
index 8f94c2cd..bdba4726 100644
--- a/app/MindWork AI Studio/Tools/PandocExport.cs
+++ b/app/MindWork AI Studio/Tools/PandocExport.cs
@@ -117,8 +117,10 @@ public static class PandocExport
/// looking at, a chat message or the result of an assistant, so the caller names it.
/// The format to write. Must be a format which uses Pandoc.
/// The content to export.
+ /// What the document is about, used to suggest a name in the save dialog.
+ /// Null falls back to a generic name.
/// True, when the document was written.
- public static async Task ToDocument(RustService rustService, PandocAvailabilityService pandocAvailability, string dialogTitle, FileExportFormat format, IContent markdownContent)
+ public static async Task ToDocument(RustService rustService, PandocAvailabilityService pandocAvailability, string dialogTitle, FileExportFormat format, IContent markdownContent, string? fileName = null)
{
if (!format.UsesPandoc() || format.ToFileTypeFilter() is not { } fileTypeFilter)
throw new ArgumentOutOfRangeException(nameof(format), format, "Pandoc cannot write this format.");
@@ -134,7 +136,7 @@ public static class PandocExport
return false;
}
- var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], format.ToSuggestedFileName());
+ var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], format.ToSuggestedFileName(fileName));
if (response.UserCancelled)
{
LOGGER.LogInformation("User cancelled the save dialog.");
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 94e54966..e7affb11 100644
--- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
+++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
@@ -49,6 +49,7 @@
- Improved the question AI Studio asks before you delete an embedding provider. It now names the data sources depending on that provider, together with what they can still do without it.
- Improved what the AI is told when it answers from your own documents (RAG): it now learns which page a passage came from, so it can name the page an answer rests on.
- Improved what happens when you ask for web content to be cleaned up and no model is available for it. AI Studio loads the page and tells you it arrived uncleaned, instead of quietly handing you the raw page with its navigation and advertising still in it.
+- Improved the file name AI Studio suggests when you export an answer. Instead of always proposing "export", it now suggests the name of your chat or of the assistant you are working in. In the Document Analysis assistant, it suggests the name of the policy.
- Changed what a tile that opens a chat directly starts with: when it uses a chat template that brings its own tools or data sources, that template decides them. The Assistant Builder says so while you build such a tile.
- Changed which model a security check of an assistant plugin may fall back on. When you have set none aside for these checks, AI Studio uses the model you were working with, but only when that model meets the trust your organization requires for a check. One ruled out for that purpose no longer gets to read a plugin's code.
- 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.
diff --git a/app/Tests/Tools/FileExportFormatTests.cs b/app/Tests/Tools/FileExportFormatTests.cs
index 3c7e0e4a..301dd2f8 100644
--- a/app/Tests/Tools/FileExportFormatTests.cs
+++ b/app/Tests/Tools/FileExportFormatTests.cs
@@ -37,6 +37,23 @@ public sealed class FileExportFormatTests
}), "A format was added to or removed from the export menu: say in FollowsPageAnchors whether its reader follows a page in a local link, then name it here.");
}
+ [TestCase("Q3: Umsatz/Planung?", FileExportFormat.HTML, "Q3 Umsatz Planung.html", Description = "A chat is named after the first words of its question, and those may hold anything.")]
+ [TestCase("Notes for the meeting.", FileExportFormat.MARKDOWN, "Notes for the meeting.md", Description = "Windows drops a trailing dot anyway.")]
+ [TestCase(null, FileExportFormat.MICROSOFT_WORD, "export.docx")]
+ [TestCase(" ", FileExportFormat.LATEX, "export.tex", Description = "A name made of nothing is no name.")]
+ public void TheSaveDialogSuggestsAUsableFileName(string? name, FileExportFormat format, string expectedFileName)
+ {
+ Assert.That(format.ToSuggestedFileName(name), Is.EqualTo(expectedFileName));
+ }
+
+ [Test]
+ public void AnOverlongFileNameIsShortened()
+ {
+ var fileName = FileExportFormat.HTML.ToSuggestedFileName(new string('a', 100));
+
+ Assert.That(fileName, Is.EqualTo($"{new string('a', 60)}.html"), "The first ten words of a question easily outgrow what a dialog shows.");
+ }
+
[Test]
public void OnlyAWebPageNeedsAPageTitle()
{