diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
index 9d912c06..b9e4cd3e 100644
--- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
+++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
@@ -147,10 +147,10 @@ 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.
///
@@ -171,30 +171,38 @@ public partial class ContentBlockComponent : MSGComponentBase
}
///
- /// 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(MessageFile table)
+ private string ExportLabel(MessageFile file)
{
- var tables = this.MessageFiles;
- 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);
}
///
@@ -749,13 +757,14 @@ public partial class ContentBlockComponent : MSGComponentBase
}
///
- /// Exports one file 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 ExportFile(MessageFile file)
{
try
{
- await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, file.Format, file.Content, file.Caption);
+ await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, file.Format, this.Content.ToExportContent(file), file.Caption);
}
catch (ArgumentOutOfRangeException e)
{
diff --git a/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs b/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs
index 4d261963..6d540797 100644
--- a/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs
+++ b/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs
@@ -140,6 +140,20 @@ public static class FileExportFormatExtensions
/// 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.
///
diff --git a/app/MindWork AI Studio/Tools/PlainFileExport.cs b/app/MindWork AI Studio/Tools/PlainFileExport.cs
index 2405b963..9ff96576 100644
--- a/app/MindWork AI Studio/Tools/PlainFileExport.cs
+++ b/app/MindWork AI Studio/Tools/PlainFileExport.cs
@@ -178,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/Tests/Tools/FileExportFormatTests.cs b/app/Tests/Tools/FileExportFormatTests.cs
index 6a1b0d5f..daa4572d 100644
--- a/app/Tests/Tools/FileExportFormatTests.cs
+++ b/app/Tests/Tools/FileExportFormatTests.cs
@@ -54,6 +54,24 @@ public sealed class FileExportFormatTests
{
Assert.That(FileExportFormatExtensions.TryFromCodeFenceLanguage(language, out var format), Is.True);
Assert.That(format, Is.EqualTo(expectedFormat));
+ Assert.That(format.IsPlainText(), Is.True, "A code block holds text, so the export writes it as it is.");
+ });
+ }
+
+ [Test]
+ public void OnlyTheTwoOfficeFormatsAreNoPlainText()
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(FileExportFormat.MICROSOFT_WORD.IsPlainText(), Is.False, "A Word file is an archive, and writing text into one breaks it.");
+ Assert.That(FileExportFormat.OPEN_DOCUMENT_TEXT.IsPlainText(), Is.False);
+ Assert.That(FileExportFormat.NONE.IsPlainText(), Is.False, "No format means no file.");
+ Assert.That(FileExportFormat.UNKNOWN.IsPlainText(), Is.False);
+ Assert.That(FileExportFormat.HTML.IsPlainText(), Is.True, "A page the model wrote is a finished file, even though an entire answer needs Pandoc to become one.");
+ Assert.That(FileExportFormat.LATEX.IsPlainText(), Is.True);
+ Assert.That(FileExportFormat.MARKDOWN.IsPlainText(), Is.True);
+ Assert.That(FileExportFormat.CSV.IsPlainText(), Is.True);
+ Assert.That(FileExportFormat.TSV.IsPlainText(), Is.True);
});
}