From 89151edf1bc38c97613cbcd49bd5c6084b6ff63c Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Wed, 23 Sep 2026 14:21:38 +0200 Subject: [PATCH] Offer code blocks of an answer in the export menu --- .../Chat/ContentBlockComponent.razor.cs | 47 +++++++++++-------- .../Tools/FileExportFormatExtensions.cs | 14 ++++++ .../Tools/PlainFileExport.cs | 16 +++++-- app/Tests/Tools/FileExportFormatTests.cs | 18 +++++++ 4 files changed, 71 insertions(+), 24 deletions(-) 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); }); }