mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-12 20:53:48 +00:00
Rework of the file export (#894)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
f14c69b938
commit
c66a61713d
33 files changed
+1212
-266
No files matched your search
@@ -84,6 +84,19 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
|
||||
[Parameter]
|
||||
public Func<bool> RegenerateEnabled { get; set; } = () => false;
|
||||
|
||||
/// <summary>
|
||||
/// What the export offers, used both as the label of the export button and as the title of
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public string? ExportTitle { get; set; }
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
@@ -94,22 +107,113 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
[Inject]
|
||||
private IJSRuntime JsRuntime { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<ContentBlockComponent> Logger { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private PandocAvailabilityService PandocAvailability { get; init; } = null!;
|
||||
|
||||
private bool HideContent { get; set; }
|
||||
private bool hasRenderHash;
|
||||
private int lastRenderHash;
|
||||
private string cachedMarkdownRenderPlanInput = string.Empty;
|
||||
private MarkdownRenderPlan cachedMarkdownRenderPlan = MarkdownRenderPlan.EMPTY;
|
||||
private string cachedMessageTablesInput = string.Empty;
|
||||
private IReadOnlyList<MessageTable> cachedMessageTables = [];
|
||||
private char csvSeparator = ',';
|
||||
private ElementReference mathContentContainer;
|
||||
private string lastMathRenderSignature = string.Empty;
|
||||
private bool hasActiveMathContainer;
|
||||
private bool isDisposed;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this block can be exported.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// We wait for the stream to finish: half an answer is nothing anybody wants in a document,
|
||||
/// and waiting keeps us from searching for a text which still grows with every token. Only text
|
||||
/// can be completely exported; an image, for example, has no representation our formats could write.
|
||||
/// </remarks>
|
||||
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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Cached the same way the Markdown render plan is: reading the tables 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
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!this.Content.TryGetMarkdownText(out var markdown))
|
||||
return [];
|
||||
|
||||
if (ReferenceEquals(this.cachedMessageTablesInput, markdown) || string.Equals(this.cachedMessageTablesInput, markdown, StringComparison.Ordinal))
|
||||
return this.cachedMessageTables;
|
||||
|
||||
this.cachedMessageTablesInput = markdown;
|
||||
this.cachedMessageTables = PlainFileExport.ExtractTables(markdown, this.csvSeparator);
|
||||
return this.cachedMessageTables;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Names one table 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.
|
||||
/// </remarks>
|
||||
private string ExportLabel(MessageTable table)
|
||||
{
|
||||
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));
|
||||
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
return captionIsTelling
|
||||
? $"{table.Caption} ({table.Format.ToFileExtension()})"
|
||||
: string.Format(this.T("Table {0} ({1})"), table.Ordinal, table.Format.ToFileExtension());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the export offers, falling back to the chat wording when nobody named it.
|
||||
/// </summary>
|
||||
private string EffectiveExportTitle => this.ExportTitle ?? this.T("Export AI response");
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.RegisterStreamingEvents();
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
//
|
||||
// Which separator a CSV needs depends on the language, and asking for the language means
|
||||
// waiting for the settings. The first render therefore uses the comma we start with; once
|
||||
// we know better, we ask for another render. Nobody can have opened the export menu in
|
||||
// between, so no file is ever written with the wrong separator.
|
||||
//
|
||||
var languagePlugin = await this.SettingsManager.GetActiveLanguagePlugin();
|
||||
var separator = CsvWriter.SeparatorFor(languagePlugin.IETFTag);
|
||||
if (separator == this.csvSeparator)
|
||||
return;
|
||||
|
||||
this.csvSeparator = separator;
|
||||
this.cachedMessageTablesInput = string.Empty;
|
||||
this.cachedMessageTables = [];
|
||||
await this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
|
||||
protected override Task OnParametersSetAsync()
|
||||
@@ -543,9 +647,47 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
await this.RemoveBlockFunc(this.Content);
|
||||
}
|
||||
|
||||
private async Task ExportToWord()
|
||||
/// <summary>
|
||||
/// Exports the entire message.
|
||||
/// </summary>
|
||||
private async Task ExportDocument(FileExportFormat format)
|
||||
{
|
||||
await PandocExport.ToMicrosoftWord(this.RustService, this.DialogService, T("Export Chat to Microsoft Word"), this.Content);
|
||||
try
|
||||
{
|
||||
//
|
||||
// The format itself knows who writes it, so we do not have to keep a list of formats
|
||||
// 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);
|
||||
else if (this.Content.TryGetMarkdownText(out var markdown))
|
||||
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, markdown);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException e)
|
||||
{
|
||||
await this.ReportUnknownExportFormat(e, format);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports one table out of the message, exactly as the menu offered it.
|
||||
/// </summary>
|
||||
private async Task ExportTable(MessageTable table)
|
||||
{
|
||||
try
|
||||
{
|
||||
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, table.Format, table.Content, table.Caption);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException e)
|
||||
{
|
||||
await this.ReportUnknownExportFormat(e, table.Format);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReportUnknownExportFormat(ArgumentOutOfRangeException exception, FileExportFormat format)
|
||||
{
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Error, string.Format(this.T("Failed to export this message, because the file format '{0}' is unknown."), format)));
|
||||
this.Logger.LogError(exception, "Failed to export the content, because no exporter writes the format {ExportFormat}.", format);
|
||||
}
|
||||
|
||||
private async Task RegenerateBlock()
|
||||
@@ -618,4 +760,4 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
|
||||
await this.DisposeMathContainerIfNeededAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user