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
}
- @if (this.MessageTables.Count > 0)
+ @if (this.MessageFiles.Count > 0)
{
- @foreach (var messageTable in this.MessageTables)
+ @foreach (var messageFile in this.MessageFiles)
{
-
+
}
}
diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
index 4dc68e92..86242dfd 100644
--- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
+++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
@@ -104,6 +104,18 @@ public partial class ContentBlockComponent : MSGComponentBase
///
[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!;
@@ -125,8 +137,8 @@ public partial class ContentBlockComponent : MSGComponentBase
private int lastRenderHash;
private string cachedMarkdownRenderPlanInput = string.Empty;
private MarkdownRenderPlan cachedMarkdownRenderPlan = MarkdownRenderPlan.EMPTY;
- private string cachedMessageTablesInput = string.Empty;
- private IReadOnlyList cachedMessageTables = [];
+ private string cachedMessageFilesInput = string.Empty;
+ private IReadOnlyList cachedMessageFiles = [];
private char csvSeparator = ',';
private ElementReference mathContentContainer;
private SourcesList? sourcesList;
@@ -147,54 +159,62 @@ public partial class ContentBlockComponent : MSGComponentBase
private bool CanExport => this.Content is { InitialRemoteWait: false, IsStreaming: false } && this.Content.TryGetMarkdownText(out _);
///
- /// The tables this block holds so that the export menu can offer each of them.
+ /// The files this block holds, tables and code blocks, so that the export menu can offer each of them.
///
///
- /// Cached the same way the Markdown render plan is: reading the tables means parsing the whole
+ /// Cached the same way the Markdown render plan is: reading the files means parsing the whole
/// message, and a block re-renders for reasons which have nothing to do with its text, such as
/// switching the theme, which would parse every message of a long chat again.
///
- private IReadOnlyList MessageTables
+ private IReadOnlyList MessageFiles
{
get
{
if (!this.Content.TryGetMarkdownText(out var markdown))
return [];
- if (ReferenceEquals(this.cachedMessageTablesInput, markdown) || string.Equals(this.cachedMessageTablesInput, markdown, StringComparison.Ordinal))
- return this.cachedMessageTables;
+ if (ReferenceEquals(this.cachedMessageFilesInput, markdown) || string.Equals(this.cachedMessageFilesInput, markdown, StringComparison.Ordinal))
+ return this.cachedMessageFiles;
- this.cachedMessageTablesInput = markdown;
- this.cachedMessageTables = PlainFileExport.ExtractTables(markdown, this.csvSeparator);
- return this.cachedMessageTables;
+ this.cachedMessageFilesInput = markdown;
+ this.cachedMessageFiles = PlainFileExport.ExtractFiles(markdown, this.csvSeparator);
+ return this.cachedMessageFiles;
}
}
///
- /// Names one table in the export menu.
+ /// Names one file in the export menu.
///
///
- /// With a single table the format alone says everything. As soon as an answer holds more than
- /// one, the user has to be able to tell them apart: the heading above a table does that, unless
- /// it is missing or two tables share one, and then we count them.
+ /// Tables and code blocks are named apart, just as they are counted apart. With a single file of
+ /// its kind the format alone says everything. As soon as an answer holds more than one, the user
+ /// has to be able to tell them apart: the heading above a file does that, unless it is missing
+ /// or two files of the kind share one, and then we count them. A code block always says that it
+ /// is one, because the menu offers the entire answer as a web page or a LaTeX document right
+ /// below, and the two entries must not read alike.
///
- private string ExportLabel(MessageTable table)
+ private string ExportLabel(MessageFile file)
{
- var tables = this.MessageTables;
- if (tables.Count < 2)
- return table.Format.ToName();
-
- var captionIsTelling = !string.IsNullOrWhiteSpace(table.Caption)
- && tables.Where(entry => entry.Ordinal != table.Ordinal).All(entry => !string.Equals(entry.Caption, table.Caption, StringComparison.Ordinal));
+ var isTable = file.Format.IsTabular();
+ var filesOfItsKind = this.MessageFiles.Where(entry => entry.Format.IsTabular() == isTable).ToList();
+ var extension = file.Format.ToFileExtension();
//
// The caption is the heading the model wrote, so it already carries the language of the
// answer and needs no translation of ours. Only the fallback, where we have to count the
- // tables ourselves, is our own wording.
+ // files ourselves, is our own wording.
//
- return captionIsTelling
- ? $"{table.Caption} ({table.Format.ToFileExtension()})"
- : string.Format(this.T("Table {0} ({1})"), table.Ordinal, table.Format.ToFileExtension());
+ string name;
+ if (filesOfItsKind.Count < 2)
+ name = file.Format.ToName();
+ else if (!string.IsNullOrWhiteSpace(file.Caption) && filesOfItsKind.Count(entry => string.Equals(entry.Caption, file.Caption, StringComparison.Ordinal)) is 1)
+ name = $"{file.Caption} ({extension})";
+ else
+ return isTable
+ ? string.Format(T("Table {0} ({1})"), file.Ordinal, extension)
+ : string.Format(T("Code block {0} ({1})"), file.Ordinal, extension);
+
+ return isTable ? name : string.Format(T("Code block: {0}"), name);
}
///
@@ -221,8 +241,8 @@ public partial class ContentBlockComponent : MSGComponentBase
return;
this.csvSeparator = separator;
- this.cachedMessageTablesInput = string.Empty;
- this.cachedMessageTables = [];
+ this.cachedMessageFilesInput = string.Empty;
+ this.cachedMessageFiles = [];
await this.InvokeAsync(this.StateHasChanged);
}
@@ -738,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)
{
@@ -749,17 +769,19 @@ public partial class ContentBlockComponent : MSGComponentBase
}
///
- /// Exports one table out of the message, exactly as the menu offered it.
+ /// Exports one file out of the message, along with the sources the answer rests on wherever its
+ /// format has room for them.
///
- private async Task ExportTable(MessageTable table)
+ private async Task ExportFile(MessageFile file)
{
try
{
- await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, table.Format, table.Content, table.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)
{
- await this.ReportUnknownExportFormat(e, table.Format);
+ await this.ReportUnknownExportFormat(e, file.Format);
}
}
diff --git a/app/MindWork AI Studio/Chat/IContentExtensions.cs b/app/MindWork AI Studio/Chat/IContentExtensions.cs
index 4d8f2346..d078c9d3 100644
--- a/app/MindWork AI Studio/Chat/IContentExtensions.cs
+++ b/app/MindWork AI Studio/Chat/IContentExtensions.cs
@@ -67,26 +67,65 @@ public static class IContentExtensions
return false;
}
- var answer = text.Text.Trim();
- var sources = text.Sources.ToExportMarkdown(keepPageAnchors);
- if (sources.Length == 0)
- {
- markdown = answer;
- return true;
- }
+ markdown = AppendSources(text.Text.Trim(), text.Sources.ToExportMarkdown(keepPageAnchors));
+ return true;
+ }
- if (answer.Length == 0)
- {
- markdown = sources;
- return true;
- }
+ ///
+ /// Reads one file out of this content the way it leaves AI Studio, together with the sources
+ /// the answer rests on.
+ ///
+ ///
+ /// A code block saved on its own came out of the same answer, so it rests on the same sources
+ /// and takes them along. How depends on the format. Markdown is what the source list is written
+ /// in, so a Markdown text gets it just as the entire answer does. A web page or a LaTeX document
+ /// gets it as a comment at its end: anything visible would have to be woven into markup the
+ /// model wrote. A fragment has no body to put it in, a page may hide whatever lies outside its
+ /// layout, and one underscore in a title is enough to stop a LaTeX run. A comment breaks
+ /// neither, and whoever opens the file finds it. A table gets no sources at all, since it has
+ /// no column a link list would fit into.
+ ///
+ /// Apart from that, the file is what the model wrote, scripts of a web page included. Saving it
+ /// is what the user chose to do; the chat still never renders it.
+ ///
+ /// The content the file was found in.
+ /// The file, as PlainFileExport.ExtractFiles read it out of this content.
+ /// The content of the file to write.
+ public static string ToExportContent(this IContent content, MessageFile file)
+ {
+ if (file.Format.IsTabular())
+ return file.Content;
+
+ var sources = content.Sources.ToExportMarkdown(file.Format.FollowsPageAnchors());
+ if (file.Format is FileExportFormat.MARKDOWN)
+ return AppendSources(file.Content, sources);
+
+ if (sources.Length is 0 || !file.Format.TryToComment(sources, out var comment))
+ return file.Content;
+
+ return $"{file.Content}{Environment.NewLine}{Environment.NewLine}{comment}";
+ }
+
+ ///
+ /// Puts the source list below a Markdown text.
+ ///
+ /// The Markdown text.
+ /// The source list as SourceExtensions.ToExportMarkdown writes it, or an
+ /// empty string when there are no sources.
+ /// The text followed by its sources.
+ private static string AppendSources(string markdown, string sources)
+ {
+ if (sources.Length == 0)
+ return markdown;
+
+ if (markdown.Length == 0)
+ return sources;
//
// The blank line is not cosmetic: it ends a paragraph, a list, a table, or a block quote, so
// that the heading of the source list stands on its own instead of being pulled into the
// last block of the answer.
//
- markdown = $"{Markdown.CloseOpenCodeFence(answer)}{Environment.NewLine}{Environment.NewLine}{sources}";
- return true;
+ return $"{Markdown.CloseOpenCodeFence(markdown)}{Environment.NewLine}{Environment.NewLine}{sources}";
}
}
\ No newline at end of file
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/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 7fb62ad5..6d292049 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
@@ -3297,6 +3297,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1820166585"] = "Ja, ent
-- Number of sources
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1848978959"] = "Anzahl der Quellen"
+-- Code block {0} ({1})
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1934297017"] = "Codeblock {0} ({1})"
+
-- Show {0} tool calls
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1981771421"] = "{0} Werkzeugaufrufe anzeigen"
@@ -3345,6 +3348,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3587744975"] = "Neu gen
-- Blocked
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blockiert"
+-- Code block: {0}
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3840086915"] = "Codeblock: {0}"
+
-- Do you really want to regenerate this message?
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Möchten Sie diese Nachricht wirklich neu generieren?"
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 de64a8e9..93d915e8 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
@@ -3297,6 +3297,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1820166585"] = "Yes, re
-- Number of sources
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1848978959"] = "Number of sources"
+-- Code block {0} ({1})
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1934297017"] = "Code block {0} ({1})"
+
-- Show {0} tool calls
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1981771421"] = "Show {0} tool calls"
@@ -3345,6 +3348,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3587744975"] = "Regener
-- Blocked
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blocked"
+-- Code block: {0}
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3840086915"] = "Code block: {0}"
+
-- Do you really want to regenerate this message?
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Do you really want to regenerate this message?"
diff --git a/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs b/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs
index 2fde6b79..0e1b552e 100644
--- a/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs
+++ b/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs
@@ -103,6 +103,57 @@ public static class FileExportFormatExtensions
_ => string.Empty,
};
+ ///
+ /// Reads which format a model means when it names a language behind the opening fence of a
+ /// code block.
+ ///
+ ///
+ /// A model which answers with a finished file puts it into a code block and names its language,
+ /// as in ```html. Models do not agree on the spelling, so we accept the usual names of a format
+ /// in any case. Only formats which are plain text appear here: a code block holds text, never
+ /// a Word document.
+ ///
+ /// The language behind the opening fence, as Markdig reads it into
+ /// FencedCodeBlock.Info.
+ /// The format the language names, or NONE when it names none of ours.
+ /// True, when the language names a format AI Studio writes.
+ public static bool TryFromCodeFenceLanguage(string? language, out FileExportFormat format)
+ {
+ format = language?.Trim().ToLowerInvariant() switch
+ {
+ "html" => FileExportFormat.HTML,
+ "latex" or "tex" => FileExportFormat.LATEX,
+ "markdown" or "md" => FileExportFormat.MARKDOWN,
+ "csv" => FileExportFormat.CSV,
+ "tsv" => FileExportFormat.TSV,
+
+ _ => FileExportFormat.NONE,
+ };
+
+ return format is not FileExportFormat.NONE;
+ }
+
+ ///
+ /// Determines whether the format holds a table rather than a text.
+ ///
+ /// The format.
+ /// True for the formats a spreadsheet opens.
+ public static bool IsTabular(this FileExportFormat format) => format is FileExportFormat.CSV or FileExportFormat.TSV;
+
+ ///
+ /// Determines whether a file of the format is plain text, which AI Studio writes as it is.
+ ///
+ ///
+ /// That holds for a web page and a LaTeX document as well, even though an entire answer needs
+ /// Pandoc to become one: the answer is Markdown, whereas a page the model wrote is a finished
+ /// file already. A Word or an OpenDocument file is an archive, and only Pandoc produces one. The
+ /// list is spelled out on purpose, so a format added later counts as plain text only once
+ /// somebody says so.
+ ///
+ /// The format.
+ /// True, when a text written as it is makes a valid file of the format.
+ public static bool IsPlainText(this FileExportFormat format) => format is FileExportFormat.LATEX or FileExportFormat.MARKDOWN or FileExportFormat.HTML or FileExportFormat.CSV or FileExportFormat.TSV;
+
///
/// Returns the file name the save dialog starts with.
///
@@ -204,6 +255,42 @@ public static class FileExportFormatExtensions
_ => WITHOUT_BYTE_ORDER_MARK,
};
+ ///
+ /// Wraps a text into a comment of the format: whoever opens the file in an editor reads it,
+ /// while a browser or a LaTeX run skips it.
+ ///
+ ///
+ /// A comment in HTML, and so in Markdown, ends at the first --> it holds, and a browser takes
+ /// --!> for the same; the rest of the text would spill onto the page from there. The title of
+ /// a web page may hold either, so a space goes in before the bracket, which keeps the text
+ /// readable and ends nothing. Every other pair of dashes stays, because a web address may carry
+ /// one, as in the xn-- of a domain with an umlaut. A LaTeX comment has no end to watch for: it
+ /// runs to the end of its line, so every line starts one.
+ ///
+ /// The format.
+ /// The text to put into the comment.
+ /// The comment, or an empty string when the format has none.
+ /// True, when the format knows comments.
+ public static bool TryToComment(this FileExportFormat format, string text, out string comment)
+ {
+ var lines = text.TrimEnd().ReplaceLineEndings("\n").Split('\n');
+ switch (format)
+ {
+ case FileExportFormat.HTML or FileExportFormat.MARKDOWN:
+ var commentText = string.Join(Environment.NewLine, lines).Replace("-->", "-- >").Replace("--!>", "--! >");
+ comment = $"";
+ return true;
+
+ case FileExportFormat.LATEX:
+ comment = string.Join(Environment.NewLine, lines.Select(line => line.Length is 0 ? "%" : $"% {line}"));
+ return true;
+
+ default:
+ comment = string.Empty;
+ return false;
+ }
+ }
+
///
/// Determines whether a link into a local file may name the page it points at.
///
@@ -224,6 +311,19 @@ public static class FileExportFormatExtensions
_ => true,
};
+ ///
+ /// Determines whether Pandoc has to be told the title of a document in the format.
+ ///
+ ///
+ /// A web page shows its title in the browser tab. Without one, Pandoc names the page after its
+ /// input file, which is a temporary file of ours with a random name. Word and OpenDocument show
+ /// no such title, and handed one anyway, they keep it as a document property nobody asked for;
+ /// verified with Pandoc 3.8.3 on 2026-09-23. LaTeX ignores it.
+ ///
+ /// The format.
+ /// True, when a document of this format needs a title besides its content.
+ public static bool NeedsPageTitle(this FileExportFormat format) => format is FileExportFormat.HTML;
+
///
/// Returns the name Pandoc knows the format by.
///
diff --git a/app/MindWork AI Studio/Tools/MessageFile.cs b/app/MindWork AI Studio/Tools/MessageFile.cs
new file mode 100644
index 00000000..28151d7a
--- /dev/null
+++ b/app/MindWork AI Studio/Tools/MessageFile.cs
@@ -0,0 +1,14 @@
+namespace AIStudio.Tools;
+
+///
+/// A file found in a message, ready to be written: a table the model wrote, or a code block the
+/// model marked as a format we write.
+///
+/// Which table or which code block of the message this is, counting from one
+/// within its kind; the format tells the two kinds apart, see FileExportFormatExtensions.IsTabular.
+/// This is what tells two files of one kind apart even when they carry the same heading.
+/// What the file is about: the heading above it, or else the first column
+/// heading of a table. Empty for a code block without a heading above it.
+/// The format this content is written as.
+/// The finished file content.
+public sealed record MessageFile(int Ordinal, string Caption, FileExportFormat Format, string Content);
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/MessageTable.cs b/app/MindWork AI Studio/Tools/MessageTable.cs
deleted file mode 100644
index 7ea9a7be..00000000
--- a/app/MindWork AI Studio/Tools/MessageTable.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace AIStudio.Tools;
-
-///
-/// A table found in a message, ready to be written to a file.
-///
-/// Which table of the message this is, counting from one. The same table
-/// appears once per format we offer for it, so this is what tells two tables apart even when they
-/// carry the same heading.
-/// What the table is about, taken from its first column heading.
-/// The format this content is written as.
-/// The finished file content.
-public sealed record MessageTable(int Ordinal, string Caption, FileExportFormat Format, string Content);
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/PandocExport.cs b/app/MindWork AI Studio/Tools/PandocExport.cs
index 1b63fa42..bdba4726 100644
--- a/app/MindWork AI Studio/Tools/PandocExport.cs
+++ b/app/MindWork AI Studio/Tools/PandocExport.cs
@@ -43,14 +43,23 @@ public static class PandocExport
await File.WriteAllTextAsync(tempMarkdownFilePath, markdownText, new UTF8Encoding(false), token);
// Call Pandoc to create the document:
- var pandoc = await PandocProcessBuilder
+ var pandocBuilder = PandocProcessBuilder
.Create()
.UseStandaloneMode()
.WithInputFormat("gfm+emoji+tex_math_dollars")
.WithOutputFormat(format.ToPandocOutputFormat())
.WithOutputFile(targetFilePath)
- .WithInputFile(tempMarkdownFilePath)
- .BuildAsync(rustService);
+ .WithInputFile(tempMarkdownFilePath);
+
+ //
+ // The document is named after the file it is written to. Set as metadata, the name
+ // reaches the page as a string which Pandoc escapes; only a file named true or false
+ // is read as a switch and keeps the temporary name.
+ //
+ if (format.NeedsPageTitle())
+ pandocBuilder.AddArgument("-M").AddArgument($"pagetitle={Path.GetFileNameWithoutExtension(targetFilePath)}");
+
+ var pandoc = await pandocBuilder.BuildAsync(rustService);
using var process = Process.Start(pandoc.StartInfo);
if (process is null)
@@ -108,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.");
@@ -125,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/Tools/PlainFileExport.cs b/app/MindWork AI Studio/Tools/PlainFileExport.cs
index d3e56cad..9ff96576 100644
--- a/app/MindWork AI Studio/Tools/PlainFileExport.cs
+++ b/app/MindWork AI Studio/Tools/PlainFileExport.cs
@@ -16,18 +16,21 @@ public static class PlainFileExport
private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(PlainFileExport).Namespace, nameof(PlainFileExport));
///
- /// Reads every table a message holds, in the order they appear in it.
+ /// Reads every file a message holds, in the order they appear in it.
///
///
- /// Two kinds of tables end up in an answer. Almost always it is a Markdown table written with
- /// pipes, which is what a model produces on its own; we turn its cells into a file. Rarely a
- /// model answers with a fenced code block marked as csv or tsv, which already is the finished
- /// file: we hand that through untouched rather than taking it apart and reassembling it.
+ /// Two kinds of files end up in an answer. Almost always it is a Markdown table written with
+ /// pipes, which is what a model produces on its own; we turn its cells into a file. Besides,
+ /// a model answers with a fenced code block marked as a format we write, such as html, latex,
+ /// markdown, or csv, whenever it was asked for a web page, a document, or data. Such a block
+ /// already is the finished file: we hand it through untouched rather than taking it apart and
+ /// reassembling it. We do not judge what the block holds, either. A browser shows a fragment of
+ /// HTML just as well as an entire page, and a LaTeX fragment is still what the user asked for.
///
/// The Markdown text of the message.
/// The separator to write a Markdown table with, see CsvWriter.SeparatorFor.
- /// The tables, or an empty list when the message holds none.
- public static IReadOnlyList ExtractTables(string markdown, char separator)
+ /// The files, or an empty list when the message holds none.
+ public static IReadOnlyList ExtractFiles(string markdown, char separator)
{
if (string.IsNullOrWhiteSpace(markdown))
return [];
@@ -40,9 +43,10 @@ public static class PlainFileExport
var document = Markdig.Markdown.Parse(markdown, Markdown.SAFE_MARKDOWN_PIPELINE);
//
- // What a table is about stands above it, not in it: models introduce their tables with a
- // heading. We remember every heading with its line so that each table can take the last
- // one before it, and fall back to its own first column heading when there is none.
+ // What a file is about stands above it, not in it: models introduce their tables and code
+ // blocks with a heading. We remember every heading with its line so that each file can take
+ // the last one before it. A table falls back to its own first column heading when there is
+ // none; a code block has nothing comparable and stays without a caption.
//
var headings = document.Descendants()
.Select(heading => (heading.Line, Text: ToPlainText(heading)))
@@ -56,11 +60,18 @@ public static class PlainFileExport
var codeBlocks = document.Descendants()
.Select(block => (block.Line, Content: ToContent(block)));
+ //
+ // Tables and code blocks are counted apart. The menu falls back to that number when a
+ // heading cannot tell two files apart, and "Table 2" has to be the second table of the
+ // answer, not the second entry of the menu.
+ //
+ var numberOfTables = 0;
+ var numberOfCodeBlocks = 0;
return tables.Concat(codeBlocks)
.Where(entry => entry.Content is not null)
.OrderBy(entry => entry.Line)
- .Select((entry, index) => new MessageTable(
- index + 1,
+ .Select(entry => new MessageFile(
+ entry.Content!.Value.Format.IsTabular() ? ++numberOfTables : ++numberOfCodeBlocks,
Caption: HeadingAbove(entry.Line) is { Length: > 0 } heading ? heading : entry.Content!.Value.Fallback,
entry.Content!.Value.Format,
entry.Content.Value.Text))
@@ -90,22 +101,22 @@ public static class PlainFileExport
}
///
- /// Turns a fenced code block into a file, when the model marked it as tabular data.
+ /// Turns a fenced code block into a file, when the model marked it as a format we write.
///
+ ///
+ /// A block the model never closed is left out. That happens when an answer broke off, at the
+ /// output limit of the model for example, and the file would end wherever the answer did: half
+ /// a web page or half a table is nothing anybody wants to save.
+ ///
private static (string Fallback, FileExportFormat Format, string Text)? ToContent(FencedCodeBlock block)
{
- var format = block.Info?.Trim() switch
- {
- "csv" => FileExportFormat.CSV,
- "tsv" => FileExportFormat.TSV,
-
- _ => FileExportFormat.NONE,
- };
-
- if (format is FileExportFormat.NONE)
+ if (block.ClosingFencedCharCount is 0 || !FileExportFormatExtensions.TryFromCodeFenceLanguage(block.Info, out var format))
return null;
var content = block.Lines.ToString();
+ if (!format.IsTabular())
+ return (string.Empty, format, content);
+
var blockSeparator = format is FileExportFormat.TSV ? '\t' : ',';
var firstLine = content.AsSpan();
var lineEnd = firstLine.IndexOf('\n');
@@ -167,20 +178,26 @@ public static class PlainFileExport
}
///
- /// Writes the given text to a plain text file and lets the user save it.
+ /// Writes the given text to a plain text file as it is and lets the user save it.
///
+ ///
+ /// Nothing is converted here, which is what sets this apart from PandocExport.ToDocument. A web
+ /// page or a LaTeX document the model wrote is a finished file already and comes through here;
+ /// an entire answer in one of these formats is Markdown and goes to Pandoc instead.
+ ///
/// The Rust service, used for the save dialog.
/// The title of the save dialog. The caller knows what the user is
/// 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 does not use Pandoc.
- /// What to write. The caller decides whether that is the entire
- /// message or one table out of it.
+ /// The format to write. Must be a plain text format, see
+ /// FileExportFormatExtensions.IsPlainText.
+ /// The finished file. The caller decides whether that is the entire
+ /// message or one file out of it.
/// What the file is about, used to suggest a name in the save dialog.
/// Null falls back to a generic name.
/// True, when the file was written.
public static async Task ToFile(RustService rustService, string dialogTitle, FileExportFormat format, string fileContent, string? fileName = null)
{
- if (format.UsesPandoc() || format.ToFileTypeFilter() is not { } fileTypeFilter)
+ if (!format.IsPlainText() || format.ToFileTypeFilter() is not { } fileTypeFilter)
throw new ArgumentOutOfRangeException(nameof(format), format, "AI Studio cannot write this format itself.");
var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], format.ToSuggestedFileName(fileName));
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 82d42678..e7affb11 100644
--- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
+++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
@@ -1,6 +1,7 @@
# v26.9.1, build 256 (2026-09-xx xx:xx UTC)
- Added a way to copy an entire chat, either with the button in the chat toolbar or next to the chat in the chat list. The copy opens right away so you can continue in it, while the original conversation stays exactly as it was. Many thanks to Peer Hogeterp (`peerschuett`) and Jens Erler (`j-erler`) for this feature.
- Added a way to roll a chat back to an earlier AI response. The response you pick stays, and every message after it is removed permanently, together with the attachments of those messages.
+- Added a way to save a single code block of an answer. When an answer holds a web page, a LaTeX document, or a Markdown text, the export menu now offers that block as a file of its own.
- Added tools that AI models can use on their own, starting with Web Search and Read Web Page. When you ask something a model cannot answer from what it knows, it now searches the web, reads the pages it found, and answers with the sources it used. You decide which tools a model may use, right below the message field, and you can watch it work: AI Studio shows which tool is running and, afterward, every call it made with its result. Whether tools are offered at all depends on the model because it has to support them. Read Web Page works right away; for Web Search you pick a search service in the app settings — Tavily or Staan with a free API key, or a SearXNG instance you run yourself. Set up more than one, and they can take turns when one of them finds nothing, or be asked all at once with their results combined. Many thanks to Peer Hogeterp (`peerschuett`) and Nils Kruthoff (`nilskruthoff`) for building this feature.
- Added answers that appear word by word even while the AI uses its tools. You read along as the model writes, including the short note it puts down before it looks something up, and the answer that follows a tool call arrives the same way instead of all at once at the end.
- Added safeguards around everything these tools bring back. Anything fetched from the web is treated as untrusted: AI Studio removes instructions hidden in a page before a model reads it and tells you when it did, exactly as it already does for the documents and web pages you load yourself. A model can never point a tool at your own network. Each tool states how much you have to trust a provider before it may be used with it, so your questions do not travel further than you allow. You can adjust that requirement per tool in the app settings.
@@ -48,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.
@@ -71,6 +73,7 @@
- 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.
- Fixed exported answers losing their sources. When an answer is based on web pages a tool read or on documents of your own, the exported file now lists those sources in every format AI Studio writes.
- Fixed the copy button leaving the sources behind. Copy an answer, and its sources come along.
+- Fixed exported web pages showing a cryptic string of letters and digits as their title in the browser tab. They now carry the name of their file.
- Fixed a tile that opens a chat directly always demanding a workspace. Leave the workspace empty in the Assistant Builder, and the tile opens a disappearing chat instead.
- Fixed the same restriction for plugin authors: a direct-chat launcher can now open a chat without naming a workspace. The example assistant plugin shows both ways.
- Fixed the security check of an assistant plugin being impossible when no model is set aside for such checks, and you have no app-wide default either. The dialog now lets you pick one, and that choice applies to this one check. Before, the button to start the check was greyed out with nothing saying why, so the plugin could not be enabled at all.
diff --git a/app/Tests/Chat/IContentExtensionsTests.cs b/app/Tests/Chat/IContentExtensionsTests.cs
index 873ecf53..aac364e6 100644
--- a/app/Tests/Chat/IContentExtensionsTests.cs
+++ b/app/Tests/Chat/IContentExtensionsTests.cs
@@ -162,16 +162,85 @@ public sealed class IContentExtensionsTests
"|---|---|",
"| Q1 | 100 |"), TOOL_SOURCE);
- content.TryGetMarkdownText(out var markdown);
- var tables = PlainFileExport.ExtractTables(markdown, ',');
+ var tables = FilesOf(content);
Assert.Multiple(() =>
{
Assert.That(tables, Has.Count.EqualTo(1), "One table in the message, one table offered for it.");
- Assert.That(tables[0].Content, Does.Not.Contain("example.org"), "A data table has no column a link list would fit into.");
+ Assert.That(content.ToExportContent(tables[0]), Is.EqualTo(tables[0].Content), "A data table has no column a link list would fit into.");
});
}
+ [Test]
+ public void AMarkdownBlockCarriesTheSourcesVisibly()
+ {
+ var content = TextWith(Lines(
+ "Here are your notes:",
+ string.Empty,
+ "```markdown",
+ "# Notes",
+ string.Empty,
+ "The notes.",
+ "```"), TOOL_SOURCE);
+
+ var exported = content.ToExportContent(FilesOf(content).Single());
+
+ Assert.That(TopLevelBlocksOf(exported), Is.EqualTo(new[] { "h1", "ParagraphBlock", "h1", "h2", "ListBlock" }), "The notes without the text around them, followed by the source list just as the entire answer carries it.");
+ }
+
+ [Test]
+ public void AWebPageCarriesTheSourcesInAComment()
+ {
+ var content = TextWith(Lines(
+ "```html",
+ "",
+ "