Added saving code blocks of every programming language (#1117)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-27 15:23:42 +01:00
1 parent 1a23479f33
commit 03abd78a5f
25 files changed
+1209 -65

No files matched your search

@@ -83,7 +83,7 @@
</MudTooltip>
}
@if (this.Role is ChatRole.AI && this.CanExport)
@if (this.CanExportAnswer)
{
<MudTooltip Text="@this.EffectiveExportTitle" Placement="Placement.Bottom">
<MudMenu Icon="@Icons.Material.Filled.Save">
@@ -91,12 +91,25 @@
{
<MudMenuItem OnClick="@(() => this.ExportDocument(documentFormat))" Icon="@documentFormat.ToIcon()" Label="@documentFormat.ToName()"/>
}
@if (this.MessageFiles.Count > 0)
@if (this.MessageFiles.Count > MAX_FILES_WITHOUT_SUBMENUS)
{
<MudDivider/>
@foreach (var filesOfAKind in this.MessageFilesByKind)
{
<MudMenu StartIcon="@ExportGroupIcon(filesOfAKind)" Label="@this.ExportGroupLabel(filesOfAKind)">
@foreach (var messageFile in filesOfAKind)
{
<MudMenuItem OnClick="@(() => this.ExportFile(messageFile))" Icon="@messageFile.ToIcon()" Label="@this.ExportLabel(messageFile)"/>
}
</MudMenu>
}
}
else if (this.MessageFiles.Count > 0)
{
<MudDivider/>
@foreach (var messageFile in this.MessageFiles)
{
<MudMenuItem OnClick="@(() => this.ExportFile(messageFile))" Icon="@messageFile.Format.ToIcon()" Label="@this.ExportLabel(messageFile)"/>
<MudMenuItem OnClick="@(() => this.ExportFile(messageFile))" Icon="@messageFile.ToIcon()" Label="@this.ExportLabel(messageFile)"/>
}
}
<MudDivider/>
@@ -220,7 +233,13 @@
var segmentContent = segment.GetContent(renderPlan.Source);
if (segment.Type is MarkdownRenderSegmentType.MARKDOWN)
{
<MudMarkdown @key="@segment.RenderKey" Value="@segmentContent" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.CHAT_MARKDOWN_PIPELINE" />
<ExportableMarkdown @key="@segment.RenderKey" Value="@segmentContent" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.CHAT_MARKDOWN_PIPELINE" Files="@this.FilesWithSaveButton" ValueStart="@segment.Start">
<CodeBlockActions Context="file">
<MudTooltip Text="@this.SaveAsLabel(file)" Placement="Placement.Top">
<MudIconButton Icon="@Icons.Material.Filled.Save" Color="Color.Primary" Variant="Variant.Filled" Class="ma-2" OnClick="@(() => this.ExportFile(file))"/>
</MudTooltip>
</CodeBlockActions>
</ExportableMarkdown>
}
else
{
@@ -23,6 +23,11 @@ public partial class ContentBlockComponent : MSGComponentBase
private const string MATH_BLOCK_MARKER_BRACKET_CLOSE = """\]""";
private const string HTML_CODE_FENCE_PREFIX = "```html";
/// <summary>
/// How many files of an answer the export menu lists one by one; beyond that, it groups them by kind.
/// </summary>
private const int MAX_FILES_WITHOUT_SUBMENUS = 5;
private static readonly string[] HTML_TAG_MARKERS =
[
"<!doctype",
@@ -104,10 +109,11 @@ public partial class ContentBlockComponent : MSGComponentBase
/// the save dialog.
/// </summary>
/// <remarks>
/// Only AI blocks can be exported, so this always names something the AI produced. In the chat
/// that is its response, whereas in an assistant it is the result, and there the user sees no
/// chat at all. Whoever renders this block knows which of the two it is. Null falls back to
/// the chat wording.
/// Only AI blocks offer the export menu, so this always names something the AI produced. In the
/// chat that is its response, whereas in an assistant it is the result, and there the user sees
/// no chat at all. Whoever renders this block knows which of the two it is. Null falls back to
/// the chat wording. A code block of a message of the user is saved with the label of its
/// button as the title instead.
/// </remarks>
[Parameter]
public string? ExportTitle { get; set; }
@@ -179,6 +185,20 @@ public partial class ContentBlockComponent : MSGComponentBase
/// </remarks>
private bool CanExport => this.Content is { InitialRemoteWait: false, IsStreaming: false } && this.Content.TryGetMarkdownText(out _);
/// <summary>
/// Whether this block offers its answer for export through the export menu: entirely, and file by file.
/// </summary>
private bool CanExportAnswer => this.Role is ChatRole.AI && this.CanExport;
/// <summary>
/// Whether each code block of this block can be saved on its own, through a button at the block.
/// </summary>
/// <remarks>
/// Besides an answer, this holds for a message of the user: code the user sent weeks ago can be
/// got back that way after many changes to it, without copying it out of the chat.
/// </remarks>
private bool CanSaveCodeBlocks => this.Role is ChatRole.AI or ChatRole.USER && this.CanExport;
/// <summary>
/// Whether this block leaves AI Studio with the notice that AI generated it, when that is switched on.
/// </summary>
@@ -191,6 +211,10 @@ public partial class ContentBlockComponent : MSGComponentBase
/// 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.
///
/// The files are read from the text the render plan shows, not from the message as stored. A
/// code block the chat renders finds its file by where it stands in that text, and an answer of
/// raw HTML only becomes a code block there.
/// </remarks>
private IReadOnlyList<MessageFile> MessageFiles
{
@@ -203,27 +227,44 @@ public partial class ContentBlockComponent : MSGComponentBase
return this.cachedMessageFiles;
this.cachedMessageFilesInput = markdown;
this.cachedMessageFiles = PlainFileExport.ExtractFiles(markdown, this.csvSeparator);
this.cachedMessageFiles = PlainFileExport.ExtractFiles(this.GetMarkdownRenderPlan(markdown).Source, this.csvSeparator);
return this.cachedMessageFiles;
}
}
/// <summary>
/// The files whose code blocks get a button to save them: all files of this block, as long as its
/// code blocks can be saved.
/// </summary>
/// <remarks>
/// In a long answer, the user would otherwise have to scroll from the code block up to the export
/// menu. The button saves the very file the menu offers for the block, under the same name.
/// </remarks>
private IReadOnlyList<MessageFile> FilesWithSaveButton => this.CanSaveCodeBlocks ? this.MessageFiles : [];
/// <summary>
/// Names the button which saves a file at its code block, e.g., "Save as Python (.py)".
/// </summary>
private string SaveAsLabel(MessageFile file) => string.Format(T("Save as {0}"), file.ToName());
/// <summary>
/// Names one file in the export menu.
/// </summary>
/// <remarks>
/// 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
/// its kind its type 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.
/// or two files of the kind share one. Then the type still does, as long as no other file of
/// the kind shares it, such as one block of Python next to one of Rust; otherwise 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.
/// </remarks>
private string ExportLabel(MessageFile file)
{
var isTable = file.Format.IsTabular();
var filesOfItsKind = this.MessageFiles.Where(entry => entry.Format.IsTabular() == isTable).ToList();
var extension = file.Format.ToFileExtension();
var extensionOrName = file.ToFileExtensionOrName();
var typeName = file.ToName();
//
// The caption is the heading the model wrote, so it already carries the language of the
@@ -232,17 +273,44 @@ public partial class ContentBlockComponent : MSGComponentBase
//
string name;
if (filesOfItsKind.Count < 2)
name = file.Format.ToName();
name = typeName;
else if (!string.IsNullOrWhiteSpace(file.Caption) && filesOfItsKind.Count(entry => string.Equals(entry.Caption, file.Caption, StringComparison.Ordinal)) is 1)
name = $"{file.Caption} ({extension})";
name = $"{file.Caption} ({extensionOrName})";
else if (filesOfItsKind.Count(entry => string.Equals(entry.ToName(), typeName, StringComparison.Ordinal)) is 1)
name = typeName;
else
return isTable
? string.Format(T("Table {0} ({1})"), file.Ordinal, extension)
: string.Format(T("Code block {0} ({1})"), file.Ordinal, extension);
? string.Format(T("Table {0} ({1})"), file.Ordinal, extensionOrName)
: string.Format(T("Code block {0} ({1})"), file.Ordinal, extensionOrName);
return isTable ? name : string.Format(T("Code block: {0}"), name);
}
/// <summary>
/// The files this block holds by kind, tables first, for an answer with more files than the
/// export menu lists one by one.
/// </summary>
/// <remarks>
/// An answer with many code blocks, a tutorial for example, would otherwise push the formats
/// for the entire answer out of sight. Each submenu keeps the order of the answer, so "Code
/// block 3" is the third entry of its submenu as well.
/// </remarks>
private IEnumerable<IGrouping<bool, MessageFile>> MessageFilesByKind => this.MessageFiles.GroupBy(file => file.Format.IsTabular()).OrderByDescending(isTable => isTable.Key);
/// <summary>
/// Names the submenu of one kind of files in the export menu, with the number of its files.
/// </summary>
private string ExportGroupLabel(IGrouping<bool, MessageFile> filesOfAKind)
{
var count = filesOfAKind.Count().CompactCount();
return filesOfAKind.Key ? string.Format(T("Tables ({0})"), count) : string.Format(T("Code blocks ({0})"), count);
}
/// <summary>
/// Returns the icon of the submenu of one kind of files in the export menu.
/// </summary>
private static string ExportGroupIcon(IGrouping<bool, MessageFile> filesOfAKind) => filesOfAKind.Key ? FileExportFormat.CSV.ToIcon() : FileExportFormat.SOURCE_CODE.ToIcon();
/// <summary>
/// What the export offers, falling back to the chat wording when nobody named it.
/// </summary>
@@ -803,8 +871,13 @@ public partial class ContentBlockComponent : MSGComponentBase
{
try
{
//
// A message of the user is no AI response, so the save dialog takes the label of the
// button at the code block as its title instead:
//
var dialogTitle = this.Role is ChatRole.AI ? this.EffectiveExportTitle : this.SaveAsLabel(file);
var fileName = string.IsNullOrWhiteSpace(file.Caption) ? this.ExportFileName : file.Caption;
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, file.Format, this.Content.ToExportContent(file), fileName);
await PlainFileExport.ToFile(this.RustService, dialogTitle, file, this.Content.ToExportContent(file), fileName);
}
catch (ArgumentOutOfRangeException e)
{
@@ -76,17 +76,20 @@ public static class IContentExtensions
/// the answer rests on.
/// </summary>
/// <remarks>
/// A code block saved on its own came out of the same answer, so it rests on the same sources
/// A document 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.
/// no column a link list would fit into. Neither does code: a program, a script, or a
/// configuration is meant for a compiler or a tool, and a comment of ours would be the one part
/// of it nobody asked for. In some languages, such as JSON, it would even break the file.
///
/// 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.
/// is what the user chose to do; the chat still never renders it. Code only gets what its tools
/// expect of a file, see ToSourceCodeFile.
/// </remarks>
/// <param name="content">The content the file was found in.</param>
/// <param name="file">The file, as PlainFileExport.ExtractFiles read it out of this content.</param>
@@ -96,6 +99,9 @@ public static class IContentExtensions
if (file.Format.IsTabular())
return file.Content;
if (file.Format is FileExportFormat.SOURCE_CODE)
return ToSourceCodeFile(file);
var sources = content.Sources.ToExportMarkdown(file.Format.FollowsPageAnchors());
if (file.Format is FileExportFormat.MARKDOWN)
return AppendSources(file.Content, sources);
@@ -106,6 +112,22 @@ public static class IContentExtensions
return $"{file.Content}{Environment.NewLine}{Environment.NewLine}{comment}";
}
/// <summary>
/// Turns a code block into the file its tools expect.
/// </summary>
/// <remarks>
/// Markdig hands us the lines of a block without a line break after the last one. Compilers
/// and shells read such a file, but Git marks it, and some tools drop the last line altogether.
/// The lines end with LF as the model wrote them, except where the language needs CR LF.
/// </remarks>
/// <param name="file">The code block.</param>
/// <returns>The content of the file to write.</returns>
private static string ToSourceCodeFile(MessageFile file)
{
var code = file.Content.EndsWith('\n') ? file.Content : $"{file.Content}\n";
return file.Language.NeedsWindowsLineEndings ? code.ReplaceLineEndings("\r\n") : code;
}
/// <summary>
/// Puts the source list below a Markdown text.
/// </summary>