mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 20:47:22 +00:00
Added saving code blocks of every programming language (#1117)
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user