mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-28 13:44:26 +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
@@ -35,6 +35,7 @@ Key structure:
|
||||
- **JSON in model answers** - Read the JSON part of a model answer with `ModelAnswerJson`, never with a scanner of your own. It skips the prose and code fences around the JSON but checks nothing, so broken JSON reaches the deserializer unchanged and its message can go back to the model.
|
||||
- **Encryption** - Initialized before Rust service is marked ready
|
||||
- **Debug environment** - Reads `startup.env` file with IPC credentials
|
||||
- **Static files and the browser cache** - When the developer starts the app from Rider, the embedded browser of Tauri keeps the static files under `wwwroot/`, such as `app.css` or a script, in its cache. The app then still shows the old file, and a change seems not to work. Whenever you changed such a file, tell the developer to clear that cache through the developer tools of the embedded browser before testing. Users of a release are not affected.
|
||||
- **Production environment** - Runtime launches .NET sidecar with environment variables
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -3598,6 +3598,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2544592344"] = "Failed
|
||||
-- Arguments
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2738624831"] = "Arguments"
|
||||
|
||||
-- Save as {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2814713106"] = "Save as {0}"
|
||||
|
||||
-- Export AI response
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2822776450"] = "Export AI response"
|
||||
|
||||
@@ -3622,6 +3625,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3424652889"] = "Unknown
|
||||
-- Regenerate
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3587744975"] = "Regenerate"
|
||||
|
||||
-- Tables ({0})
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3588342717"] = "Tables ({0})"
|
||||
|
||||
-- Blocked
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blocked"
|
||||
|
||||
@@ -3637,6 +3643,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3951371697"] = "Yes, ro
|
||||
-- Remove Message
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Remove Message"
|
||||
|
||||
-- Code blocks ({0})
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T413085919"] = "Code blocks ({0})"
|
||||
|
||||
-- No, keep it
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4188329028"] = "No, keep it"
|
||||
|
||||
@@ -13174,6 +13183,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXPORTFORMATEXTENSIONS::T3054800422"] = "M
|
||||
-- Webpage (.html)
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXPORTFORMATEXTENSIONS::T3651679344"] = "Webpage (.html)"
|
||||
|
||||
-- Source code
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXPORTFORMATEXTENSIONS::T4137983821"] = "Source code"
|
||||
|
||||
-- Table (.csv)
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXPORTFORMATEXTENSIONS::T530872684"] = "Table (.csv)"
|
||||
|
||||
@@ -15967,6 +15979,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T1064148123"] = "Fail
|
||||
-- Failed to install update automatically. Please try again manually.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T3709709946"] = "Failed to install update automatically. Please try again manually."
|
||||
|
||||
-- Text
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCECODELANGUAGES::T1041509726"] = "Text"
|
||||
|
||||
-- Could not open the file location.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEDOCUMENTLOCATIONEXTENSIONS::T1118835751"] = "Could not open the file location."
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Renders Markdown the way MudMarkdown does, and shows actions next to every code block which is
|
||||
/// one of the files of its message, such as a button to save that block.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In a long answer, the export menu at the top of the message lies far away from the code block
|
||||
/// the user is reading. MudMarkdown renders every code block through RenderCodeBlock, the highlighted
|
||||
/// code together with its copy button, and lets us override that. We wrap what it renders and put our
|
||||
/// actions beside it, so the copy button stays exactly as the library made it.
|
||||
///
|
||||
/// A block finds its file by where it starts. The chat renders an answer in parts, cut around its
|
||||
/// formulas, and Markdig reads each part on its own: a block knows where it starts within its part
|
||||
/// only. Added to where the part starts in the answer, that is the position the file was read with.
|
||||
/// A block without a file, such as a formula or a block the model never closed, gets no actions,
|
||||
/// just as the export menu does not offer it.
|
||||
/// </remarks>
|
||||
public sealed class ExportableMarkdown : MudMarkdown
|
||||
{
|
||||
/// <summary>
|
||||
/// The files of the message, as PlainFileExport.ExtractFiles read them out of its text. Without
|
||||
/// any, this renders exactly as MudMarkdown does.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public IReadOnlyList<MessageFile> Files { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Where Value starts in the text the files were read from.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public int ValueStart { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// What to show next to the code block of a file, for example a button to save it.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment<MessageFile>? CodeBlockActions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Finds the file which starts at a position of the text the files were read from.
|
||||
/// </summary>
|
||||
/// <param name="files">The files of the message.</param>
|
||||
/// <param name="position">Where a block starts in the text of the message.</param>
|
||||
/// <returns>The file, or null when no file starts there.</returns>
|
||||
internal static MessageFile? FileAt(IReadOnlyList<MessageFile> files, int position) => files.FirstOrDefault(file => file.Position == position);
|
||||
|
||||
#region Overrides of MudMarkdown
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void RenderCodeBlock(in RenderTreeBuilder builder, ref int elementIndex, in Markdig.Syntax.CodeBlock code, in string? info)
|
||||
{
|
||||
if (this.CodeBlockActions is null || FileAt(this.Files, this.ValueStart + code.Span.Start) is not { } file)
|
||||
{
|
||||
base.RenderCodeBlock(builder, ref elementIndex, code, info);
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// Our frames are numbered by their place in this method, as Blazor asks for. The frames of
|
||||
// the library keep the running count it hands through every render method instead.
|
||||
//
|
||||
builder.OpenElement(0, "div");
|
||||
builder.AddAttribute(1, "class", "code-block-with-actions");
|
||||
base.RenderCodeBlock(builder, ref elementIndex, code, info);
|
||||
|
||||
builder.OpenElement(2, "div");
|
||||
builder.AddAttribute(3, "class", "code-block-actions");
|
||||
builder.AddContent(4, this.CodeBlockActions(file));
|
||||
builder.CloseElement();
|
||||
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+15
@@ -3600,6 +3600,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2544592344"] = "Diese N
|
||||
-- Arguments
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2738624831"] = "Argumente"
|
||||
|
||||
-- Save as {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2814713106"] = "Als {0} speichern"
|
||||
|
||||
-- Export AI response
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2822776450"] = "KI-Antwort exportieren"
|
||||
|
||||
@@ -3624,6 +3627,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3424652889"] = "Unbekan
|
||||
-- Regenerate
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3587744975"] = "Neu generieren"
|
||||
|
||||
-- Tables ({0})
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3588342717"] = "Tabellen ({0})"
|
||||
|
||||
-- Blocked
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blockiert"
|
||||
|
||||
@@ -3639,6 +3645,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3951371697"] = "Ja, Cha
|
||||
-- Remove Message
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Nachricht entfernen"
|
||||
|
||||
-- Code blocks ({0})
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T413085919"] = "Codeblöcke ({0})"
|
||||
|
||||
-- No, keep it
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4188329028"] = "Nein, behalten"
|
||||
|
||||
@@ -13176,6 +13185,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXPORTFORMATEXTENSIONS::T3054800422"] = "M
|
||||
-- Webpage (.html)
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXPORTFORMATEXTENSIONS::T3651679344"] = "Webseite (.html)"
|
||||
|
||||
-- Source code
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXPORTFORMATEXTENSIONS::T4137983821"] = "Quellcode"
|
||||
|
||||
-- Table (.csv)
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXPORTFORMATEXTENSIONS::T530872684"] = "Tabelle (.csv)"
|
||||
|
||||
@@ -15969,6 +15981,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T1064148123"] = "Die
|
||||
-- Failed to install update automatically. Please try again manually.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T3709709946"] = "Fehler bei der automatischen Installation des Updates. Bitte versuchen Sie es manuell erneut."
|
||||
|
||||
-- Text
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCECODELANGUAGES::T1041509726"] = "Text"
|
||||
|
||||
-- Could not open the file location.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEDOCUMENTLOCATIONEXTENSIONS::T1118835751"] = "Der Speicherort der Datei konnte nicht geöffnet werden."
|
||||
|
||||
|
||||
+15
@@ -3600,6 +3600,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2544592344"] = "Failed
|
||||
-- Arguments
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2738624831"] = "Arguments"
|
||||
|
||||
-- Save as {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2814713106"] = "Save as {0}"
|
||||
|
||||
-- Export AI response
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2822776450"] = "Export AI response"
|
||||
|
||||
@@ -3624,6 +3627,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3424652889"] = "Unknown
|
||||
-- Regenerate
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3587744975"] = "Regenerate"
|
||||
|
||||
-- Tables ({0})
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3588342717"] = "Tables ({0})"
|
||||
|
||||
-- Blocked
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blocked"
|
||||
|
||||
@@ -3639,6 +3645,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3951371697"] = "Yes, ro
|
||||
-- Remove Message
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Remove Message"
|
||||
|
||||
-- Code blocks ({0})
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T413085919"] = "Code blocks ({0})"
|
||||
|
||||
-- No, keep it
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4188329028"] = "No, keep it"
|
||||
|
||||
@@ -13176,6 +13185,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXPORTFORMATEXTENSIONS::T3054800422"] = "M
|
||||
-- Webpage (.html)
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXPORTFORMATEXTENSIONS::T3651679344"] = "Webpage (.html)"
|
||||
|
||||
-- Source code
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXPORTFORMATEXTENSIONS::T4137983821"] = "Source code"
|
||||
|
||||
-- Table (.csv)
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXPORTFORMATEXTENSIONS::T530872684"] = "Table (.csv)"
|
||||
|
||||
@@ -15969,6 +15981,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T1064148123"] = "Fail
|
||||
-- Failed to install update automatically. Please try again manually.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T3709709946"] = "Failed to install update automatically. Please try again manually."
|
||||
|
||||
-- Text
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCECODELANGUAGES::T1041509726"] = "Text"
|
||||
|
||||
-- Could not open the file location.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEDOCUMENTLOCATIONEXTENSIONS::T1118835751"] = "Could not open the file location."
|
||||
|
||||
|
||||
@@ -62,6 +62,16 @@ public sealed class DataBatchProcessing(Expression<Func<Data, DataBatchProcessin
|
||||
/// </remarks>
|
||||
public FileExportFormat ResultFileFormat { get; set; } = ManagedConfiguration.Register(configSelection, value => value.ResultFileFormat, FileExportFormat.MARKDOWN);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the result files can be written in a format: only in one which holds an entire answer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The format list of the assistant offers nothing else, but a configuration plugin may name
|
||||
/// any format, a table or code for example, and the result files would then end up without an
|
||||
/// extension.
|
||||
/// </remarks>
|
||||
public static bool IsValidResultFileFormat(FileExportFormat format) => FileExportFormatExtensions.ANSWER_FORMATS.Contains(format);
|
||||
|
||||
public string CsvFileName { get; set; } = ManagedConfiguration.Register(configSelection, value => value.CsvFileName, string.Empty);
|
||||
|
||||
public string ResultColumnHeader { get; set; } = ManagedConfiguration.Register(configSelection, value => value.ResultColumnHeader, string.Empty);
|
||||
|
||||
@@ -23,6 +23,7 @@ public static partial class ManagedConfiguration
|
||||
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
|
||||
/// <param name="dryRun">When true, the method will not apply any changes, but only check if the configuration can be read.</param>
|
||||
/// <param name="_">An unused parameter to help with type inference for enum types. You might ignore it when calling the method.</param>
|
||||
/// <param name="validator">An optional validator for rejecting enum values the setting does not support, e.g., a format a feature cannot write.</param>
|
||||
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
|
||||
/// <typeparam name="TValue">The type of the property within the configuration class.</typeparam>
|
||||
/// <returns>True when the configuration was successfully processed, otherwise false.</returns>
|
||||
@@ -32,7 +33,8 @@ public static partial class ManagedConfiguration
|
||||
Guid configPluginId,
|
||||
LuaTable settings,
|
||||
bool dryRun,
|
||||
TValue? _ = default)
|
||||
TValue? _ = default,
|
||||
Func<TValue, bool>? validator = null)
|
||||
where TValue : Enum
|
||||
{
|
||||
//
|
||||
@@ -53,7 +55,8 @@ public static partial class ManagedConfiguration
|
||||
if(configuredEnumValue.TryRead<string>(out var configuredEnumText))
|
||||
{
|
||||
// Step 3 -- try to parse the string as the enum type:
|
||||
if (EnumNames.TryParse(typeof(TValue), configuredEnumText, out var configuredEnum))
|
||||
if (EnumNames.TryParse(typeof(TValue), configuredEnumText, out var configuredEnum)
|
||||
&& (validator?.Invoke((TValue)configuredEnum) ?? true))
|
||||
{
|
||||
configuredValue = (TValue)configuredEnum;
|
||||
successful = true;
|
||||
|
||||
@@ -15,4 +15,10 @@ public enum FileExportFormat
|
||||
HTML,
|
||||
CSV,
|
||||
TSV,
|
||||
|
||||
/// <summary>
|
||||
/// Code in any language, one code block of an answer. Its language says how the file is named,
|
||||
/// see SourceCodeLanguage.
|
||||
/// </summary>
|
||||
SOURCE_CODE,
|
||||
}
|
||||
@@ -12,7 +12,9 @@ namespace AIStudio.Tools;
|
||||
/// <remarks>
|
||||
/// This is the single place where an export format is described. Adding another one means adding
|
||||
/// an enum member and one line per method here; neither the exporters nor the export menu need
|
||||
/// to know about it.
|
||||
/// to know about it. Code is the one exception: SOURCE_CODE stands for code in every language, so
|
||||
/// its file takes the extension and the save dialog filter from its language, see
|
||||
/// SourceCodeLanguage, and the format itself names none.
|
||||
/// </remarks>
|
||||
public static class FileExportFormatExtensions
|
||||
{
|
||||
@@ -64,6 +66,7 @@ public static class FileExportFormatExtensions
|
||||
FileExportFormat.HTML => TB("Webpage (.html)"),
|
||||
FileExportFormat.CSV => TB("Table (.csv)"),
|
||||
FileExportFormat.TSV => TB("Table (.tsv)"),
|
||||
FileExportFormat.SOURCE_CODE => TB("Source code"),
|
||||
|
||||
_ => TB("Unknown format"),
|
||||
};
|
||||
@@ -81,6 +84,7 @@ public static class FileExportFormatExtensions
|
||||
FileExportFormat.MARKDOWN => Icons.Material.Filled.TextFields,
|
||||
FileExportFormat.HTML => Icons.Material.Filled.Html,
|
||||
FileExportFormat.CSV or FileExportFormat.TSV => Icons.Material.Filled.TableChart,
|
||||
FileExportFormat.SOURCE_CODE => Icons.Material.Filled.Code,
|
||||
|
||||
_ => Icons.Material.Filled.Help,
|
||||
};
|
||||
@@ -89,7 +93,8 @@ public static class FileExportFormatExtensions
|
||||
/// Returns the file extension of the format, including the leading dot.
|
||||
/// </summary>
|
||||
/// <param name="format">The format.</param>
|
||||
/// <returns>The file extension, or an empty string when the format writes no file.</returns>
|
||||
/// <returns>The file extension, or an empty string when the format writes no file or, as code
|
||||
/// does, takes the extension from its language.</returns>
|
||||
public static string ToFileExtension(this FileExportFormat format) => format switch
|
||||
{
|
||||
FileExportFormat.MICROSOFT_WORD => ".docx",
|
||||
@@ -111,7 +116,8 @@ public static class FileExportFormatExtensions
|
||||
/// A model which answers with a finished file puts it into a code block and names its language,
|
||||
/// as in ```html. Models do not agree on the spelling, so we accept the usual names of a format
|
||||
/// in any case. Only formats which are plain text appear here: a code block holds text, never
|
||||
/// a Word document.
|
||||
/// a Word document. A block in any other language, or in none, is code, see
|
||||
/// SourceCodeLanguages.FromCodeFenceLanguage.
|
||||
/// </remarks>
|
||||
/// <param name="language">The language behind the opening fence, as Markdig reads it into
|
||||
/// FencedCodeBlock.Info.</param>
|
||||
@@ -152,7 +158,7 @@ public static class FileExportFormatExtensions
|
||||
/// </remarks>
|
||||
/// <param name="format">The format.</param>
|
||||
/// <returns>True, when a text written as it is makes a valid file of the format.</returns>
|
||||
public static bool IsPlainText(this FileExportFormat format) => format is FileExportFormat.LATEX or FileExportFormat.MARKDOWN or FileExportFormat.HTML or FileExportFormat.CSV or FileExportFormat.TSV;
|
||||
public static bool IsPlainText(this FileExportFormat format) => format is FileExportFormat.LATEX or FileExportFormat.MARKDOWN or FileExportFormat.HTML or FileExportFormat.CSV or FileExportFormat.TSV or FileExportFormat.SOURCE_CODE;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the file name the save dialog starts with.
|
||||
@@ -166,10 +172,22 @@ public static class FileExportFormatExtensions
|
||||
/// <param name="name">What the file is about, for example the heading above a table. Anything
|
||||
/// a file name cannot hold is removed. Null or blank falls back to a generic name.</param>
|
||||
/// <returns>The suggested file name, including its extension.</returns>
|
||||
public static string ToSuggestedFileName(this FileExportFormat format, string? name = null)
|
||||
public static string ToSuggestedFileName(this FileExportFormat format, string? name = null) => SuggestFileName(name, format.ToFileExtension());
|
||||
|
||||
/// <summary>
|
||||
/// Returns the file name the save dialog starts with, for a file with the given extension.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same rules as for a format apply, see ToSuggestedFileName. Code blocks need this, since
|
||||
/// their extension comes from the language of the block, not from a format.
|
||||
/// </remarks>
|
||||
/// <param name="name">What the file is about. Null or blank falls back to a generic name.</param>
|
||||
/// <param name="fileExtension">The file extension, including the leading dot.</param>
|
||||
/// <returns>The suggested file name, including its extension.</returns>
|
||||
internal static string SuggestFileName(string? name, string fileExtension)
|
||||
{
|
||||
var fileName = ToFileNameFragment(name);
|
||||
return $"{(fileName.Length is 0 ? "export" : fileName)}{format.ToFileExtension()}";
|
||||
return $"{(fileName.Length is 0 ? "export" : fileName)}{fileExtension}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -223,7 +241,8 @@ public static class FileExportFormatExtensions
|
||||
/// Returns the filter which the save dialog offers for the format.
|
||||
/// </summary>
|
||||
/// <param name="format">The format.</param>
|
||||
/// <returns>The filter, or null when the format cannot be written.</returns>
|
||||
/// <returns>The filter, or null when the format cannot be written or, as code does, takes the
|
||||
/// filter from its language.</returns>
|
||||
public static FileTypeFilter? ToFileTypeFilter(this FileExportFormat format) => format switch
|
||||
{
|
||||
FileExportFormat.MICROSOFT_WORD => FileTypes.MS_WORD,
|
||||
|
||||
@@ -1,14 +1,65 @@
|
||||
using System.Text;
|
||||
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// A file found in a message, ready to be written: a table the model wrote, or one of its code blocks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The export menu and the writer ask the file how it is named and written, never its format
|
||||
/// directly: code in Python and code in Rust share the format SOURCE_CODE, and only the language
|
||||
/// of the file tells them apart.
|
||||
/// </remarks>
|
||||
/// <param name="Ordinal">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.</param>
|
||||
/// <param name="Position">Where the table or code block starts in the Markdown text the file was
|
||||
/// read from, as the index of its first character. A block the chat renders finds its file by it:
|
||||
/// the chat renders an answer in parts, around its formulas, and each part knows where it starts.</param>
|
||||
/// <param name="Caption">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.</param>
|
||||
/// <param name="Format">The format this content is written as.</param>
|
||||
/// <param name="Language">The language of the code, when the format is SOURCE_CODE; NONE for
|
||||
/// every other file.</param>
|
||||
/// <param name="Content">The finished file content.</param>
|
||||
public sealed record MessageFile(int Ordinal, string Caption, FileExportFormat Format, string Content);
|
||||
public sealed record MessageFile(int Ordinal, int Position, string Caption, FileExportFormat Format, SourceCodeLanguage Language, string Content)
|
||||
{
|
||||
private bool IsSourceCode => this.Format is FileExportFormat.SOURCE_CODE;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the name of the file's type as shown to the user, for example "Webpage (.html)" or
|
||||
/// "Python (.py)".
|
||||
/// </summary>
|
||||
public string ToName() => this.IsSourceCode ? this.Language.ToName() : this.Format.ToName();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the icon of the file's type.
|
||||
/// </summary>
|
||||
public string ToIcon() => this.Format.ToIcon();
|
||||
|
||||
/// <summary>
|
||||
/// Returns what tells a file of this type apart: its extension, for example ".html", or the
|
||||
/// fixed name a tool looks for, such as Dockerfile.
|
||||
/// </summary>
|
||||
public string ToFileExtensionOrName() => this.IsSourceCode ? this.Language.ToFileExtensionOrName() : this.Format.ToFileExtension();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the filter which the save dialog offers for the file.
|
||||
/// </summary>
|
||||
/// <returns>The filter, or null when AI Studio cannot write the file itself.</returns>
|
||||
public FileTypeFilter? ToFileTypeFilter() => this.IsSourceCode ? this.Language.ToFileTypeFilter() : this.Format.ToFileTypeFilter();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the file name the save dialog starts with.
|
||||
/// </summary>
|
||||
/// <param name="name">What the file is about. Null or blank falls back to a generic name.</param>
|
||||
/// <returns>The suggested file name.</returns>
|
||||
public string ToSuggestedFileName(string? name = null) => this.IsSourceCode ? this.Language.ToSuggestedFileName(name) : this.Format.ToSuggestedFileName(name);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the encoding the file gets written with.
|
||||
/// </summary>
|
||||
public Encoding ToFileEncoding() => this.IsSourceCode ? this.Language.ToFileEncoding() : this.Format.ToFileEncoding();
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Text;
|
||||
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Markdig.Extensions.Mathematics;
|
||||
using Markdig.Extensions.Tables;
|
||||
using Markdig.Syntax;
|
||||
using Markdig.Syntax.Inlines;
|
||||
@@ -21,13 +23,16 @@ public static class PlainFileExport
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// every fenced code block is a file. A model marks it as a format we write, such as html, latex,
|
||||
/// markdown, or csv, whenever it was asked for a web page, a document, or data; any other block
|
||||
/// is code, in whatever language the model named, or in none. Either way, the 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 or a few lines of code are still what the user
|
||||
/// asked for.
|
||||
/// </remarks>
|
||||
/// <param name="markdown">The Markdown text of the message.</param>
|
||||
/// <param name="markdown">The Markdown text of the message, as the chat renders it: the position
|
||||
/// of each file refers to this text.</param>
|
||||
/// <param name="separator">The separator to write a Markdown table with, see CsvWriter.SeparatorFor.</param>
|
||||
/// <returns>The files, or an empty list when the message holds none.</returns>
|
||||
public static IReadOnlyList<MessageFile> ExtractFiles(string markdown, char separator)
|
||||
@@ -55,10 +60,16 @@ public static class PlainFileExport
|
||||
.ToList();
|
||||
|
||||
var tables = document.Descendants<Table>()
|
||||
.Select(table => (table.Line, Content: ToContent(table, separator)));
|
||||
.Select(table => (table.Line, Position: table.Span.Start, File: Read(table, separator)));
|
||||
|
||||
//
|
||||
// Markdig reads a formula between $$ as a MathBlock, which is a fenced code block as well.
|
||||
// Nobody saves a formula on its own, and as a block without a language, every formula of an
|
||||
// answer would end up in the menu as a text file:
|
||||
//
|
||||
var codeBlocks = document.Descendants<FencedCodeBlock>()
|
||||
.Select(block => (block.Line, Content: ToContent(block)));
|
||||
.Where(block => block is not MathBlock)
|
||||
.Select(block => (block.Line, Position: block.Span.Start, File: Read(block)));
|
||||
|
||||
//
|
||||
// Tables and code blocks are counted apart. The menu falls back to that number when a
|
||||
@@ -68,22 +79,33 @@ public static class PlainFileExport
|
||||
var numberOfTables = 0;
|
||||
var numberOfCodeBlocks = 0;
|
||||
return tables.Concat(codeBlocks)
|
||||
.Where(entry => entry.Content is not null)
|
||||
.Where(entry => entry.File is not null)
|
||||
.OrderBy(entry => entry.Line)
|
||||
.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))
|
||||
entry.File!.Format.IsTabular() ? ++numberOfTables : ++numberOfCodeBlocks,
|
||||
entry.Position,
|
||||
Caption: HeadingAbove(entry.Line) is { Length: > 0 } heading ? heading : entry.File!.Fallback,
|
||||
entry.File!.Format,
|
||||
entry.File!.Language,
|
||||
entry.File!.Content))
|
||||
.ToList();
|
||||
|
||||
string HeadingAbove(int line) => headings.LastOrDefault(heading => heading.Line < line).Text ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A file read out of a table or a code block, before ExtractFiles knows where it stands in the message.
|
||||
/// </summary>
|
||||
/// <param name="Fallback">What the file is about when no heading stands above it.</param>
|
||||
/// <param name="Format">The format the file is written as.</param>
|
||||
/// <param name="Language">The language of the code, or NONE when the format is not SOURCE_CODE.</param>
|
||||
/// <param name="Content">The finished file content.</param>
|
||||
private sealed record FoundFile(string Fallback, FileExportFormat Format, SourceCodeLanguage Language, string Content);
|
||||
|
||||
/// <summary>
|
||||
/// Turns a Markdown table into a file.
|
||||
/// </summary>
|
||||
private static (string Fallback, FileExportFormat Format, string Text)? ToContent(Table table, char separator)
|
||||
private static FoundFile? Read(Table table, char separator)
|
||||
{
|
||||
var rows = table.OfType<TableRow>()
|
||||
.Select(row => row.OfType<TableCell>().Select(ToPlainText).ToArray())
|
||||
@@ -97,25 +119,29 @@ public static class PlainFileExport
|
||||
foreach (var fields in rows)
|
||||
text.AppendLine(CsvWriter.ToRow(separator, fields));
|
||||
|
||||
return (rows[0].FirstOrDefault() ?? string.Empty, FileExportFormat.CSV, text.ToString());
|
||||
return new(rows[0].FirstOrDefault() ?? string.Empty, FileExportFormat.CSV, SourceCodeLanguage.NONE, text.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a fenced code block into a file, when the model marked it as a format we write.
|
||||
/// Turns a fenced code block into a file: in the format the model marked it as, when we write
|
||||
/// that format, and as code in its language otherwise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// a web page, half a table, or half a program is nothing anybody wants to save.
|
||||
/// </remarks>
|
||||
private static (string Fallback, FileExportFormat Format, string Text)? ToContent(FencedCodeBlock block)
|
||||
private static FoundFile? Read(FencedCodeBlock block)
|
||||
{
|
||||
if (block.ClosingFencedCharCount is 0 || !FileExportFormatExtensions.TryFromCodeFenceLanguage(block.Info, out var format))
|
||||
if (block.ClosingFencedCharCount is 0)
|
||||
return null;
|
||||
|
||||
var content = block.Lines.ToString();
|
||||
if (!FileExportFormatExtensions.TryFromCodeFenceLanguage(block.Info, out var format))
|
||||
return new(string.Empty, FileExportFormat.SOURCE_CODE, SourceCodeLanguages.FromCodeFenceLanguage(block.Info), content);
|
||||
|
||||
if (!format.IsTabular())
|
||||
return (string.Empty, format, content);
|
||||
return new(string.Empty, format, SourceCodeLanguage.NONE, content);
|
||||
|
||||
var blockSeparator = format is FileExportFormat.TSV ? '\t' : ',';
|
||||
var firstLine = content.AsSpan();
|
||||
@@ -126,7 +152,7 @@ public static class PlainFileExport
|
||||
var separatorPosition = firstLine.IndexOf(blockSeparator);
|
||||
var fallback = (separatorPosition >= 0 ? firstLine[..separatorPosition] : firstLine).Trim().Trim('"').ToString();
|
||||
|
||||
return (fallback, format, content);
|
||||
return new(fallback, format, SourceCodeLanguage.NONE, content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -200,7 +226,35 @@ public static class PlainFileExport
|
||||
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));
|
||||
return await WriteFile(rustService, dialogTitle, format, fileTypeFilter, format.ToSuggestedFileName(fileName), format.ToFileEncoding(), fileContent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes one file out of a message as it is and lets the user save it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unlike an entire message, such a file knows more about itself than its format says, so the
|
||||
/// file names its type, the name the save dialog suggests, and its encoding.
|
||||
/// </remarks>
|
||||
/// <param name="rustService">The Rust service, used for the save dialog.</param>
|
||||
/// <param name="dialogTitle">The title of the save dialog.</param>
|
||||
/// <param name="file">The file, as ExtractFiles read it out of the message. Its format must be a
|
||||
/// plain text format, see FileExportFormatExtensions.IsPlainText.</param>
|
||||
/// <param name="fileContent">The finished file, see IContentExtensions.ToExportContent.</param>
|
||||
/// <param name="fileName">What the file is about, used to suggest a name in the save dialog.
|
||||
/// Null falls back to a generic name.</param>
|
||||
/// <returns>True, when the file was written.</returns>
|
||||
public static async Task<bool> ToFile(RustService rustService, string dialogTitle, MessageFile file, string fileContent, string? fileName = null)
|
||||
{
|
||||
if (!file.Format.IsPlainText() || file.ToFileTypeFilter() is not { } fileTypeFilter)
|
||||
throw new ArgumentOutOfRangeException(nameof(file), file.Format, "AI Studio cannot write this format itself.");
|
||||
|
||||
return await WriteFile(rustService, dialogTitle, file.Format, fileTypeFilter, file.ToSuggestedFileName(fileName), file.ToFileEncoding(), fileContent);
|
||||
}
|
||||
|
||||
private static async Task<bool> WriteFile(RustService rustService, string dialogTitle, FileExportFormat format, FileTypeFilter fileTypeFilter, string suggestedFileName, Encoding encoding, string fileContent)
|
||||
{
|
||||
var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], suggestedFileName);
|
||||
if (response.UserCancelled)
|
||||
{
|
||||
LOGGER.LogInformation("User cancelled the save dialog.");
|
||||
@@ -213,7 +267,7 @@ public static class PlainFileExport
|
||||
feedback.ShowRunning(response.SaveFilePath);
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(response.SaveFilePath, fileContent, format.ToFileEncoding());
|
||||
await File.WriteAllTextAsync(response.SaveFilePath, fileContent, encoding);
|
||||
await feedback.SucceededAsync(Icons.Material.Filled.CheckCircle, TB("The export succeeded."), response.SaveFilePath, true);
|
||||
|
||||
return true;
|
||||
|
||||
@@ -486,7 +486,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.PromptFilePath, this.Id, settingsTable, dryRun);
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.PreselectedPolicyId, this.Id, settingsTable, dryRun);
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.OutputMode, this.Id, settingsTable, dryRun);
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.ResultFileFormat, this.Id, settingsTable, dryRun);
|
||||
|
||||
var resultFileFormatIsValid = ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.ResultFileFormat, this.Id, settingsTable, dryRun, validator: DataBatchProcessing.IsValidResultFileFormat);
|
||||
if (!resultFileFormatIsValid && settingsTable.TryGetValue("DataBatchProcessing.ResultFileFormat", out _))
|
||||
LOG.LogWarning("The Batch Processing result file format configured by plugin {ConfigPluginId} must be one of {ResultFileFormats}.", this.Id, string.Join(", ", FileExportFormatExtensions.ANSWER_FORMATS));
|
||||
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.CsvFileName, this.Id, settingsTable, dryRun);
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.ResultColumnHeader, this.Id, settingsTable, dryRun);
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.CsvSeparator, this.Id, settingsTable, dryRun);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Text;
|
||||
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// A language a model names behind the opening fence of a code block, together with what a file
|
||||
/// in that language needs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Most languages go by their extension. A few tools look for a fixed file name instead, such as
|
||||
/// Docker for its Dockerfile, so such a language names the file and leaves the extension empty.
|
||||
/// </remarks>
|
||||
/// <param name="Name">The name of the language as shown to the user, for example Python. The names
|
||||
/// of languages are proper names and stay untranslated.</param>
|
||||
/// <param name="FileExtension">The file extension, including the leading dot. Empty when the
|
||||
/// language has a fixed file name.</param>
|
||||
/// <param name="FileName">The fixed file name a tool looks for, for example Dockerfile. Empty when
|
||||
/// the language goes by its extension.</param>
|
||||
/// <param name="NeedsByteOrderMark">Whether the file has to start with a byte order mark. Windows
|
||||
/// PowerShell 5.1, which still ships with Windows, reads a script without one in the local ANSI code
|
||||
/// page and garbles every umlaut.</param>
|
||||
/// <param name="NeedsWindowsLineEndings">Whether the lines have to end with CR LF. The Windows
|
||||
/// command prompt misses jump labels in a batch file whose lines end with LF alone.</param>
|
||||
public sealed record SourceCodeLanguage(string Name, string FileExtension, string FileName = "", bool NeedsByteOrderMark = false, bool NeedsWindowsLineEndings = false)
|
||||
{
|
||||
/// <summary>
|
||||
/// No language at all, for a file which is not a code block of this kind.
|
||||
/// </summary>
|
||||
public static readonly SourceCodeLanguage NONE = new(string.Empty, string.Empty);
|
||||
|
||||
private static readonly Encoding WITH_BYTE_ORDER_MARK = new UTF8Encoding(true);
|
||||
private static readonly Encoding WITHOUT_BYTE_ORDER_MARK = new UTF8Encoding(false);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the language has a fixed file name rather than an extension.
|
||||
/// </summary>
|
||||
private bool HasFixedFileName => this.FileName.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the name of the language as shown to the user, together with its file.
|
||||
/// </summary>
|
||||
/// <returns>For example "Python (.py)", or the file name alone, such as "Dockerfile".</returns>
|
||||
public string ToName() => this.HasFixedFileName ? this.FileName : $"{this.Name} ({this.FileExtension})";
|
||||
|
||||
/// <summary>
|
||||
/// Returns what tells a file of the language apart: its extension, or its fixed file name.
|
||||
/// </summary>
|
||||
/// <returns>For example ".py" or "Dockerfile".</returns>
|
||||
public string ToFileExtensionOrName() => this.HasFixedFileName ? this.FileName : this.FileExtension;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the filter which the save dialog offers for a file of the language.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A filter by file name holds no extension, so the dialog shows every file, cf.
|
||||
/// FileTypes.ToFileDialogFilter. That is what we want for a Dockerfile.
|
||||
/// </remarks>
|
||||
/// <returns>The filter.</returns>
|
||||
public FileTypeFilter ToFileTypeFilter() => this.HasFixedFileName
|
||||
? FileTypeFilter.ByFileName(this.Name, this.FileName)
|
||||
: FileTypeFilter.Leaf(this.Name, this.FileExtension.TrimStart('.'));
|
||||
|
||||
/// <summary>
|
||||
/// Returns the file name the save dialog starts with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A fixed file name wins over whatever the file is about: Docker finds a Dockerfile, but no
|
||||
/// "Backend.Dockerfile" unless somebody tells it so.
|
||||
/// </remarks>
|
||||
/// <param name="name">What the file is about, for example the heading above the code block.
|
||||
/// Null or blank falls back to a generic name.</param>
|
||||
/// <returns>The suggested file name.</returns>
|
||||
public string ToSuggestedFileName(string? name = null) => this.HasFixedFileName ? this.FileName : FileExportFormatExtensions.SuggestFileName(name, this.FileExtension);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the encoding the file gets written with: UTF-8, with a byte order mark only where
|
||||
/// the language needs one.
|
||||
/// </summary>
|
||||
/// <returns>The encoding to write the file with.</returns>
|
||||
public Encoding ToFileEncoding() => this.NeedsByteOrderMark ? WITH_BYTE_ORDER_MARK : WITHOUT_BYTE_ORDER_MARK;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Collections.Frozen;
|
||||
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// The languages AI Studio recognizes behind the opening fence of a code block, so that the block
|
||||
/// can be saved as a file of its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Models do not agree on how they spell a language: Python comes as python, py, or Python3. The
|
||||
/// catalog therefore knows the usual aliases, the way GitHub Linguist and highlight.js spell them,
|
||||
/// in any case. The formats AI Studio writes as formats of their own, such as html, latex, markdown,
|
||||
/// or csv, are missing on purpose: FileExportFormatExtensions.TryFromCodeFenceLanguage reads them
|
||||
/// first, and a file in one of them is treated apart from code, it takes the sources of the answer
|
||||
/// along, for example. Each language has exactly one extension; whoever needs another one renames
|
||||
/// the file.
|
||||
/// </remarks>
|
||||
public static class SourceCodeLanguages
|
||||
{
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(SourceCodeLanguages).Namespace, nameof(SourceCodeLanguages));
|
||||
|
||||
/// <summary>
|
||||
/// What a model writes behind the fence when it means plain text.
|
||||
/// </summary>
|
||||
private static readonly FrozenSet<string> PLAIN_TEXT_ALIASES = new[] { "text", "txt", "plaintext", "plain", "none", "nohighlight" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static readonly FrozenDictionary<string, SourceCodeLanguage> LANGUAGES_BY_ALIAS = BuildCatalog(
|
||||
[
|
||||
(new("ABAP", ".abap"), ["abap"]),
|
||||
(new("Ada", ".adb"), ["ada"]),
|
||||
(new("Apache", ".conf"), ["apache", "apacheconf"]),
|
||||
(new("Arduino", ".ino"), ["arduino", "ino"]),
|
||||
(new("AsciiDoc", ".adoc"), ["asciidoc", "adoc"]),
|
||||
(new("Assembly", ".asm"), ["asm", "assembly", "nasm", "x86asm"]),
|
||||
(new("AWK", ".awk"), ["awk"]),
|
||||
(new("Batch", ".bat", NeedsWindowsLineEndings: true), ["bat", "batch", "batchfile", "cmd", "dos"]),
|
||||
(new("BibTeX", ".bib"), ["bibtex", "biblatex", "bib"]),
|
||||
(new("Bicep", ".bicep"), ["bicep"]),
|
||||
(new("C", ".c"), ["c"]),
|
||||
(new("C#", ".cs"), ["csharp", "cs", "c#"]),
|
||||
(new("C++", ".cpp"), ["cpp", "c++", "cc", "cxx"]),
|
||||
(new("Clojure", ".clj"), ["clojure", "clj"]),
|
||||
(new("CMake", string.Empty, "CMakeLists.txt"), ["cmake"]),
|
||||
(new("COBOL", ".cbl"), ["cobol"]),
|
||||
(new("Containerfile", string.Empty, "Containerfile"), ["containerfile"]),
|
||||
(new("CSS", ".css"), ["css"]),
|
||||
(new("CUDA", ".cu"), ["cuda", "cu"]),
|
||||
(new("Cypher", ".cypher"), ["cypher"]),
|
||||
(new("D", ".d"), ["d"]),
|
||||
(new("Dart", ".dart"), ["dart"]),
|
||||
(new("Diff", ".diff"), ["diff", "udiff"]),
|
||||
(new("Dockerfile", string.Empty, "Dockerfile"), ["dockerfile", "docker"]),
|
||||
(new("Dotenv", ".env"), ["dotenv", "env"]),
|
||||
(new("Elixir", ".ex"), ["elixir", "ex"]),
|
||||
(new("Elm", ".elm"), ["elm"]),
|
||||
(new("Emacs Lisp", ".el"), ["elisp", "emacs-lisp"]),
|
||||
(new("Erlang", ".erl"), ["erlang", "erl"]),
|
||||
(new("F#", ".fs"), ["fsharp", "fs", "f#"]),
|
||||
(new("Fish", ".fish"), ["fish"]),
|
||||
(new("Fortran", ".f90"), ["fortran", "f90", "f95", "f03", "f08"]),
|
||||
(new("GDScript", ".gd"), ["gdscript", "gd"]),
|
||||
(new("Gherkin", ".feature"), ["gherkin", "cucumber", "feature"]),
|
||||
(new("GLSL", ".glsl"), ["glsl"]),
|
||||
(new("Gnuplot", ".gp"), ["gnuplot"]),
|
||||
(new("Go", ".go"), ["go", "golang"]),
|
||||
(new("GraphQL", ".graphql"), ["graphql", "gql"]),
|
||||
(new("Graphviz", ".dot"), ["graphviz", "dot", "gv"]),
|
||||
(new("Groovy", ".groovy"), ["groovy", "gradle"]),
|
||||
(new("Haskell", ".hs"), ["haskell", "hs"]),
|
||||
(new("HCL", ".hcl"), ["hcl"]),
|
||||
(new("HLSL", ".hlsl"), ["hlsl"]),
|
||||
(new("HTTP", ".http"), ["http"]),
|
||||
(new("INI", ".ini"), ["ini", "dosini"]),
|
||||
(new("Java", ".java"), ["java"]),
|
||||
(new("Java Properties", ".properties"), ["properties", "java-properties"]),
|
||||
(new("JavaScript", ".js"), ["javascript", "js", "node", "nodejs"]),
|
||||
(new("Jenkinsfile", string.Empty, "Jenkinsfile"), ["jenkinsfile", "jenkins"]),
|
||||
(new("Jinja", ".j2"), ["jinja", "jinja2", "j2"]),
|
||||
(new("JSON", ".json"), ["json"]),
|
||||
(new("JSON5", ".json5"), ["json5"]),
|
||||
(new("JSONC", ".jsonc"), ["jsonc"]),
|
||||
(new("JSX", ".jsx"), ["jsx"]),
|
||||
(new("Julia", ".jl"), ["julia", "jl"]),
|
||||
(new("Kotlin", ".kt"), ["kotlin", "kt"]),
|
||||
(new("Less", ".less"), ["less"]),
|
||||
(new("Lisp", ".lisp"), ["lisp", "commonlisp", "common-lisp"]),
|
||||
(new("Log", ".log"), ["log"]),
|
||||
(new("Lua", ".lua"), ["lua"]),
|
||||
(new("Makefile", string.Empty, "Makefile"), ["makefile", "make"]),
|
||||
(new("Mathematica", ".wl"), ["mathematica", "wolfram", "wl"]),
|
||||
(new("MATLAB", ".m"), ["matlab"]),
|
||||
(new("Mermaid", ".mmd"), ["mermaid", "mmd"]),
|
||||
(new("Modelica", ".mo"), ["modelica"]),
|
||||
(new("Mojo", ".mojo"), ["mojo"]),
|
||||
(new("Nginx", ".conf"), ["nginx", "nginxconf"]),
|
||||
(new("Nim", ".nim"), ["nim"]),
|
||||
(new("Nix", ".nix"), ["nix"]),
|
||||
(new("Objective-C", ".m"), ["objectivec", "objective-c", "objc", "obj-c"]),
|
||||
(new("OCaml", ".ml"), ["ocaml", "ml"]),
|
||||
(new("Octave", ".m"), ["octave"]),
|
||||
(new("Pascal", ".pas"), ["pascal", "delphi", "objectpascal", "pas"]),
|
||||
(new("Patch", ".patch"), ["patch"]),
|
||||
(new("Perl", ".pl"), ["perl", "pl"]),
|
||||
(new("PHP", ".php"), ["php"]),
|
||||
(new("PlantUML", ".puml"), ["plantuml", "puml"]),
|
||||
(new("PowerShell", ".ps1", NeedsByteOrderMark: true), ["powershell", "ps1", "pwsh", "posh"]),
|
||||
(new("Prisma", ".prisma"), ["prisma"]),
|
||||
(new("Prolog", ".pl"), ["prolog"]),
|
||||
(new("Protocol Buffers", ".proto"), ["protobuf", "proto", "proto3"]),
|
||||
(new("Python", ".py"), ["python", "py", "python3", "py3"]),
|
||||
(new("R", ".R"), ["r"]),
|
||||
(new("Racket", ".rkt"), ["racket", "rkt"]),
|
||||
(new("Razor", ".razor"), ["razor"]),
|
||||
(new("reStructuredText", ".rst"), ["restructuredtext", "rst"]),
|
||||
(new("Ruby", ".rb"), ["ruby", "rb"]),
|
||||
(new("Rust", ".rs"), ["rust", "rs"]),
|
||||
(new("SAS", ".sas"), ["sas"]),
|
||||
(new("Sass", ".sass"), ["sass"]),
|
||||
(new("Scala", ".scala"), ["scala"]),
|
||||
(new("Scheme", ".scm"), ["scheme", "scm"]),
|
||||
(new("SCSS", ".scss"), ["scss"]),
|
||||
(new("sed", ".sed"), ["sed"]),
|
||||
(new("Shell", ".sh"), ["bash", "sh", "shell", "ksh"]),
|
||||
(new("Solidity", ".sol"), ["solidity", "sol"]),
|
||||
(new("SPARQL", ".rq"), ["sparql", "rq"]),
|
||||
(new("SQL", ".sql"), ["sql", "mysql", "mssql", "postgresql", "postgres", "pgsql", "plpgsql", "plsql", "sqlite", "tsql"]),
|
||||
(new("Stata", ".do"), ["stata"]),
|
||||
(new("Svelte", ".svelte"), ["svelte"]),
|
||||
(new("SVG", ".svg"), ["svg"]),
|
||||
(new("Swift", ".swift"), ["swift"]),
|
||||
(new("SystemVerilog", ".sv"), ["systemverilog", "sv"]),
|
||||
(new("Tcl", ".tcl"), ["tcl"]),
|
||||
(new("Terraform", ".tf"), ["terraform", "tf"]),
|
||||
(new("TOML", ".toml"), ["toml"]),
|
||||
(new("TSX", ".tsx"), ["tsx"]),
|
||||
(new("Turtle", ".ttl"), ["turtle", "ttl"]),
|
||||
(new("TypeScript", ".ts"), ["typescript", "ts"]),
|
||||
(new("Typst", ".typ"), ["typst", "typ"]),
|
||||
(new("VB.NET", ".vb"), ["vbnet", "vb.net", "vb", "visualbasic"]),
|
||||
(new("VBScript", ".vbs"), ["vbscript", "vbs"]),
|
||||
(new("Verilog", ".v"), ["verilog"]),
|
||||
(new("VHDL", ".vhd"), ["vhdl", "vhd"]),
|
||||
(new("Vim script", ".vim"), ["vim", "vimscript", "viml"]),
|
||||
(new("Vue", ".vue"), ["vue"]),
|
||||
(new("WebAssembly", ".wat"), ["wat", "wast", "wasm"]),
|
||||
(new("XAML", ".xaml"), ["xaml"]),
|
||||
(new("XML", ".xml"), ["xml"]),
|
||||
(new("XSLT", ".xslt"), ["xslt", "xsl"]),
|
||||
(new("YAML", ".yaml"), ["yaml", "yml"]),
|
||||
(new("Zig", ".zig"), ["zig"]),
|
||||
(new("Zsh", ".zsh"), ["zsh"]),
|
||||
]);
|
||||
|
||||
/// <summary>
|
||||
/// Every alias the catalog knows, including the ones for plain text.
|
||||
/// </summary>
|
||||
internal static IEnumerable<string> Aliases => [..PLAIN_TEXT_ALIASES, ..LANGUAGES_BY_ALIAS.Keys];
|
||||
|
||||
/// <summary>
|
||||
/// Every language the catalog knows.
|
||||
/// </summary>
|
||||
internal static IEnumerable<SourceCodeLanguage> Languages => LANGUAGES_BY_ALIAS.Values.Distinct();
|
||||
|
||||
/// <summary>
|
||||
/// Reads which language a model means when it names one behind the opening fence of a code block.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every code block becomes a file. A language we do not know keeps its name and becomes a text
|
||||
/// file, so the user still sees which block it is; a block without any language is plain text.
|
||||
/// </remarks>
|
||||
/// <param name="language">The language behind the opening fence, as Markdig reads it into
|
||||
/// FencedCodeBlock.Info.</param>
|
||||
/// <returns>The language, never NONE.</returns>
|
||||
public static SourceCodeLanguage FromCodeFenceLanguage(string? language)
|
||||
{
|
||||
var name = language?.Trim() ?? string.Empty;
|
||||
if (name.Length is 0 || PLAIN_TEXT_ALIASES.Contains(name))
|
||||
return new(TB("Text"), ".txt");
|
||||
|
||||
return LANGUAGES_BY_ALIAS.TryGetValue(name, out var knownLanguage) ? knownLanguage : new(name, ".txt");
|
||||
}
|
||||
|
||||
private static FrozenDictionary<string, SourceCodeLanguage> BuildCatalog(IEnumerable<(SourceCodeLanguage Language, string[] Aliases)> languages)
|
||||
{
|
||||
//
|
||||
// Add throws for an alias named twice. A mistake in the list above therefore fails every test
|
||||
// which touches the catalog, instead of quietly hiding one of the two languages:
|
||||
//
|
||||
var catalog = new Dictionary<string, SourceCodeLanguage>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (language, aliases) in languages)
|
||||
foreach (var alias in aliases)
|
||||
catalog.Add(alias, language);
|
||||
|
||||
return catalog.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,24 @@
|
||||
margin-bottom:2em;
|
||||
}
|
||||
|
||||
/* The actions of a code block, such as saving it, appear left of its copy button, which is 36px wide plus a margin of 8px */
|
||||
.mud-markdown-body .code-block-with-actions {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mud-markdown-body .code-block-actions {
|
||||
position: absolute;
|
||||
display: none;
|
||||
top: 0;
|
||||
right: 44px;
|
||||
}
|
||||
|
||||
/* Both appear while the pointer is on the code block, and the copy button stays while it is on the actions, which are no part of the block */
|
||||
.mud-markdown-body .code-block-with-actions:hover > .code-block-actions,
|
||||
.mud-markdown-body .code-block-with-actions:hover .mud-markdown-code-highlight-copybtn {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.justified-markdown .mud-markdown-body p,
|
||||
.justified-markdown .mud-markdown-body li,
|
||||
.justified-markdown .mud-markdown-body blockquote p {
|
||||
|
||||
@@ -74,6 +74,9 @@
|
||||
- Improved searching your local data sources while AI Studio updates their index. A document that changed stays searchable in its previous version until its new version is complete. This improvement comes from Paul Koudelka.
|
||||
- Improved reading Word documents, for example, in your local data sources. AI Studio now leaves out their custom properties, such as the internal IDs of SharePoint, which told the AI nothing and made searches less precise. Thanks, Paul Koudelka.
|
||||
- Improved the tool selection in chats and assistants, which now lists only the tools you can select right now. Tools you cannot select at the moment, for example, because a setting is missing, wait behind a button below the list, together with the reason. The button also tells you how many of them you have selected.
|
||||
- Improved saving a single code block of an answer. The export menu now offers code in every programming language, such as Python or Rust, as a file of its own. A code block in a language AI Studio does not know, or without any language, becomes a text file.
|
||||
- Improved the export menu of answers with many tables or code blocks. From six of them on, the menu groups them by kind, so the formats for the entire answer stay in view. When an answer holds code blocks in different languages, the menu now names each block by its language, such as Python or Rust, instead of numbering them.
|
||||
- Improved saving a single code block. When you point at a code block, whether in an answer or in one of your own messages, a save button now appears next to its copy button. You no longer have to scroll up to the export menu of a long answer, and you can save code you sent weeks ago, for example, to get back an earlier version of it.
|
||||
- Changed the banner on the start page to a fresh design that shows what AI Studio can do today.
|
||||
- Changed when a chat you add to a workspace in the workspace list appears there. Like any other new chat, it now appears once there is something to keep, for example, your first message, instead of right away.
|
||||
- Changed the name of the Slide Planner Assistant to Slide Assistant. With a PowerPoint template, it now creates the slides themselves, not just a plan for them.
|
||||
@@ -147,6 +150,7 @@
|
||||
- Fixed an assistant undoing your changes every few seconds when you left it during a run and came back before it finished. Your changes now stay, and the result still appears when the assistant is done. In the Slide Assistant, this also ends short freezes while it plans.
|
||||
- Fixed exporting a briefing of the Visual Briefing assistant into a file which another program keeps open or which cannot be written. AI Studio now tells you that the export failed.
|
||||
- Fixed the German warning about tools which the chosen AI provider is not trusted enough to use. It named these tools as if they were the provider.
|
||||
- Fixed the configuration of your organization being able to preset a format for the result files of the Batch Processing assistant which cannot hold an entire answer, such as a table. AI Studio now ignores such a format and keeps the one you chose.
|
||||
- Updated the code contributions on the supporters page, which now thank everyone who has contributed code to AI Studio so far.
|
||||
- Upgraded several libraries to improve security.
|
||||
- Upgraded to Rust v1.99.1
|
||||
|
||||
@@ -229,6 +229,43 @@ public sealed class IContentExtensionsTests
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CodeCarriesNoSources()
|
||||
{
|
||||
var content = TextWith(Lines(
|
||||
"```python",
|
||||
"import sys",
|
||||
"print(sys.argv)",
|
||||
"```"), TOOL_SOURCE);
|
||||
|
||||
var file = FilesOf(content).Single();
|
||||
|
||||
Assert.That(content.ToExportContent(file), Is.EqualTo("import sys\nprint(sys.argv)\n"), "A program is meant for its interpreter; the only thing we add is the line break its last line lacks.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CodeWhichEndsWithALineBreakGetsNoSecondOne()
|
||||
{
|
||||
var file = new MessageFile(1, Position: 0, string.Empty, FileExportFormat.SOURCE_CODE, SourceCodeLanguages.FromCodeFenceLanguage("python"), "print(42)\n");
|
||||
|
||||
Assert.That(TextWith(string.Empty).ToExportContent(file), Is.EqualTo("print(42)\n"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ABatchFileEndsItsLinesTheWindowsWay()
|
||||
{
|
||||
var content = TextWith(Lines(
|
||||
"```bat",
|
||||
"@echo off",
|
||||
":again",
|
||||
"goto again",
|
||||
"```"));
|
||||
|
||||
var file = FilesOf(content).Single();
|
||||
|
||||
Assert.That(content.ToExportContent(file), Is.EqualTo("@echo off\r\n:again\r\ngoto again\r\n"), "The command prompt misses jump labels in a file whose lines end with LF alone.");
|
||||
}
|
||||
|
||||
[TestCase("markdown")]
|
||||
[TestCase("html")]
|
||||
[TestCase("latex")]
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using AIStudio.Components;
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Markdig.Extensions.Mathematics;
|
||||
using Markdig.Syntax;
|
||||
|
||||
namespace AIStudio.Tests.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Checks which code blocks of an answer get actions, such as a button to save them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A block finds its file by where it starts. The chat renders the answer with a pipeline of its
|
||||
/// own, so the blocks are read the same way here; how an answer cut around a formula keeps its
|
||||
/// positions is checked in PlainFileExportTests.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ExportableMarkdownTests
|
||||
{
|
||||
[Test]
|
||||
public void EachCodeBlockFindsItsFile()
|
||||
{
|
||||
var answer = Lines(
|
||||
"| Quarter | Revenue |",
|
||||
"|---|---|",
|
||||
"| Q1 | 100 |",
|
||||
string.Empty,
|
||||
"```python",
|
||||
"print(100)",
|
||||
"```",
|
||||
string.Empty,
|
||||
"```html",
|
||||
"<p>100</p>",
|
||||
"```");
|
||||
|
||||
var files = PlainFileExport.ExtractFiles(answer, ',');
|
||||
var foundFiles = Render(answer).Descendants<FencedCodeBlock>().Select(block => ExportableMarkdown.FileAt(files, block.Span.Start));
|
||||
|
||||
Assert.That(foundFiles, Is.EqualTo(new[] { files[1], files[2] }), "The table comes first among the files, but it is no code block.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AFormulaFindsNoFile()
|
||||
{
|
||||
var answer = Lines("1. The energy follows from", string.Empty, " $$", " E = mc^2", " $$");
|
||||
|
||||
var formula = Render(answer).Descendants<MathBlock>().Single();
|
||||
|
||||
Assert.That(ExportableMarkdown.FileAt(PlainFileExport.ExtractFiles(answer, ','), formula.Span.Start), Is.Null, "Markdig reads a formula as a fenced block, but nobody saves a formula as a text file.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ACodeBlockTheModelNeverClosedFindsNoFile()
|
||||
{
|
||||
var answer = Lines("The answer starts normally.", string.Empty, "```python", "def main():");
|
||||
|
||||
var block = Render(answer).Descendants<FencedCodeBlock>().Single();
|
||||
|
||||
Assert.That(ExportableMarkdown.FileAt(PlainFileExport.ExtractFiles(answer, ','), block.Span.Start), Is.Null, "The file would end wherever the answer broke off.");
|
||||
}
|
||||
|
||||
private static MarkdownDocument Render(string answer) => Markdig.Markdown.Parse(answer, Markdown.CHAT_MARKDOWN_PIPELINE);
|
||||
|
||||
private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Lua;
|
||||
using Lua.Standard;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Checks which format of the result files an organization can preselect for the Batch Processing
|
||||
/// assistant through a configuration plugin.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The settings are reached through Program.SERVICE_PROVIDER, as in the app, because a managed setting
|
||||
/// writes its value and its lock there. The plugin passes the check of DataBatchProcessing, as
|
||||
/// PluginConfiguration does.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class BatchProcessingResultFileFormatTests
|
||||
{
|
||||
private static readonly Guid PLUGIN_ID = Guid.Parse("9d2f6c41-3b8e-4a07-b5d1-e6a8c2f04b73");
|
||||
|
||||
private SettingsManager settingsManager = null!;
|
||||
private ServiceProvider serviceProvider = null!;
|
||||
private IServiceProvider previousServiceProvider = null!;
|
||||
|
||||
[SetUp]
|
||||
public void CreateSettings()
|
||||
{
|
||||
// Creating the settings data registers every managed setting:
|
||||
this.settingsManager = new SettingsManager(NullLogger<SettingsManager>.Instance, null!);
|
||||
|
||||
this.previousServiceProvider = Program.SERVICE_PROVIDER;
|
||||
this.serviceProvider = new ServiceCollection().AddSingleton(this.settingsManager).BuildServiceProvider();
|
||||
Program.SERVICE_PROVIDER = this.serviceProvider;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void RestoreApplicationState()
|
||||
{
|
||||
// The managed state of a setting outlives this fixture, so we release what the plugin took over:
|
||||
ManagedConfiguration.CleanupLeftOverManagedConfigurations([], new HashSet<Guid>());
|
||||
|
||||
Program.SERVICE_PROVIDER = this.previousServiceProvider;
|
||||
this.serviceProvider.Dispose();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task APluginPreselectsAFormatForEntireAnswers()
|
||||
{
|
||||
var processed = await ProcessAsync("MICROSOFT_WORD");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(processed, Is.True);
|
||||
Assert.That(this.ResultFileFormat, Is.EqualTo(FileExportFormat.MICROSOFT_WORD));
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase("CSV", TestName = "A table, which holds one table out of an answer")]
|
||||
[TestCase("SOURCE_CODE", TestName = "Code, which takes its extension from a language")]
|
||||
[TestCase("NONE", TestName = "No format at all")]
|
||||
public async Task AFormatWhichHoldsNoEntireAnswerIsRejected(string configuredFormat)
|
||||
{
|
||||
this.ResultFileFormat = FileExportFormat.HTML;
|
||||
var processed = await ProcessAsync(configuredFormat);
|
||||
|
||||
ManagedConfiguration.TryGet(x => x.BatchProcessing, x => x.ResultFileFormat, out var configMeta);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(processed, Is.False, "The result files would end up without an extension or as something other than an answer.");
|
||||
Assert.That(this.ResultFileFormat, Is.EqualTo(FileExportFormat.HTML), "A rejected value leaves the choice of the user untouched.");
|
||||
Assert.That(configMeta.IsLocked, Is.False, "A rejected value locks nothing.");
|
||||
});
|
||||
}
|
||||
|
||||
private FileExportFormat ResultFileFormat
|
||||
{
|
||||
get => this.settingsManager.ConfigurationData.BatchProcessing.ResultFileFormat;
|
||||
set => this.settingsManager.ConfigurationData.BatchProcessing.ResultFileFormat = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lets a configuration plugin preselect the format of the result files.
|
||||
/// </summary>
|
||||
/// <param name="format">The name of the format as the plugin writes it.</param>
|
||||
/// <returns>Whether the plugin's value was taken.</returns>
|
||||
private static async Task<bool> ProcessAsync(string format)
|
||||
{
|
||||
var state = LuaState.Create();
|
||||
state.OpenBasicLibrary();
|
||||
|
||||
await state.DoStringAsync($"""
|
||||
SETTINGS = {"{}"}
|
||||
SETTINGS["DataBatchProcessing.ResultFileFormat"] = "{format}"
|
||||
""");
|
||||
|
||||
if (!state.Environment["SETTINGS"].TryRead<LuaTable>(out var settingsTable))
|
||||
throw new InvalidOperationException("The settings of this test are not a Lua table.");
|
||||
|
||||
return ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.ResultFileFormat, PLUGIN_ID, settingsTable, false, validator: DataBatchProcessing.IsValidResultFileFormat);
|
||||
}
|
||||
}
|
||||
@@ -101,10 +101,22 @@ public sealed class FileExportFormatTests
|
||||
Assert.That(FileExportFormat.MARKDOWN.IsPlainText(), Is.True);
|
||||
Assert.That(FileExportFormat.CSV.IsPlainText(), Is.True);
|
||||
Assert.That(FileExportFormat.TSV.IsPlainText(), Is.True);
|
||||
Assert.That(FileExportFormat.SOURCE_CODE.IsPlainText(), Is.True, "Code is text, whatever its language.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase("css", TestName = "A language AI Studio writes no file for")]
|
||||
[Test]
|
||||
public void CodeLeavesItsFileToItsLanguage()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(FileExportFormat.SOURCE_CODE.ToFileExtension(), Is.Empty, "Python and Rust share the format, but not the extension.");
|
||||
Assert.That(FileExportFormat.SOURCE_CODE.ToFileTypeFilter(), Is.Null, "Without the language, the save dialog would offer the wrong files.");
|
||||
Assert.That(FileExportFormatExtensions.ANSWER_FORMATS, Does.Not.Contain(FileExportFormat.SOURCE_CODE), "Code is one block out of an answer, never the answer itself.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase("css", TestName = "A language without a format of its own")]
|
||||
[TestCase("docx", TestName = "A format no code block can hold")]
|
||||
[TestCase("", TestName = "A fence without a language")]
|
||||
[TestCase(null, TestName = "A fence Markdig read no language for")]
|
||||
|
||||
@@ -2,6 +2,8 @@ using System.Runtime.CompilerServices;
|
||||
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Markdig.Syntax;
|
||||
|
||||
namespace AIStudio.Tests.Tools;
|
||||
|
||||
/// <summary>
|
||||
@@ -70,17 +72,39 @@ public sealed class PlainFileExportTests
|
||||
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)
|
||||
[TestCase("```python", "Python (.py)")]
|
||||
[TestCase("```rust", "Rust (.rs)")]
|
||||
[TestCase("```css", "CSS (.css)")]
|
||||
[TestCase("~~~bash", "Shell (.sh)")]
|
||||
[TestCase("```foo", "foo (.txt)", Description = "A language AI Studio does not know still names the block.")]
|
||||
[TestCase("```", "Text (.txt)", Description = "A block without a language is plain text.")]
|
||||
public void ACodeBlockInAnyOtherLanguageIsOfferedAsCode(string openingFence, string expectedName)
|
||||
{
|
||||
var files = PlainFileExport.ExtractFiles(Lines(openingFence, "body { margin: 0; }", "```"), ',');
|
||||
var files = PlainFileExport.ExtractFiles(Lines(openingFence, "print(42)", openingFence[..3]), ',');
|
||||
|
||||
Assert.That(files, Is.Empty);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(files, Has.Count.EqualTo(1));
|
||||
Assert.That(files[0].Format, Is.EqualTo(FileExportFormat.SOURCE_CODE));
|
||||
Assert.That(files[0].ToName(), Is.EqualTo(expectedName));
|
||||
Assert.That(files[0].Content, Is.EqualTo("print(42)"), "The code leaves the answer exactly as the model wrote it, without the fence around it.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AFormulaIsNoCodeBlock()
|
||||
{
|
||||
var answer = Lines("The energy follows from", string.Empty, "$$", "E = mc^2", "$$", string.Empty, "for every body at rest.");
|
||||
|
||||
var files = PlainFileExport.ExtractFiles(answer, ',');
|
||||
|
||||
Assert.That(files, Is.Empty, "Markdig reads a formula as a fenced block, but nobody saves a formula as a text file.");
|
||||
}
|
||||
|
||||
[TestCase("```html", "<html><body><p>The answer broke off here", TestName = "Half a web page")]
|
||||
[TestCase("```csv", "Quarter,Revenue", TestName = "Half a table")]
|
||||
[TestCase("```python", "def main():", TestName = "Half a program")]
|
||||
[TestCase("```", "The answer broke off here", TestName = "Half a text")]
|
||||
public void ACodeBlockTheModelNeverClosedIsNotOffered(string openingFence, string content)
|
||||
{
|
||||
var files = PlainFileExport.ExtractFiles(Lines("The answer starts normally.", string.Empty, openingFence, content), ',');
|
||||
@@ -106,6 +130,12 @@ public sealed class PlainFileExportTests
|
||||
string.Empty,
|
||||
"```latex",
|
||||
@"\section{Second block}",
|
||||
"```",
|
||||
string.Empty,
|
||||
"# Script",
|
||||
string.Empty,
|
||||
"```python",
|
||||
"print('Third block')",
|
||||
"```");
|
||||
|
||||
var files = PlainFileExport.ExtractFiles(answer, ',');
|
||||
@@ -115,7 +145,84 @@ public sealed class PlainFileExportTests
|
||||
(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.");
|
||||
(3, "Script", FileExportFormat.SOURCE_CODE),
|
||||
}), "The first code block is code block 1, even though a table stands before it, and code counts along with the other blocks.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EachFileKnowsWhereItStartsInTheAnswer()
|
||||
{
|
||||
var answer = Lines(
|
||||
"Here is the revenue:",
|
||||
string.Empty,
|
||||
"| Quarter | Revenue |",
|
||||
"|---|---|",
|
||||
"| Q1 | 100 |",
|
||||
string.Empty,
|
||||
"And the script which made it:",
|
||||
string.Empty,
|
||||
"```python",
|
||||
"print(100)",
|
||||
"```");
|
||||
|
||||
var files = PlainFileExport.ExtractFiles(answer, ',');
|
||||
|
||||
Assert.That(files.Select(file => file.Position), Is.EqualTo(new[] { answer.IndexOf("| Quarter", StringComparison.Ordinal), answer.IndexOf("```python", StringComparison.Ordinal) }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ACodeBlockInAListKnowsWhereItStarts()
|
||||
{
|
||||
var answer = Lines(
|
||||
"1. Install the package:",
|
||||
string.Empty,
|
||||
" ```bash",
|
||||
" pip install requests",
|
||||
" ```",
|
||||
"2. Run your script.");
|
||||
|
||||
var files = PlainFileExport.ExtractFiles(answer, ',');
|
||||
|
||||
Assert.That(files.Select(file => file.Position), Is.EqualTo(new[] { answer.IndexOf("```bash", StringComparison.Ordinal) }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks what a code block the chat renders relies on to find its file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The chat renders an answer in parts, cut around each formula, and Markdig reads every part on
|
||||
/// its own. A block of such a part therefore knows where it starts within its part only. Added to
|
||||
/// where the part starts in the answer, that has to be the position the file was read with.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void ACodeBlockAfterAFormulaFindsItsFile()
|
||||
{
|
||||
var answer = Lines(
|
||||
"# Script",
|
||||
string.Empty,
|
||||
"```python",
|
||||
"print(1)",
|
||||
"```",
|
||||
string.Empty,
|
||||
"$$",
|
||||
"E = mc^2",
|
||||
"$$",
|
||||
string.Empty,
|
||||
"```rust",
|
||||
"fn main() {}",
|
||||
"```");
|
||||
|
||||
var files = PlainFileExport.ExtractFiles(answer, ',');
|
||||
var partStart = answer.LastIndexOf("$$", StringComparison.Ordinal) + "$$".Length + Environment.NewLine.Length;
|
||||
var part = Markdig.Markdown.Parse(answer[partStart..], Markdown.CHAT_MARKDOWN_PIPELINE);
|
||||
var blockOfThePart = part.Descendants<FencedCodeBlock>().Single();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(files, Has.Count.EqualTo(2));
|
||||
Assert.That(partStart + blockOfThePart.Span.Start, Is.EqualTo(files[1].Position));
|
||||
Assert.That(files[1].ToName(), Is.EqualTo("Rust (.rs)"));
|
||||
});
|
||||
}
|
||||
|
||||
private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using AIStudio.Tools;
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tests.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Checks which file a code block becomes, judged by the language the model names behind its
|
||||
/// opening fence.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class SourceCodeLanguagesTests
|
||||
{
|
||||
[TestCase("python", "Python (.py)")]
|
||||
[TestCase("py", "Python (.py)")]
|
||||
[TestCase("rust", "Rust (.rs)")]
|
||||
[TestCase("rs", "Rust (.rs)")]
|
||||
[TestCase("javascript", "JavaScript (.js)")]
|
||||
[TestCase("js", "JavaScript (.js)")]
|
||||
[TestCase("bash", "Shell (.sh)")]
|
||||
[TestCase("sh", "Shell (.sh)")]
|
||||
[TestCase("c#", "C# (.cs)")]
|
||||
[TestCase("C++", "C++ (.cpp)")]
|
||||
[TestCase("Python", "Python (.py)", Description = "Models do not agree on the case.")]
|
||||
[TestCase(" yaml ", "YAML (.yaml)", Description = "Space around the name is no part of it.")]
|
||||
public void AKnownLanguageNamesItsFile(string language, string expectedName)
|
||||
{
|
||||
Assert.That(SourceCodeLanguages.FromCodeFenceLanguage(language).ToName(), Is.EqualTo(expectedName));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnUnknownLanguageBecomesATextFileUnderItsOwnName()
|
||||
{
|
||||
var language = SourceCodeLanguages.FromCodeFenceLanguage("foo");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(language.Name, Is.EqualTo("foo"), "The user still has to recognize which block it is.");
|
||||
Assert.That(language.FileExtension, Is.EqualTo(".txt"));
|
||||
Assert.That(language.ToName(), Is.EqualTo("foo (.txt)"));
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase(null, TestName = "A fence Markdig read no language for")]
|
||||
[TestCase("", TestName = "A fence without a language")]
|
||||
[TestCase(" ", TestName = "A language made of nothing")]
|
||||
[TestCase("text", TestName = "A fence marked as text")]
|
||||
[TestCase("PlainText", TestName = "A fence marked as plain text")]
|
||||
public void PlainTextBecomesATextFile(string? language)
|
||||
{
|
||||
Assert.That(SourceCodeLanguages.FromCodeFenceLanguage(language).ToName(), Is.EqualTo("Text (.txt)"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AFileOfALanguageIsNamedAfterWhatItIsAbout()
|
||||
{
|
||||
var python = SourceCodeLanguages.FromCodeFenceLanguage("python");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(python.ToSuggestedFileName("Sort: a list?"), Is.EqualTo("Sort a list.py"));
|
||||
Assert.That(python.ToSuggestedFileName(), Is.EqualTo("export.py"));
|
||||
Assert.That(python.ToFileExtensionOrName(), Is.EqualTo(".py"));
|
||||
Assert.That(python.ToFileTypeFilter().FlattenExtensions(), Is.EqualTo(new[] { "py" }), "The dialog offers the one extension we write, without its dot.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase("dockerfile", "Dockerfile")]
|
||||
[TestCase("Makefile", "Makefile")]
|
||||
[TestCase("cmake", "CMakeLists.txt")]
|
||||
public void AToolWhichLooksForAFixedFileNameGetsIt(string language, string expectedFileName)
|
||||
{
|
||||
var fixedName = SourceCodeLanguages.FromCodeFenceLanguage(language);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(fixedName.ToSuggestedFileName("Backend"), Is.EqualTo(expectedFileName), "The tool finds no file under another name, whatever the heading above the block says.");
|
||||
Assert.That(fixedName.ToName(), Is.EqualTo(expectedFileName));
|
||||
Assert.That(fixedName.ToFileExtensionOrName(), Is.EqualTo(expectedFileName));
|
||||
Assert.That(FileTypes.ToFileDialogFilter(fixedName.ToFileTypeFilter()), Is.Null, "A file name is no extension, so the dialog shows every file.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyPowerShellNeedsAByteOrderMark()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(SourceCodeLanguages.FromCodeFenceLanguage("powershell").ToFileEncoding().GetPreamble(), Is.Not.Empty, "Windows PowerShell 5.1 reads a script without one in the ANSI code page.");
|
||||
Assert.That(SourceCodeLanguages.FromCodeFenceLanguage("python").ToFileEncoding().GetPreamble(), Is.Empty, "An interpreter has no use for it, and a shebang line breaks behind one.");
|
||||
Assert.That(SourceCodeLanguages.Languages.Where(language => language.NeedsByteOrderMark).Select(language => language.Name), Is.EqualTo(new[] { "PowerShell" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyABatchFileNeedsWindowsLineEndings()
|
||||
{
|
||||
Assert.That(SourceCodeLanguages.Languages.Where(language => language.NeedsWindowsLineEndings).Select(language => language.Name), Is.EqualTo(new[] { "Batch" }), "The command prompt misses jump labels in a file whose lines end with LF alone.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryLanguageHasAFile()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
foreach (var language in SourceCodeLanguages.Languages)
|
||||
{
|
||||
Assert.That(language.Name, Is.Not.Empty);
|
||||
Assert.That(language.FileExtension.Length is 0 ^ language.FileName.Length is 0, Is.True, $"{language.Name} needs either an extension or a fixed file name, not both.");
|
||||
if (language.FileExtension.Length > 0)
|
||||
Assert.That(language.FileExtension, Does.StartWith(".").And.Length.GreaterThan(1), $"{language.Name} has a broken extension.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NoAliasIsNamedTwice()
|
||||
{
|
||||
Assert.That(SourceCodeLanguages.Aliases, Is.Unique.Using((IEqualityComparer<string>)StringComparer.OrdinalIgnoreCase), "Plain text is checked first, so a language with the same alias would never be found.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NoAliasNamesAFormatOfItsOwn()
|
||||
{
|
||||
// FileExportFormatExtensions.TryFromCodeFenceLanguage is asked first, so such an entry would never be found:
|
||||
Assert.That(SourceCodeLanguages.Aliases.Where(alias => FileExportFormatExtensions.TryFromCodeFenceLanguage(alias, out _)), Is.Empty);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user