mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-13 14:53:49 +00:00
Improved exports (#993)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
e9ebc02a96
commit
fda42ac24b
20 files changed
+883
-101
No files matched your search
@@ -91,12 +91,12 @@
|
||||
{
|
||||
<MudMenuItem OnClick="@(() => this.ExportDocument(documentFormat))" Icon="@documentFormat.ToIcon()" Label="@documentFormat.ToName()"/>
|
||||
}
|
||||
@if (this.MessageTables.Count > 0)
|
||||
@if (this.MessageFiles.Count > 0)
|
||||
{
|
||||
<MudDivider/>
|
||||
@foreach (var messageTable in this.MessageTables)
|
||||
@foreach (var messageFile in this.MessageFiles)
|
||||
{
|
||||
<MudMenuItem OnClick="@(() => this.ExportTable(messageTable))" Icon="@messageTable.Format.ToIcon()" Label="@this.ExportLabel(messageTable)"/>
|
||||
<MudMenuItem OnClick="@(() => this.ExportFile(messageFile))" Icon="@messageFile.Format.ToIcon()" Label="@this.ExportLabel(messageFile)"/>
|
||||
}
|
||||
}
|
||||
<MudDivider/>
|
||||
|
||||
@@ -104,6 +104,18 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public string? ExportTitle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// What an export of this block is named after, which the save dialog suggests as file name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In the chat that is the name of the chat, in an assistant whatever the assistant says its
|
||||
/// result is about. Whoever renders this block knows which of the two it is. A table or a code
|
||||
/// block with a heading above it is named after that heading instead. Null falls back to a
|
||||
/// generic name.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public string? ExportFileName { get; set; }
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
@@ -125,8 +137,8 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
private int lastRenderHash;
|
||||
private string cachedMarkdownRenderPlanInput = string.Empty;
|
||||
private MarkdownRenderPlan cachedMarkdownRenderPlan = MarkdownRenderPlan.EMPTY;
|
||||
private string cachedMessageTablesInput = string.Empty;
|
||||
private IReadOnlyList<MessageTable> cachedMessageTables = [];
|
||||
private string cachedMessageFilesInput = string.Empty;
|
||||
private IReadOnlyList<MessageFile> cachedMessageFiles = [];
|
||||
private char csvSeparator = ',';
|
||||
private ElementReference mathContentContainer;
|
||||
private SourcesList? sourcesList;
|
||||
@@ -147,54 +159,62 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
private bool CanExport => this.Content is { InitialRemoteWait: false, IsStreaming: false } && this.Content.TryGetMarkdownText(out _);
|
||||
|
||||
/// <summary>
|
||||
/// The tables this block holds so that the export menu can offer each of them.
|
||||
/// The files this block holds, tables and code blocks, so that the export menu can offer each of them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Cached the same way the Markdown render plan is: reading the tables means parsing the whole
|
||||
/// Cached the same way the Markdown render plan is: reading the files means parsing the whole
|
||||
/// message, and a block re-renders for reasons which have nothing to do with its text, such as
|
||||
/// switching the theme, which would parse every message of a long chat again.
|
||||
/// </remarks>
|
||||
private IReadOnlyList<MessageTable> MessageTables
|
||||
private IReadOnlyList<MessageFile> MessageFiles
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!this.Content.TryGetMarkdownText(out var markdown))
|
||||
return [];
|
||||
|
||||
if (ReferenceEquals(this.cachedMessageTablesInput, markdown) || string.Equals(this.cachedMessageTablesInput, markdown, StringComparison.Ordinal))
|
||||
return this.cachedMessageTables;
|
||||
if (ReferenceEquals(this.cachedMessageFilesInput, markdown) || string.Equals(this.cachedMessageFilesInput, markdown, StringComparison.Ordinal))
|
||||
return this.cachedMessageFiles;
|
||||
|
||||
this.cachedMessageTablesInput = markdown;
|
||||
this.cachedMessageTables = PlainFileExport.ExtractTables(markdown, this.csvSeparator);
|
||||
return this.cachedMessageTables;
|
||||
this.cachedMessageFilesInput = markdown;
|
||||
this.cachedMessageFiles = PlainFileExport.ExtractFiles(markdown, this.csvSeparator);
|
||||
return this.cachedMessageFiles;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Names one table in the export menu.
|
||||
/// Names one file in the export menu.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// With a single table the format alone says everything. As soon as an answer holds more than
|
||||
/// one, the user has to be able to tell them apart: the heading above a table does that, unless
|
||||
/// it is missing or two tables share one, and then we count them.
|
||||
/// Tables and code blocks are named apart, just as they are counted apart. With a single file of
|
||||
/// its kind the format alone says everything. As soon as an answer holds more than one, the user
|
||||
/// has to be able to tell them apart: the heading above a file does that, unless it is missing
|
||||
/// or two files of the kind share one, and then we count them. A code block always says that it
|
||||
/// is one, because the menu offers the entire answer as a web page or a LaTeX document right
|
||||
/// below, and the two entries must not read alike.
|
||||
/// </remarks>
|
||||
private string ExportLabel(MessageTable table)
|
||||
private string ExportLabel(MessageFile file)
|
||||
{
|
||||
var tables = this.MessageTables;
|
||||
if (tables.Count < 2)
|
||||
return table.Format.ToName();
|
||||
|
||||
var captionIsTelling = !string.IsNullOrWhiteSpace(table.Caption)
|
||||
&& tables.Where(entry => entry.Ordinal != table.Ordinal).All(entry => !string.Equals(entry.Caption, table.Caption, StringComparison.Ordinal));
|
||||
var isTable = file.Format.IsTabular();
|
||||
var filesOfItsKind = this.MessageFiles.Where(entry => entry.Format.IsTabular() == isTable).ToList();
|
||||
var extension = file.Format.ToFileExtension();
|
||||
|
||||
//
|
||||
// The caption is the heading the model wrote, so it already carries the language of the
|
||||
// answer and needs no translation of ours. Only the fallback, where we have to count the
|
||||
// tables ourselves, is our own wording.
|
||||
// files ourselves, is our own wording.
|
||||
//
|
||||
return captionIsTelling
|
||||
? $"{table.Caption} ({table.Format.ToFileExtension()})"
|
||||
: string.Format(this.T("Table {0} ({1})"), table.Ordinal, table.Format.ToFileExtension());
|
||||
string name;
|
||||
if (filesOfItsKind.Count < 2)
|
||||
name = file.Format.ToName();
|
||||
else if (!string.IsNullOrWhiteSpace(file.Caption) && filesOfItsKind.Count(entry => string.Equals(entry.Caption, file.Caption, StringComparison.Ordinal)) is 1)
|
||||
name = $"{file.Caption} ({extension})";
|
||||
else
|
||||
return isTable
|
||||
? string.Format(T("Table {0} ({1})"), file.Ordinal, extension)
|
||||
: string.Format(T("Code block {0} ({1})"), file.Ordinal, extension);
|
||||
|
||||
return isTable ? name : string.Format(T("Code block: {0}"), name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -221,8 +241,8 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
return;
|
||||
|
||||
this.csvSeparator = separator;
|
||||
this.cachedMessageTablesInput = string.Empty;
|
||||
this.cachedMessageTables = [];
|
||||
this.cachedMessageFilesInput = string.Empty;
|
||||
this.cachedMessageFiles = [];
|
||||
await this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
|
||||
@@ -738,9 +758,9 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
// here which would fall out of sync with the one in FileExportFormatExtensions.
|
||||
//
|
||||
if (format.UsesPandoc())
|
||||
await PandocExport.ToDocument(this.RustService, this.PandocAvailability, this.EffectiveExportTitle, format, this.Content);
|
||||
await PandocExport.ToDocument(this.RustService, this.PandocAvailability, this.EffectiveExportTitle, format, this.Content, this.ExportFileName);
|
||||
else if (this.Content.TryGetExportMarkdown(out var markdown))
|
||||
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, markdown);
|
||||
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, markdown, this.ExportFileName);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException e)
|
||||
{
|
||||
@@ -749,17 +769,19 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports one table out of the message, exactly as the menu offered it.
|
||||
/// Exports one file out of the message, along with the sources the answer rests on wherever its
|
||||
/// format has room for them.
|
||||
/// </summary>
|
||||
private async Task ExportTable(MessageTable table)
|
||||
private async Task ExportFile(MessageFile file)
|
||||
{
|
||||
try
|
||||
{
|
||||
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, table.Format, table.Content, table.Caption);
|
||||
var fileName = string.IsNullOrWhiteSpace(file.Caption) ? this.ExportFileName : file.Caption;
|
||||
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, file.Format, this.Content.ToExportContent(file), fileName);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException e)
|
||||
{
|
||||
await this.ReportUnknownExportFormat(e, table.Format);
|
||||
await this.ReportUnknownExportFormat(e, file.Format);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,26 +67,65 @@ public static class IContentExtensions
|
||||
return false;
|
||||
}
|
||||
|
||||
var answer = text.Text.Trim();
|
||||
var sources = text.Sources.ToExportMarkdown(keepPageAnchors);
|
||||
if (sources.Length == 0)
|
||||
{
|
||||
markdown = answer;
|
||||
return true;
|
||||
}
|
||||
markdown = AppendSources(text.Text.Trim(), text.Sources.ToExportMarkdown(keepPageAnchors));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (answer.Length == 0)
|
||||
{
|
||||
markdown = sources;
|
||||
return true;
|
||||
}
|
||||
/// <summary>
|
||||
/// Reads one file out of this content the way it leaves AI Studio, together with the sources
|
||||
/// 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
|
||||
/// 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.
|
||||
///
|
||||
/// 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.
|
||||
/// </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>
|
||||
/// <returns>The content of the file to write.</returns>
|
||||
public static string ToExportContent(this IContent content, MessageFile file)
|
||||
{
|
||||
if (file.Format.IsTabular())
|
||||
return file.Content;
|
||||
|
||||
var sources = content.Sources.ToExportMarkdown(file.Format.FollowsPageAnchors());
|
||||
if (file.Format is FileExportFormat.MARKDOWN)
|
||||
return AppendSources(file.Content, sources);
|
||||
|
||||
if (sources.Length is 0 || !file.Format.TryToComment(sources, out var comment))
|
||||
return file.Content;
|
||||
|
||||
return $"{file.Content}{Environment.NewLine}{Environment.NewLine}{comment}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts the source list below a Markdown text.
|
||||
/// </summary>
|
||||
/// <param name="markdown">The Markdown text.</param>
|
||||
/// <param name="sources">The source list as SourceExtensions.ToExportMarkdown writes it, or an
|
||||
/// empty string when there are no sources.</param>
|
||||
/// <returns>The text followed by its sources.</returns>
|
||||
private static string AppendSources(string markdown, string sources)
|
||||
{
|
||||
if (sources.Length == 0)
|
||||
return markdown;
|
||||
|
||||
if (markdown.Length == 0)
|
||||
return sources;
|
||||
|
||||
//
|
||||
// The blank line is not cosmetic: it ends a paragraph, a list, a table, or a block quote, so
|
||||
// that the heading of the source list stands on its own instead of being pulled into the
|
||||
// last block of the answer.
|
||||
//
|
||||
markdown = $"{Markdown.CloseOpenCodeFence(answer)}{Environment.NewLine}{Environment.NewLine}{sources}";
|
||||
return true;
|
||||
return $"{Markdown.CloseOpenCodeFence(markdown)}{Environment.NewLine}{Environment.NewLine}{sources}";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user