Locked changes to a chat while an answer is being written (#1058)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-13 18:50:56 +02:00
1 parent 88d1f4d96a
commit fbb65fd72d
19 files changed
+841 -59

No files matched your search

@@ -0,0 +1,105 @@
using System.Diagnostics.CodeAnalysis;
namespace AIStudio.Chat;
/// <summary>
/// Changes the messages of a chat on behalf of the user, but never while an answer is being written for it.
/// </summary>
/// <remarks>
/// While an answer is being written, the AI job holds the very same thread as the chat: it writes into
/// the answer, and it stores the whole conversation every few seconds. A message the user removed in
/// the meantime does not come back, but the job goes on writing an answer nobody sees, and the chat
/// stays locked until that answer is complete. Removing the answer and sending another message would
/// leave two answers being written for one chat. Changing older messages is no better: the job stores
/// the conversation on another thread and could read it in the middle of the change.<br/><br/>
///
/// That is why every message is locked while an answer is being written, not only the answer itself.
/// Stopping the answer unlocks the chat right away.
/// </remarks>
/// <param name="isAnswerBeingWritten">Whether an answer is being written for the chat with the given ID right now.</param>
public sealed class ChatBlockActions(Func<Guid, bool> isAnswerBeingWritten)
{
/// <summary>
/// Whether the user may change the messages of the given chat right now.
/// </summary>
/// <param name="chat">The chat, or null when there is none yet.</param>
/// <returns>True when there is a chat and no answer is being written for it.</returns>
public bool AreAllowed([NotNullWhen(true)] ChatThread? chat) => chat is not null && !isAnswerBeingWritten(chat.ChatId);
/// <summary>
/// Removes a message, together with the hidden prompt an answer was written for.
/// </summary>
/// <param name="chat">The chat.</param>
/// <param name="content">The content of the message to remove.</param>
/// <returns>True when the message was removed.</returns>
public bool TryRemove([NotNullWhen(true)] ChatThread? chat, IContent content)
{
if (!this.AreAllowed(chat) || !Holds(chat, content))
return false;
chat.Remove(content);
return true;
}
/// <summary>
/// Removes an answer so that it can be written anew for the same question.
/// </summary>
/// <param name="chat">The chat.</param>
/// <param name="answer">The content of the answer to remove.</param>
/// <returns>True when the answer was removed.</returns>
public bool TryRemoveForRegenerate([NotNullWhen(true)] ChatThread? chat, IContent answer)
{
if (!this.AreAllowed(chat) || !Holds(chat, answer))
return false;
chat.Remove(answer, removeForRegenerate: true);
return true;
}
/// <summary>
/// Rolls the chat back, so that the given answer becomes its last message.
/// </summary>
/// <remarks>
/// What the rollback removes and what it keeps is documented at ChatThread.RollBackTo.
/// </remarks>
/// <param name="chat">The chat.</param>
/// <param name="answer">The content of the answer to keep as the last message.</param>
/// <returns>True when later messages were removed.</returns>
public bool TryRollBack([NotNullWhen(true)] ChatThread? chat, IContent answer) => this.AreAllowed(chat) && chat.RollBackTo(answer);
/// <summary>
/// Makes the last question the draft again, and removes the answer to it.
/// </summary>
/// <param name="chat">The chat.</param>
/// <param name="question">The content of the question to edit.</param>
/// <returns>True when the question became the draft.</returns>
public bool TryEditLastUserBlock([NotNullWhen(true)] ChatThread? chat, IContent question)
{
if (!this.AreAllowed(chat) || question is not ContentText || !Holds(chat, question))
return false;
var lastContent = chat.Blocks.Last().Content;
chat.RemoveForEditing(question);
if (lastContent is not null)
chat.Remove(lastContent);
return true;
}
/// <summary>
/// Makes a question without an answer the draft again.
/// </summary>
/// <param name="chat">The chat.</param>
/// <param name="question">The content of the question to edit.</param>
/// <returns>True when the question became the draft.</returns>
public bool TryEditLastBlock([NotNullWhen(true)] ChatThread? chat, IContent question)
{
if (!this.AreAllowed(chat) || question is not ContentText || !Holds(chat, question))
return false;
chat.RemoveForEditing(question);
return true;
}
private static bool Holds(ChatThread chat, IContent content) => chat.Blocks.Any(block => block.Content == content);
}
@@ -55,31 +55,31 @@
@if (this.IsSecondToLastBlock && this.Role is ChatRole.USER && this.EditLastUserBlockFunc is not null)
{
<MudTooltip Text="@T("Edit")" Placement="Placement.Bottom">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Default" OnClick="@this.EditLastUserBlock"/>
<MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Default" Disabled="@(!this.BlockActionsEnabled())" OnClick="@this.EditLastUserBlock"/>
</MudTooltip>
}
@if (this.IsLastContentBlock && this.Role is ChatRole.USER && this.EditLastBlockFunc is not null)
{
<MudTooltip Text="@T("Edit")" Placement="Placement.Bottom">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Default" OnClick="@this.EditLastBlock"/>
<MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Default" Disabled="@(!this.BlockActionsEnabled())" OnClick="@this.EditLastBlock"/>
</MudTooltip>
}
@if (this.IsLastContentBlock && this.Role is ChatRole.AI && this.RegenerateFunc is not null)
{
<MudTooltip Text="@T("Regenerate")" Placement="Placement.Bottom">
<MudIconButton Icon="@Icons.Material.Filled.Recycling" Color="Color.Default" Disabled="@(!this.RegenerateEnabled())" OnClick="@this.RegenerateBlock"/>
<MudIconButton Icon="@Icons.Material.Filled.Recycling" Color="Color.Default" Disabled="@(!this.BlockActionsEnabled() || !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"/>
<MudIconButton Icon="@Icons.Material.Filled.Restore" Color="Color.Default" Disabled="@(!this.BlockActionsEnabled())" OnClick="@this.RollbackBlock"/>
</MudTooltip>
}
@if (this.RemoveBlockFunc is not null)
{
<MudTooltip Text="@T("Removes this block")" Placement="Placement.Bottom">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="@this.RemoveBlock"/>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Disabled="@(!this.BlockActionsEnabled())" OnClick="@this.RemoveBlock"/>
</MudTooltip>
}
@@ -89,8 +89,15 @@ public partial class ContentBlockComponent : MSGComponentBase
[Parameter]
public Func<bool> RegenerateEnabled { get; set; } = () => false;
/// <summary>
/// Whether the user may change the messages of the chat right now, i.e., remove, edit, regenerate, or roll back.
/// </summary>
/// <remarks>
/// The chat answers false while an answer is being written for it, since every change to a message
/// would collide with the answer. Whoever passes none of these actions needs no answer here.
/// </remarks>
[Parameter]
public Func<bool> RollbackEnabled { get; set; } = () => false;
public Func<bool> BlockActionsEnabled { get; set; } = () => false;
/// <summary>
/// What the export offers, used both as the label of the export button and as the title of
@@ -321,6 +328,7 @@ public partial class ContentBlockComponent : MSGComponentBase
hash.Add(this.HideContent);
hash.Add(this.SettingsManager.IsDarkMode);
hash.Add(this.RegenerateEnabled());
hash.Add(this.BlockActionsEnabled());
hash.Add(this.Content.InitialRemoteWait);
hash.Add(this.Content.IsStreaming);
hash.Add(this.Content.FileAttachments.Count);
@@ -733,7 +741,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private async Task RemoveBlock()
{
if (this.RemoveBlockFunc is null)
if (this.RemoveBlockFunc is null || !this.BlockActionsEnabled())
return;
var remove = await this.DialogService.ShowMessageBox(
@@ -793,7 +801,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private async Task RegenerateBlock()
{
if (this.RegenerateFunc is null)
if (this.RegenerateFunc is null || !this.BlockActionsEnabled())
return;
if(this.Role is not ChatRole.AI)
@@ -811,7 +819,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private async Task RollbackBlock()
{
if (this.RollbackFunc is null || this.Role is not ChatRole.AI || !this.RollbackEnabled())
if (this.RollbackFunc is null || this.Role is not ChatRole.AI || !this.BlockActionsEnabled())
return;
var rollback = await this.DialogService.ShowMessageBox(
@@ -826,7 +834,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private async Task EditLastBlock()
{
if (this.EditLastBlockFunc is null)
if (this.EditLastBlockFunc is null || !this.BlockActionsEnabled())
return;
if(this.Role is not ChatRole.USER)
@@ -837,7 +845,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private async Task EditLastUserBlock()
{
if (this.EditLastUserBlockFunc is null)
if (this.EditLastUserBlockFunc is null || !this.BlockActionsEnabled())
return;
if(this.Role is not ChatRole.USER)