Merge branch 'main' into feature/token-count-from-usage

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-09-23 17:18:06 +02:00
commit ae10bb4809
69 files changed
+4129 -336

No files matched your search

+42
View File
@@ -350,6 +350,48 @@ public sealed record ChatThread
this.Blocks.Remove(block);
}
/// <summary>
/// Rolls this chat thread back, so that the given content becomes the last block of the conversation.
/// </summary>
/// <remarks>
/// Every later block is removed in conversation order, which is the order of the time stamps and
/// the order in which the chat shows the blocks. That includes the blocks hidden from the user,
/// such as the prompts an assistant sends into a chat: the user can neither see nor remove them,
/// so leaving them behind would continue the chat with messages nobody knows about. Hidden blocks
/// before the content stay, e.g. the example conversation of a chat template. The managed
/// transcripts of the removed blocks are deleted with them.<br/><br/>
///
/// The augmented data and the AI-selected data sources are reset, too. Both describe the last
/// retrieval, not a certain message, so after a rollback nobody knows whether they belong to a
/// kept or to a removed one. The next message with active data sources retrieves anew; without
/// active data sources, the chat continues without this context. The data source options stay,
/// because they are the user's choice rather than the result of a message.<br/><br/>
///
/// What stays as well is everything the thread ratchets for security reasons, namely the data
/// security and the required provider confidence. Both only ever tighten, because the data which
/// raised them was seen by this thread. Removing the message that brought it in does not unsee
/// it, so the chat keeps demanding the same of every provider which continues it.
/// </remarks>
/// <param name="content">The content to keep as the last block.</param>
/// <returns>True when one or more later blocks were removed. False when the content is unknown or already the last block; the thread stays unchanged then.</returns>
public bool RollBackTo(IContent content)
{
var sortedBlocks = this.Blocks.OrderBy(x => x.Time).ToList();
var blockIndex = sortedBlocks.FindIndex(block => ReferenceEquals(block.Content, content));
if (blockIndex < 0 || blockIndex == sortedBlocks.Count - 1)
return false;
foreach (var block in sortedBlocks.Skip(blockIndex + 1))
{
DeleteManagedAttachments(block);
this.Blocks.Remove(block);
}
this.AugmentedData = string.Empty;
this.AISelectedDataSources = [];
return true;
}
private static void DeleteManagedAttachments(ContentBlock block)
{
if (block.Content is not ContentText textContent)
@@ -70,6 +70,12 @@
<MudIconButton Icon="@Icons.Material.Filled.Recycling" Color="Color.Default" Disabled="@(!this.RegenerateEnabled())" OnClick="@this.RegenerateBlock"/>
</MudTooltip>
}
@if (!this.IsLastContentBlock && this.Role is ChatRole.AI && this.RollbackFunc is not null)
{
<MudTooltip Text="@T("Roll back to this response")" Placement="Placement.Bottom">
<MudIconButton Icon="@Icons.Material.Filled.Restore" Color="Color.Default" Disabled="@(!this.RollbackEnabled())" OnClick="@this.RollbackBlock"/>
</MudTooltip>
}
@if (this.RemoveBlockFunc is not null)
{
<MudTooltip Text="@T("Removes this block")" Placement="Placement.Bottom">
@@ -85,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/>
@@ -76,6 +76,9 @@ public partial class ContentBlockComponent : MSGComponentBase
[Parameter]
public Func<IContent, Task>? RegenerateFunc { get; set; }
[Parameter]
public Func<IContent, Task>? RollbackFunc { get; set; }
[Parameter]
public Func<IContent, Task>? EditLastBlockFunc { get; set; }
@@ -86,6 +89,9 @@ public partial class ContentBlockComponent : MSGComponentBase
[Parameter]
public Func<bool> RegenerateEnabled { get; set; } = () => false;
[Parameter]
public Func<bool> RollbackEnabled { 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.
@@ -98,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!;
@@ -119,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;
@@ -141,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>
@@ -215,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);
}
@@ -732,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)
{
@@ -743,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);
}
}
@@ -780,6 +808,21 @@ public partial class ContentBlockComponent : MSGComponentBase
if (regenerate.HasValue && regenerate.Value)
await this.RegenerateFunc(this.Content);
}
private async Task RollbackBlock()
{
if (this.RollbackFunc is null || this.Role is not ChatRole.AI || !this.RollbackEnabled())
return;
var rollback = await this.DialogService.ShowMessageBox(
T("Roll Back Chat"),
T("Do you really want to roll back this chat to this AI response? All later messages and their attachments will be permanently removed."),
T("Yes, roll back the chat"),
T("No, keep it"));
if (rollback.HasValue && rollback.Value)
await this.RollbackFunc(this.Content);
}
private async Task EditLastBlock()
{
@@ -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}";
}
}