diff --git a/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs b/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs
index 5a22f93b..ca42fc3d 100644
--- a/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs
+++ b/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs
@@ -133,6 +133,13 @@ public static class FileExportFormatExtensions
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;
+
///
/// Returns the file name the save dialog starts with.
///
diff --git a/app/MindWork AI Studio/Tools/MessageFile.cs b/app/MindWork AI Studio/Tools/MessageFile.cs
index 15668106..28151d7a 100644
--- a/app/MindWork AI Studio/Tools/MessageFile.cs
+++ b/app/MindWork AI Studio/Tools/MessageFile.cs
@@ -1,12 +1,14 @@
namespace AIStudio.Tools;
///
-/// A file found in a message, ready to be written: a table the model wrote.
+/// 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 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.
+/// 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/PlainFileExport.cs b/app/MindWork AI Studio/Tools/PlainFileExport.cs
index 657cd576..2405b963 100644
--- a/app/MindWork AI Studio/Tools/PlainFileExport.cs
+++ b/app/MindWork AI Studio/Tools/PlainFileExport.cs
@@ -16,17 +16,20 @@ 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.
+ /// The files, or an empty list when the message holds none.
public static IReadOnlyList ExtractFiles(string markdown, char separator)
{
if (string.IsNullOrWhiteSpace(markdown))
@@ -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 MessageFile(
- 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,14 +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)
{
- if (!FileExportFormatExtensions.TryFromCodeFenceLanguage(block.Info, out var format) || format is not (FileExportFormat.CSV or FileExportFormat.TSV))
+ 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');
diff --git a/app/Tests/Tools/Fixtures/standalone_page.html b/app/Tests/Tools/Fixtures/standalone_page.html
new file mode 100644
index 00000000..72b1cc4c
--- /dev/null
+++ b/app/Tests/Tools/Fixtures/standalone_page.html
@@ -0,0 +1,201 @@
+
+
+
+
+
+Hello World
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/Tests/Tools/PlainFileExportTests.cs b/app/Tests/Tools/PlainFileExportTests.cs
new file mode 100644
index 00000000..8a41bebd
--- /dev/null
+++ b/app/Tests/Tools/PlainFileExportTests.cs
@@ -0,0 +1,134 @@
+using System.Runtime.CompilerServices;
+
+using AIStudio.Tools;
+
+namespace AIStudio.Tests.Tools;
+
+///
+/// Checks which files the export menu finds in an answer.
+///
+///
+/// Asked for a web page, a model answers with a code block marked as html, and the same goes for a
+/// LaTeX document or a Markdown text. That block already is the file the user wants. Converted along
+/// with the rest of the answer, Pandoc shows it as a listing of source code instead, which is what
+/// PR #993 reported. The fixture is the page attached to that PR, as the model wrote it.
+///
+[TestFixture]
+public sealed class PlainFileExportTests
+{
+ private static readonly string PAGE = ReadFixture("standalone_page.html");
+
+ [Test]
+ public void AnAnswerMadeOfOneWebPageOffersThatPage()
+ {
+ var files = PlainFileExport.ExtractFiles(Lines("```html", PAGE, "```"), ',');
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(files, Has.Count.EqualTo(1));
+ Assert.That(files[0].Format, Is.EqualTo(FileExportFormat.HTML));
+ Assert.That(files[0].Content, Is.EqualTo(PAGE), "The page leaves the answer exactly as the model wrote it, without the fence around it.");
+ Assert.That(files[0].Caption, Is.Empty, "Without a heading above it, a code block has nothing to be named after.");
+ });
+ }
+
+ [Test]
+ public void AWebPageAmidExplanationsIsOfferedAsWell()
+ {
+ var answer = Lines("Here is your page:", string.Empty, "```html", PAGE, "```", string.Empty, "Save it and open it in your browser.");
+
+ var files = PlainFileExport.ExtractFiles(answer, ',');
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(files, Has.Count.EqualTo(1), "Models rarely answer with the block alone, so the text around it must not hide it.");
+ Assert.That(files[0].Content, Is.EqualTo(PAGE));
+ });
+ }
+
+ [TestCase("HTML", FileExportFormat.HTML)]
+ [TestCase("tex", FileExportFormat.LATEX)]
+ [TestCase("markdown", FileExportFormat.MARKDOWN)]
+ [TestCase("html title=\"index.html\"", FileExportFormat.HTML, Description = "Whatever follows the language is an argument, not part of it.")]
+ public void ACodeBlockIsOfferedInTheFormatItsLanguageNames(string infoString, FileExportFormat expectedFormat)
+ {
+ var files = PlainFileExport.ExtractFiles(Lines($"```{infoString}", "The content.", "```"), ',');
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(files, Has.Count.EqualTo(1));
+ Assert.That(files[0].Format, Is.EqualTo(expectedFormat));
+ Assert.That(files[0].Content, Is.EqualTo("The content."));
+ });
+ }
+
+ [Test]
+ public void ATildeFenceIsOfferedAsWell()
+ {
+ var files = PlainFileExport.ExtractFiles(Lines("~~~latex", @"\section{Results}", "~~~"), ',');
+
+ Assert.That(files.Select(file => file.Format), Is.EqualTo(new[] { FileExportFormat.LATEX }));
+ }
+
+ [TestCase("```css", TestName = "A language AI Studio writes no file for")]
+ [TestCase("```", TestName = "A fence without a language")]
+ public void AnyOtherCodeBlockIsNotOffered(string openingFence)
+ {
+ var files = PlainFileExport.ExtractFiles(Lines(openingFence, "body { margin: 0; }", "```"), ',');
+
+ Assert.That(files, Is.Empty);
+ }
+
+ [TestCase("```html", "
The answer broke off here", TestName = "Half a web page")]
+ [TestCase("```csv", "Quarter,Revenue", TestName = "Half a table")]
+ public void ACodeBlockTheModelNeverClosedIsNotOffered(string openingFence, string content)
+ {
+ var files = PlainFileExport.ExtractFiles(Lines("The answer starts normally.", string.Empty, openingFence, content), ',');
+
+ Assert.That(files, Is.Empty, "The file would end wherever the answer broke off.");
+ }
+
+ [Test]
+ public void TablesAndCodeBlocksAreCountedApart()
+ {
+ var answer = Lines(
+ "# Revenue",
+ string.Empty,
+ "| Quarter | Revenue |",
+ "|---|---|",
+ "| Q1 | 100 |",
+ string.Empty,
+ "# Landing page",
+ string.Empty,
+ "```html",
+ "
First block
",
+ "```",
+ string.Empty,
+ "```latex",
+ @"\section{Second block}",
+ "```");
+
+ var files = PlainFileExport.ExtractFiles(answer, ',');
+
+ Assert.That(files.Select(file => (file.Ordinal, file.Caption, file.Format)), Is.EqualTo(new[]
+ {
+ (1, "Revenue", FileExportFormat.CSV),
+ (1, "Landing page", FileExportFormat.HTML),
+ (2, "Landing page", FileExportFormat.LATEX),
+ }), "The first code block is code block 1, even though a table stands before it.");
+ }
+
+ private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines);
+
+ ///
+ /// Reads a file from the fixtures next to this test.
+ ///
+ ///
+ /// Read from the source tree, the way the capability snapshot is, so the fixture needs no entry in
+ /// the project file. A checkout on Windows may have turned its line ends into CRLF, which the
+ /// model never wrote.
+ ///
+ private static string ReadFixture(string fileName, [CallerFilePath] string sourceFilePath = "") => File
+ .ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "Fixtures", fileName))
+ .Replace("\r\n", "\n");
+}
\ No newline at end of file