Improved exports (#993)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Peer HogeterpandThorsten Sommer authored and GitHub committed 2026-09-23 15:02:33 +02:00
1 parent e9ebc02a96
commit fda42ac24b
20 files changed
+883 -101

No files matched your search

@@ -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);
}
}