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

@@ -3664,6 +3664,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3619858297"] = "plus {0}
-- Select a provider first -- Select a provider first
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Select a provider first" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Select a provider first"
-- Profile changes are locked while a response is running.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3662116565"] = "Profile changes are locked while a response is running."
-- Data source changes are locked while a response is running. Your current selection is shown below and applies again from the next message once the run is finished.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3682097866"] = "Data source changes are locked while a response is running. Your current selection is shown below and applies again from the next message once the run is finished."
-- Start new chat in workspace '{0}' -- Start new chat in workspace '{0}'
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Start new chat in workspace '{0}'" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Start new chat in workspace '{0}'"
@@ -5524,6 +5530,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3672981145"] = "Add Workspac
-- Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost. -- Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3699436634"] = "Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3699436634"] = "Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost."
-- The workspace '{0}' cannot be deleted while one of its chats is being answered or transcribed. Please try again once that is finished.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3765015122"] = "The workspace '{0}' cannot be deleted while one of its chats is being answered or transcribed. Please try again once that is finished."
-- Chat Name -- Chat Name
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3891063690"] = "Chat Name" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3891063690"] = "Chat Name"
@@ -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) @if (this.IsSecondToLastBlock && this.Role is ChatRole.USER && this.EditLastUserBlockFunc is not null)
{ {
<MudTooltip Text="@T("Edit")" Placement="Placement.Bottom"> <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> </MudTooltip>
} }
@if (this.IsLastContentBlock && this.Role is ChatRole.USER && this.EditLastBlockFunc is not null) @if (this.IsLastContentBlock && this.Role is ChatRole.USER && this.EditLastBlockFunc is not null)
{ {
<MudTooltip Text="@T("Edit")" Placement="Placement.Bottom"> <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> </MudTooltip>
} }
@if (this.IsLastContentBlock && this.Role is ChatRole.AI && this.RegenerateFunc is not null) @if (this.IsLastContentBlock && this.Role is ChatRole.AI && this.RegenerateFunc is not null)
{ {
<MudTooltip Text="@T("Regenerate")" Placement="Placement.Bottom"> <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> </MudTooltip>
} }
@if (!this.IsLastContentBlock && this.Role is ChatRole.AI && this.RollbackFunc is not null) @if (!this.IsLastContentBlock && this.Role is ChatRole.AI && this.RollbackFunc is not null)
{ {
<MudTooltip Text="@T("Roll back to this response")" Placement="Placement.Bottom"> <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> </MudTooltip>
} }
@if (this.RemoveBlockFunc is not null) @if (this.RemoveBlockFunc is not null)
{ {
<MudTooltip Text="@T("Removes this block")" Placement="Placement.Bottom"> <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> </MudTooltip>
} }
@@ -89,8 +89,15 @@ public partial class ContentBlockComponent : MSGComponentBase
[Parameter] [Parameter]
public Func<bool> RegenerateEnabled { get; set; } = () => false; 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] [Parameter]
public Func<bool> RollbackEnabled { get; set; } = () => false; public Func<bool> BlockActionsEnabled { get; set; } = () => false;
/// <summary> /// <summary>
/// What the export offers, used both as the label of the export button and as the title of /// 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.HideContent);
hash.Add(this.SettingsManager.IsDarkMode); hash.Add(this.SettingsManager.IsDarkMode);
hash.Add(this.RegenerateEnabled()); hash.Add(this.RegenerateEnabled());
hash.Add(this.BlockActionsEnabled());
hash.Add(this.Content.InitialRemoteWait); hash.Add(this.Content.InitialRemoteWait);
hash.Add(this.Content.IsStreaming); hash.Add(this.Content.IsStreaming);
hash.Add(this.Content.FileAttachments.Count); hash.Add(this.Content.FileAttachments.Count);
@@ -733,7 +741,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private async Task RemoveBlock() private async Task RemoveBlock()
{ {
if (this.RemoveBlockFunc is null) if (this.RemoveBlockFunc is null || !this.BlockActionsEnabled())
return; return;
var remove = await this.DialogService.ShowMessageBox( var remove = await this.DialogService.ShowMessageBox(
@@ -793,7 +801,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private async Task RegenerateBlock() private async Task RegenerateBlock()
{ {
if (this.RegenerateFunc is null) if (this.RegenerateFunc is null || !this.BlockActionsEnabled())
return; return;
if(this.Role is not ChatRole.AI) if(this.Role is not ChatRole.AI)
@@ -811,7 +819,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private async Task RollbackBlock() 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; return;
var rollback = await this.DialogService.ShowMessageBox( var rollback = await this.DialogService.ShowMessageBox(
@@ -826,7 +834,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private async Task EditLastBlock() private async Task EditLastBlock()
{ {
if (this.EditLastBlockFunc is null) if (this.EditLastBlockFunc is null || !this.BlockActionsEnabled())
return; return;
if(this.Role is not ChatRole.USER) if(this.Role is not ChatRole.USER)
@@ -837,7 +845,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private async Task EditLastUserBlock() private async Task EditLastUserBlock()
{ {
if (this.EditLastUserBlockFunc is null) if (this.EditLastUserBlockFunc is null || !this.BlockActionsEnabled())
return; return;
if(this.Role is not ChatRole.USER) if(this.Role is not ChatRole.USER)
@@ -28,7 +28,7 @@
RegenerateFunc="@this.RegenerateBlock" RegenerateFunc="@this.RegenerateBlock"
RegenerateEnabled="@(() => this.IsProviderSelected && this.ChatThread.IsLLMProviderAllowed(this.Provider))" RegenerateEnabled="@(() => this.IsProviderSelected && this.ChatThread.IsLLMProviderAllowed(this.Provider))"
RollbackFunc="@this.RollbackBlock" RollbackFunc="@this.RollbackBlock"
RollbackEnabled="@(() => !this.IsCurrentChatStreaming)" BlockActionsEnabled="@(() => this.blockActions.AreAllowed(this.ChatThread))"
EditLastBlockFunc="@this.EditLastBlock" EditLastBlockFunc="@this.EditLastBlock"
EditLastUserBlockFunc="@this.EditLastUserBlock"/> EditLastUserBlockFunc="@this.EditLastUserBlock"/>
} }
@@ -88,7 +88,7 @@
</MudTooltip> </MudTooltip>
} }
<ChatTemplateSelection MarginLeft="" CanChatThreadBeUsedForTemplate="@this.CanThreadBeSaved" CurrentChatThread="@this.ChatThread" CurrentChatTemplate="@this.currentChatTemplate" CurrentChatTemplateChanged="@this.ChatTemplateWasChanged"/> <ChatTemplateSelection MarginLeft="" CanChatThreadBeUsedForTemplate="@this.CanThreadBecomeTemplate" CurrentChatThread="@this.ChatThread" CurrentChatTemplate="@this.currentChatTemplate" CurrentChatTemplateChanged="@this.ChatTemplateWasChanged"/>
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) @if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
@@ -109,7 +109,7 @@
</MudTooltip> </MudTooltip>
} }
<AttachDocuments Name="File Attachments" DocumentPaths="@this.ComposerState.FileAttachments" DocumentPathsChanged="@this.ComposerAttachmentsChanged" CatchAllDocuments="true" UseSmallForm="true" ShowMediaStatus="false" Provider="@this.Provider" OwnerChat="@this.ChatThread" EnsureOwnerChatAsync="@this.EnsureMediaImportChatAsync" Disabled="@this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner)"/> <AttachDocuments Name="File Attachments" DocumentPaths="@this.ComposerState.FileAttachments" DocumentPathsChanged="@this.ComposerAttachmentsChanged" CatchAllDocuments="true" UseSmallForm="true" ShowMediaStatus="false" Provider="@this.Provider" OwnerChat="@this.ChatThread" EnsureOwnerChatAsync="@this.EnsureMediaImportChatAsync" Disabled="@(!this.CanAttachFiles)"/>
<MudDivider Vertical="true" Style="height: 24px; align-self: center;"/> <MudDivider Vertical="true" Style="height: 24px; align-self: center;"/>
@@ -131,7 +131,7 @@
<MudDivider Vertical="true" Style="height: 24px; align-self: center;"/> <MudDivider Vertical="true" Style="height: 24px; align-self: center;"/>
<ProfileSelection MarginLeft="" CurrentProfile="@this.currentProfile" CurrentProfileChanged="@this.ProfileWasChanged" Disabled="@(!this.currentChatTemplate.AllowProfileUsage)" DisabledText="@T("Profile usage is disabled according to your chat template settings.")"/> <ProfileSelection MarginLeft="" CurrentProfile="@this.currentProfile" CurrentProfileChanged="@this.ProfileWasChanged" Disabled="@(!this.CanProfileBeChanged)" DisabledText="@this.ProfileDisabledText"/>
@if (this.SettingsManager.AreToolsEnabled()) @if (this.SettingsManager.AreToolsEnabled())
{ {
@@ -140,7 +140,7 @@
@if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager)) @if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager))
{ {
<DataSourceSelection @ref="@this.dataSourceSelectionComponent" PopoverTriggerMode="PopoverTriggerMode.ICON" LLMProvider="@this.Provider" DataSourceOptions="@this.GetCurrentDataSourceOptions()" DataSourceOptionsChanged="@(async options => await this.SetCurrentDataSourceOptions(options))" DataSourcesAISelected="@this.GetAgentSelectedDataSources()"/> <DataSourceSelection @ref="@this.dataSourceSelectionComponent" PopoverTriggerMode="PopoverTriggerMode.ICON" LLMProvider="@this.Provider" DataSourceOptions="@this.GetCurrentDataSourceOptions()" DataSourceOptionsChanged="@this.DataSourceOptionsWereChanged" DataSourcesAISelected="@this.GetAgentSelectedDataSources()" ReadOnly="@this.IsCurrentChatStreaming" ReadOnlyMessage="@T("Data source changes are locked while a response is running. Your current selection is shown below and applies again from the next message once the run is finished.")"/>
} }
@if (this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence) @if (this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence)
@@ -39,6 +39,17 @@ public partial class ChatComponent : MSGComponentBase
[Parameter] [Parameter]
public EventCallback<AIStudio.Settings.Provider> ProviderChanged { get; set; } public EventCallback<AIStudio.Settings.Provider> ProviderChanged { get; set; }
/// <summary>
/// Tells whether an answer is being written for the open chat, whenever that changes.
/// </summary>
/// <remarks>
/// For the parts of the chat page outside this component, such as the provider selection. The AI
/// job reports every part of an answer, and this component renders after each report. The page
/// learns only when an answer starts or ends, see OnAfterRenderAsync.
/// </remarks>
[Parameter]
public EventCallback<bool> IsAnswerBeingWrittenChanged { get; set; }
[Parameter] [Parameter]
public Action<string> WorkspaceName { get; set; } = _ => { }; public Action<string> WorkspaceName { get; set; } = _ => { };
@@ -91,6 +102,7 @@ public partial class ChatComponent : MSGComponentBase
private bool autoSaveEnabled; private bool autoSaveEnabled;
private HashSet<string> selectedToolIds = []; private HashSet<string> selectedToolIds = [];
private bool previousInputForbidden = true; private bool previousInputForbidden = true;
private bool announcedAnswerBeingWritten;
private Guid lastSeenChatId = Guid.Empty; private Guid lastSeenChatId = Guid.Empty;
private AIStudio.Settings.Provider lastSeenProvider = AIStudio.Settings.Provider.NONE; private AIStudio.Settings.Provider lastSeenProvider = AIStudio.Settings.Provider.NONE;
private string currentWorkspaceName = string.Empty; private string currentWorkspaceName = string.Empty;
@@ -154,6 +166,11 @@ public partial class ChatComponent : MSGComponentBase
/// </summary> /// </summary>
private ChatDraftAutosave? draftAutosave; private ChatDraftAutosave? draftAutosave;
/// <summary>
/// The only way to change the messages of the open chat, which is locked while an answer is being written.
/// </summary>
private ChatBlockActions blockActions = null!;
/// <summary> /// <summary>
/// A new chat which has not reached the disk yet, or null. /// A new chat which has not reached the disk yet, or null.
/// </summary> /// </summary>
@@ -293,6 +310,9 @@ public partial class ChatComponent : MSGComponentBase
{ {
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged; this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
// Created before anything is awaited, because the first render may happen there already:
this.blockActions = new(chatId => this.AIJobService.IsChatGenerationActive(chatId));
// //
// Leaving the chat page takes the composer along. Registered before anything is awaited, so // Leaving the chat page takes the composer along. Registered before anything is awaited, so
// that a component which goes away while it starts can never leave its handler behind: // that a component which goes away while it starts can never leave its handler behind:
@@ -568,6 +588,20 @@ public partial class ChatComponent : MSGComponentBase
this.previousInputForbidden = inputForbidden; this.previousInputForbidden = inputForbidden;
//
// The page hears about an answer starting or ending here rather than with every report of the
// AI job, since a render of the page redraws the workspace tree. This component renders after
// every such report and after another chat was opened, so the page never misses a change. The
// first render reports in any case: the page may still hold what another instance of this
// component told it, e.g., before the workspaces were shown or hidden.
//
var isAnswerBeingWritten = this.IsCurrentChatStreaming;
if (firstRender || isAnswerBeingWritten != this.announcedAnswerBeingWritten)
{
this.announcedAnswerBeingWritten = isAnswerBeingWritten;
await this.IsAnswerBeingWrittenChanged.InvokeAsync(isAnswerBeingWritten);
}
// //
// Everything which can move the token count also renders this component: the selections in // Everything which can move the token count also renders this component: the selections in
// the toolbar, the attachments and the composer all travel through an event callback whose // the toolbar, the attachments and the composer all travel through an event callback whose
@@ -755,6 +789,35 @@ public partial class ChatComponent : MSGComponentBase
private bool CanThreadBeCopied => this.CanThreadBeSaved && !this.IsCurrentChatStreaming && !this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner); private bool CanThreadBeCopied => this.CanThreadBeSaved && !this.IsCurrentChatStreaming && !this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner);
/// <summary>
/// Whether a chat template can be made from the open chat. While an answer is being written, the template would copy it unfinished.
/// </summary>
private bool CanThreadBecomeTemplate => this.CanThreadBeSaved && !this.IsCurrentChatStreaming;
/// <summary>
/// Whether the user may pick another profile, which the open chat carries from then on.
/// </summary>
/// <remarks>
/// Picking a profile replaces the thread with a copy which carries it. While an answer is being
/// written, the next update of the AI job puts the thread of the job back in place of that copy,
/// and the choice is lost.
/// </remarks>
private bool CanProfileBeChanged => this.currentChatTemplate.AllowProfileUsage && !this.IsCurrentChatStreaming;
/// <summary>
/// Whether the user may change the attachments of the message being written.
/// </summary>
/// <remarks>
/// Not while an answer is being written, just like the text of the message, and not while the
/// audio or video files of the chat are being transcribed.
/// </remarks>
private bool CanAttachFiles => !this.IsCurrentChatStreaming && !this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner);
// The chat template locks the profile for good, an answer being written only until it is complete:
private string ProfileDisabledText => this.currentChatTemplate.AllowProfileUsage
? T("Profile changes are locked while a response is running.")
: T("Profile usage is disabled according to your chat template settings.");
/// <summary> /// <summary>
/// Whether the open chat can move into a workspace, which is exactly when the workspace tree offers it. /// Whether the open chat can move into a workspace, which is exactly when the workspace tree offers it.
/// </summary> /// </summary>
@@ -916,6 +979,9 @@ public partial class ChatComponent : MSGComponentBase
private async Task ProfileWasChanged(Profile profile) private async Task ProfileWasChanged(Profile profile)
{ {
if (!this.CanProfileBeChanged)
return;
this.currentProfile = this.SettingsManager.GetProfileById(profile.Id); this.currentProfile = this.SettingsManager.GetProfileById(profile.Id);
// //
@@ -1014,6 +1080,22 @@ public partial class ChatComponent : MSGComponentBase
return this.earlyDataSourceOptions; return this.earlyDataSourceOptions;
} }
/// <summary>
/// Takes over what the user changed in the data source selection.
/// </summary>
/// <remarks>
/// While an answer is being written, the selection is read-only and turns every change down
/// itself, since the answer reads the very options it shows. Checked here once more, so that the
/// chat never stores a change which reached it anyway.
/// </remarks>
private async Task DataSourceOptionsWereChanged(DataSourceOptions updatedOptions)
{
if (this.IsCurrentChatStreaming)
return;
await this.SetCurrentDataSourceOptions(updatedOptions);
}
private async Task SetCurrentDataSourceOptions(DataSourceOptions updatedOptions) private async Task SetCurrentDataSourceOptions(DataSourceOptions updatedOptions)
{ {
@@ -1152,7 +1234,8 @@ public partial class ChatComponent : MSGComponentBase
/// message lives in the composer only. It becomes a disappearing chat of its own then. Starting a /// message lives in the composer only. It becomes a disappearing chat of its own then. Starting a
/// new chat in a workspace creates the thread right away instead, see unstoredChat. That one is /// new chat in a workspace creates the thread right away instead, see unstoredChat. That one is
/// stored with whatever its draft holds, and also while a transcript is still being made for it, /// stored with whatever its draft holds, and also while a transcript is still being made for it,
/// since the transcript would otherwise arrive in a chat which is nowhere.<br/><br/> /// since the transcript would otherwise arrive in a chat which is nowhere. Only when its workspace
/// was deleted meanwhile, the chat goes along with it.<br/><br/>
/// ///
/// The composer is cleared before the chat is stored, since the message has moved into its chat. /// The composer is cleared before the chat is stored, since the message has moved into its chat.
/// Leaving twice in a row, e.g., with two quick clicks, therefore stores it only once. /// Leaving twice in a row, e.g., with two quick clicks, therefore stores it only once.
@@ -1179,7 +1262,13 @@ public partial class ChatComponent : MSGComponentBase
this.ComposerState.Clear(); this.ComposerState.Clear();
} }
else if (unstoredChatBeingLeft is not null && (!unstoredChatBeingLeft.Draft.IsEmpty || this.MediaTranscriptionService.IsBusy(MediaImportOwner.ForChat(unstoredChatBeingLeft.ChatId)))) else if (unstoredChatBeingLeft is not null && (!unstoredChatBeingLeft.Draft.IsEmpty || this.MediaTranscriptionService.IsBusy(MediaImportOwner.ForChat(unstoredChatBeingLeft.ChatId))))
{
// Deleting the workspace took the chat along. Storing it would bring the workspace back, as an unnamed one:
if (!WorkspaceBehaviour.IsWorkspaceExisting(unstoredChatBeingLeft.WorkspaceId))
return;
chat = unstoredChatBeingLeft; chat = unstoredChatBeingLeft;
}
else else
return; return;
@@ -1261,6 +1350,14 @@ public partial class ChatComponent : MSGComponentBase
private async Task SendMessage(bool reuseLastUserPrompt = false) private async Task SendMessage(bool reuseLastUserPrompt = false)
{ {
//
// One answer at a time per chat. Checked before anything touches the chat or the composer:
// the AI job service would turn a second answer down only after the chat got its message and
// an empty answer, which then stays.
//
if (this.IsCurrentChatStreaming)
return;
if (this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner)) if (this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner))
return; return;
@@ -1794,10 +1891,9 @@ public partial class ChatComponent : MSGComponentBase
private async Task RemoveBlock(IContent block) private async Task RemoveBlock(IContent block)
{ {
if(this.ChatThread is null) if (!this.blockActions.TryRemove(this.ChatThread, block))
return; return;
this.ChatThread.Remove(block);
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
await this.SaveThread(); await this.SaveThread();
this.StateHasChanged(); this.StateHasChanged();
@@ -1805,13 +1901,12 @@ public partial class ChatComponent : MSGComponentBase
private async Task RegenerateBlock(IContent aiBlock) private async Task RegenerateBlock(IContent aiBlock)
{ {
if(this.ChatThread is null)
return;
if(!this.ChatThread.IsLLMProviderAllowed(this.Provider)) if(!this.ChatThread.IsLLMProviderAllowed(this.Provider))
return; return;
this.ChatThread.Remove(aiBlock, removeForRegenerate: true); if (!this.blockActions.TryRemoveForRegenerate(this.ChatThread, aiBlock))
return;
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
this.StateHasChanged(); this.StateHasChanged();
@@ -1820,11 +1915,8 @@ public partial class ChatComponent : MSGComponentBase
private async Task RollbackBlock(IContent aiBlock) private async Task RollbackBlock(IContent aiBlock)
{ {
if (this.ChatThread is null || this.IsCurrentChatStreaming)
return;
// Which parts of the thread a rollback resets and which it keeps is documented at RollBackTo: // Which parts of the thread a rollback resets and which it keeps is documented at RollBackTo:
if (!this.ChatThread.RollBackTo(aiBlock)) if (!this.blockActions.TryRollBack(this.ChatThread, aiBlock))
return; return;
// The rollback reset the AI-selected data sources, which the data source selection still shows: // The rollback reset the AI-selected data sources, which the data source selection still shows:
@@ -1838,19 +1930,9 @@ public partial class ChatComponent : MSGComponentBase
private Task EditLastUserBlock(IContent block) private Task EditLastUserBlock(IContent block)
{ {
if(this.ChatThread is null) if (!this.blockActions.TryEditLastUserBlock(this.ChatThread, block))
return Task.CompletedTask; return Task.CompletedTask;
if (block is not ContentText)
return Task.CompletedTask;
var lastBlock = this.ChatThread.Blocks.Last();
var lastBlockContent = lastBlock.Content;
if(lastBlockContent is null)
return Task.CompletedTask;
this.ChatThread.RemoveForEditing(block);
this.ChatThread.Remove(lastBlockContent);
this.RestoreComposerForEditing(); this.RestoreComposerForEditing();
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
this.StateHasChanged(); this.StateHasChanged();
@@ -1859,13 +1941,9 @@ public partial class ChatComponent : MSGComponentBase
private Task EditLastBlock(IContent block) private Task EditLastBlock(IContent block)
{ {
if(this.ChatThread is null) if (!this.blockActions.TryEditLastBlock(this.ChatThread, block))
return Task.CompletedTask; return Task.CompletedTask;
if (block is not ContentText)
return Task.CompletedTask;
this.ChatThread.RemoveForEditing(block);
this.RestoreComposerForEditing(); this.RestoreComposerForEditing();
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
this.StateHasChanged(); this.StateHasChanged();
@@ -64,6 +64,9 @@ public partial class ChatTemplateSelection : MSGComponentBase
private async Task CreateNewChatTemplateFromChat() private async Task CreateNewChatTemplateFromChat()
{ {
if (!this.CanChatThreadBeUsedForTemplate)
return;
var dialogParameters = new DialogParameters<SettingsDialogChatTemplate> var dialogParameters = new DialogParameters<SettingsDialogChatTemplate>
{ {
{ x => x.CreateTemplateFromExistingChatThread, true }, { x => x.CreateTemplateFromExistingChatThread, true },
@@ -57,7 +57,14 @@
} }
else if (this.showDataSourceSelection) else if (this.showDataSourceSelection)
{ {
<MudTextSwitch Label="@T("Are data sources enabled?")" Value="@this.areDataSourcesEnabled" LabelOn="@T("Yes, I want to use data sources.")" LabelOff="@T("No, I don't want to use data sources.")" ValueChanged="@this.EnabledChanged" Dense="@true"/> @if (this.ReadOnly && !string.IsNullOrWhiteSpace(this.ReadOnlyMessage))
{
<MudAlert Dense="@true" Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-2">
@this.ReadOnlyMessage
</MudAlert>
}
<MudTextSwitch Label="@T("Are data sources enabled?")" Value="@this.areDataSourcesEnabled" LabelOn="@T("Yes, I want to use data sources.")" LabelOff="@T("No, I don't want to use data sources.")" ValueChanged="@this.EnabledChanged" Dense="@true" Disabled="@this.ReadOnly"/>
@if (this.areDataSourcesEnabled) @if (this.areDataSourcesEnabled)
{ {
@* Where Semantic Search cannot be used, there is no choice to make, only the reason to tell: *@ @* Where Semantic Search cannot be used, there is no choice to make, only the reason to tell: *@
@@ -69,15 +76,15 @@
} }
else else
{ {
<MudTextSwitch Label="@T("Semantic Search")" Value="@this.IsSemanticSearchPreferred" LabelOn="@T("Yes, the AI searches your data sources itself, whenever a question calls for it (Semantic Search).")" LabelOff="@T("No, AI Studio searches your data sources with every message, before the AI answers (classic RAG).")" ValueChanged="@this.SemanticSearchChanged" Dense="@true"/> <MudTextSwitch Label="@T("Semantic Search")" Value="@this.IsSemanticSearchPreferred" LabelOn="@T("Yes, the AI searches your data sources itself, whenever a question calls for it (Semantic Search).")" LabelOff="@T("No, AI Studio searches your data sources with every message, before the AI answers (classic RAG).")" ValueChanged="@this.SemanticSearchChanged" Dense="@true" Disabled="@this.ReadOnly"/>
} }
<MudTextSwitch Label="@T("AI-based data source selection")" Value="@this.aiBasedSourceSelection" LabelOn="@T("Yes, let the AI decide which data sources are needed.")" LabelOff="@T("No, I manually decide which data source to use.")" ValueChanged="@this.AutoModeChanged" Dense="@true"/> <MudTextSwitch Label="@T("AI-based data source selection")" Value="@this.aiBasedSourceSelection" LabelOn="@T("Yes, let the AI decide which data sources are needed.")" LabelOff="@T("No, I manually decide which data source to use.")" ValueChanged="@this.AutoModeChanged" Dense="@true" Disabled="@this.ReadOnly"/>
@* With Semantic Search, no agent takes part: nothing would validate the retrieved data. *@ @* With Semantic Search, no agent takes part: nothing would validate the retrieved data. *@
@if (this.SettingsManager.ConfigurationData.AgentRetrievalContextValidation.EnableRetrievalContextValidation && !this.IsSemanticSearchEffective) @if (this.SettingsManager.ConfigurationData.AgentRetrievalContextValidation.EnableRetrievalContextValidation && !this.IsSemanticSearchEffective)
{ {
<MudTextSwitch Label="@T("AI-based data validation")" Value="@this.aiBasedValidation" LabelOn="@T("Yes, let the AI validate & filter the retrieved data.")" LabelOff="@T("No, use all data retrieved from the data sources.")" ValueChanged="@this.ValidationModeChanged" Dense="@true"/> <MudTextSwitch Label="@T("AI-based data validation")" Value="@this.aiBasedValidation" LabelOn="@T("Yes, let the AI validate & filter the retrieved data.")" LabelOff="@T("No, use all data retrieved from the data sources.")" ValueChanged="@this.ValidationModeChanged" Dense="@true" Disabled="@this.ReadOnly"/>
} }
@switch (this.aiBasedSourceSelection) @switch (this.aiBasedSourceSelection)
@@ -95,8 +102,8 @@
break; break;
case false: case false:
<MudField Label="@T("Available Data Sources")" Variant="Variant.Outlined" Class="mb-2" Disabled="@this.aiBasedSourceSelection"> <MudField Label="@T("Available Data Sources")" Variant="Variant.Outlined" Class="mb-2" Disabled="@(this.aiBasedSourceSelection || this.ReadOnly)">
<MudList T="IDataSource" Dense="@true" Class="data-source-rows" SelectionMode="@this.GetListSelectionMode()" @bind-SelectedValues:get="@this.selectedDataSources" @bind-SelectedValues:set="@this.SelectionChanged" Style="max-height: 14em;"> <MudList T="IDataSource" Dense="@true" Class="data-source-rows" SelectionMode="@this.GetListSelectionMode()" @bind-SelectedValues:get="@this.selectedDataSources" @bind-SelectedValues:set="@this.SelectionChanged" ReadOnly="@this.ReadOnly" Style="max-height: 14em;">
@foreach (var source in this.GetListedDataSources()) @foreach (var source in this.GetListedDataSources())
{ {
<DataSourceSelectionRow DataSource="@source" BlockReason="@this.GetBlockReason(source)"/> <DataSourceSelectionRow DataSource="@source" BlockReason="@this.GetBlockReason(source)"/>
@@ -45,11 +45,21 @@ public partial class DataSourceSelection : MSGComponentBase
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// For options somebody else decided on, such as those of a chat template an organization /// For options somebody else decided on, such as those of a chat template an organization
/// rolled out. Seeing which data such a chat will search is the point; changing it here is not. /// rolled out. Seeing which data such a chat will search is the point; changing it here is not.<br/><br/>
///
/// Also for options which must not change right now, such as those of a chat while an answer is
/// being written for it: the answer reads them, and they are the very options shown here, so a
/// change would reach it halfway. Every change is turned down before it touches the options.
/// </remarks> /// </remarks>
[Parameter] [Parameter]
public bool ReadOnly { get; set; } public bool ReadOnly { get; set; }
/// <summary>
/// Tells why the options cannot be changed, shown at the top of the popover while they are read-only.
/// </summary>
[Parameter]
public string ReadOnlyMessage { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Whether the options edited here are the data source defaults of the chat. /// Whether the options edited here are the data source defaults of the chat.
/// </summary> /// </summary>
@@ -324,6 +334,9 @@ public partial class DataSourceSelection : MSGComponentBase
private async Task EnabledChanged(bool state) private async Task EnabledChanged(bool state)
{ {
if (this.ReadOnly)
return;
this.areDataSourcesEnabled = state; this.areDataSourcesEnabled = state;
this.DataSourceOptions.DisableDataSources = !this.areDataSourcesEnabled; this.DataSourceOptions.DisableDataSources = !this.areDataSourcesEnabled;
@@ -334,6 +347,9 @@ public partial class DataSourceSelection : MSGComponentBase
private async Task AutoModeChanged(bool state) private async Task AutoModeChanged(bool state)
{ {
if (this.ReadOnly)
return;
this.aiBasedSourceSelection = state; this.aiBasedSourceSelection = state;
this.DataSourceOptions.AutomaticDataSourceSelection = this.aiBasedSourceSelection; this.DataSourceOptions.AutomaticDataSourceSelection = this.aiBasedSourceSelection;
@@ -343,6 +359,9 @@ public partial class DataSourceSelection : MSGComponentBase
private async Task SemanticSearchChanged(bool state) private async Task SemanticSearchChanged(bool state)
{ {
if (this.ReadOnly)
return;
this.retrievalMode = state ? DataSourceRetrievalMode.SEMANTIC_SEARCH : DataSourceRetrievalMode.EVERY_MESSAGE; this.retrievalMode = state ? DataSourceRetrievalMode.SEMANTIC_SEARCH : DataSourceRetrievalMode.EVERY_MESSAGE;
this.DataSourceOptions.RetrievalMode = this.retrievalMode; this.DataSourceOptions.RetrievalMode = this.retrievalMode;
@@ -384,6 +403,9 @@ public partial class DataSourceSelection : MSGComponentBase
private async Task ValidationModeChanged(bool state) private async Task ValidationModeChanged(bool state)
{ {
if (this.ReadOnly)
return;
this.aiBasedValidation = state; this.aiBasedValidation = state;
this.DataSourceOptions.AutomaticValidation = this.aiBasedValidation; this.DataSourceOptions.AutomaticValidation = this.aiBasedValidation;
@@ -393,6 +415,9 @@ public partial class DataSourceSelection : MSGComponentBase
private async Task SelectionChanged(IReadOnlyCollection<IDataSource>? chosenDataSources) private async Task SelectionChanged(IReadOnlyCollection<IDataSource>? chosenDataSources)
{ {
if (this.ReadOnly)
return;
this.selectedDataSources = chosenDataSources ?? []; this.selectedDataSources = chosenDataSources ?? [];
// //
@@ -34,6 +34,10 @@ public partial class ProviderSelection : MSGComponentBase
/// <summary> /// <summary>
/// Gets or sets whether provider selection is disabled. /// Gets or sets whether provider selection is disabled.
/// </summary> /// </summary>
/// <remarks>
/// A choice which reaches a disabled selection anyway is turned down. Keeping the selected provider
/// up to date after its settings changed is no choice, so that goes on.
/// </remarks>
[Parameter] [Parameter]
public bool Disabled { get; set; } public bool Disabled { get; set; }
@@ -55,6 +59,9 @@ public partial class ProviderSelection : MSGComponentBase
private async Task SelectionChanged(AIStudio.Settings.Provider provider) private async Task SelectionChanged(AIStudio.Settings.Provider provider)
{ {
if (this.Disabled)
return;
this.ProviderSettings = provider; this.ProviderSettings = provider;
await this.ProviderSettingsChanged.InvokeAsync(provider); await this.ProviderSettingsChanged.InvokeAsync(provider);
} }
@@ -382,11 +382,15 @@ public partial class Workspaces : MSGComponentBase
private bool IsChatTreeItemBusy(TreeItemData treeItem) private bool IsChatTreeItemBusy(TreeItemData treeItem)
{ {
return treeItem.Type is TreeItemType.CHAT return treeItem.Type is TreeItemType.CHAT && this.IsChatBusy(treeItem.ChatId);
&& (this.AIJobService.IsChatGenerationActive(treeItem.ChatId)
|| this.MediaTranscriptionService.IsBusy(MediaImportOwner.ForChat(treeItem.ChatId)));
} }
/// <summary>
/// Whether an answer is being written for the given chat, or a transcript is being made for it.
/// </summary>
/// <param name="chatId">The chat to check.</param>
private bool IsChatBusy(Guid chatId) => this.AIJobService.IsChatGenerationActive(chatId) || this.MediaTranscriptionService.IsBusy(MediaImportOwner.ForChat(chatId));
private string GetChatTreeItemTextStyle(TreeItemData treeItem) private string GetChatTreeItemTextStyle(TreeItemData treeItem)
{ {
var status = this.MediaTranscriptionService.GetSnapshot(MediaImportOwner.ForChat(treeItem.ChatId))?.Status; var status = this.MediaTranscriptionService.GetSnapshot(MediaImportOwner.ForChat(treeItem.ChatId))?.Status;
@@ -922,6 +926,18 @@ public partial class Workspaces : MSGComponentBase
var workspaceId = Guid.Parse(Path.GetFileName(workspacePath)); var workspaceId = Guid.Parse(Path.GetFileName(workspacePath));
var workspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(workspaceId); var workspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(workspaceId);
//
// Deleting a workspace while one of its chats is being worked on would pull the ground from
// under that work. The button cannot tell, since the chats of a workspace may not be loaded
// yet, so we check before asking: nobody should confirm something that cannot happen anyway.
//
var chatIds = this.ReadChatIdsOfWorkspace(workspaceId);
if (chatIds.Any(this.IsChatBusy))
{
await this.WarnAboutBusyWorkspaceAsync(workspaceName);
return;
}
var chatCount = Directory.EnumerateDirectories(workspacePath).Count(); var chatCount = Directory.EnumerateDirectories(workspacePath).Count();
var dialogParameters = new DialogParameters<ConfirmDialog> var dialogParameters = new DialogParameters<ConfirmDialog>
{ {
@@ -933,10 +949,43 @@ public partial class Workspaces : MSGComponentBase
if (dialogResult is null || dialogResult.Canceled) if (dialogResult is null || dialogResult.Canceled)
return; return;
Directory.Delete(workspacePath, true); if (!await WorkspaceBehaviour.DeleteWorkspaceAsync(workspaceId, this.IsChatBusy))
await WorkspaceBehaviour.RemoveWorkspaceFromCacheAsync(workspaceId); {
await this.WarnAboutBusyWorkspaceAsync(workspaceName);
return;
}
foreach (var chatId in chatIds)
this.MediaTranscriptionService.ClearOwnerState(MediaImportOwner.ForChat(chatId));
await this.LoadTreeItemsAsync(startPrefetch: false); await this.LoadTreeItemsAsync(startPrefetch: false);
// The open chat went along with its workspace. Left on the screen, it would be stored again sooner or later:
if (this.CurrentChatThread?.WorkspaceId == workspaceId)
{
this.CurrentChatThread = null;
await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
}
} }
/// <summary>
/// Reads the chats of the given workspace, together with the open chat when it belongs there.
/// </summary>
/// <remarks>
/// A new chat started in a workspace reaches the disk only with its first message. Until then, it
/// is missing from the workspace on the disk, but it may already wait for a transcript.
/// </remarks>
/// <param name="workspaceId">The workspace to read.</param>
private HashSet<Guid> ReadChatIdsOfWorkspace(Guid workspaceId)
{
var chatIds = WorkspaceBehaviour.ReadChatIdsOfWorkspace(workspaceId).ToHashSet();
if (this.CurrentChatThread is { } openChat && openChat.WorkspaceId == workspaceId)
chatIds.Add(openChat.ChatId);
return chatIds;
}
private Task WarnAboutBusyWorkspaceAsync(string workspaceName) => MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.FolderDelete, string.Format(T("The workspace '{0}' cannot be deleted while one of its chats is being answered or transcribed. Please try again once that is finished."), workspaceName)));
/// <summary> /// <summary>
/// Moves the given chat into another workspace and shows it there in the tree. /// Moves the given chat into another workspace and shows it there in the tree.
+4 -1
View File
@@ -32,7 +32,7 @@
</MudStack> </MudStack>
<CascadingValue Value="Components.CHAT"> <CascadingValue Value="Components.CHAT">
<ProviderSelection @bind-ProviderSettings="@this.providerSettings"/> <ProviderSelection @bind-ProviderSettings="@this.providerSettings" Disabled="@this.isAnswerBeingWritten"/>
</CascadingValue> </CascadingValue>
@if (this.AreWorkspacesVisible) @if (this.AreWorkspacesVisible)
{ {
@@ -97,6 +97,7 @@
<ChatComponent <ChatComponent
@bind-ChatThread="@this.chatThread" @bind-ChatThread="@this.chatThread"
@bind-Provider="@this.providerSettings" @bind-Provider="@this.providerSettings"
IsAnswerBeingWrittenChanged="@(isBeingWritten => this.isAnswerBeingWritten = isBeingWritten)"
ComposerState="@this.composerState" ComposerState="@this.composerState"
Workspaces="@this.workspaces" Workspaces="@this.workspaces"
WorkspaceName="name => this.UpdateWorkspaceName(name)"/> WorkspaceName="name => this.UpdateWorkspaceName(name)"/>
@@ -124,6 +125,7 @@
<ChatComponent <ChatComponent
@bind-ChatThread="@this.chatThread" @bind-ChatThread="@this.chatThread"
@bind-Provider="@this.providerSettings" @bind-Provider="@this.providerSettings"
IsAnswerBeingWrittenChanged="@(isBeingWritten => this.isAnswerBeingWritten = isBeingWritten)"
ComposerState="@this.composerState" ComposerState="@this.composerState"
Workspaces="@this.workspaces" Workspaces="@this.workspaces"
WorkspaceName="name => this.UpdateWorkspaceName(name)"/> WorkspaceName="name => this.UpdateWorkspaceName(name)"/>
@@ -135,6 +137,7 @@
<ChatComponent <ChatComponent
@bind-ChatThread="@this.chatThread" @bind-ChatThread="@this.chatThread"
@bind-Provider="@this.providerSettings" @bind-Provider="@this.providerSettings"
IsAnswerBeingWrittenChanged="@(isBeingWritten => this.isAnswerBeingWritten = isBeingWritten)"
ComposerState="@this.composerState" ComposerState="@this.composerState"
Workspaces="@this.workspaces" Workspaces="@this.workspaces"
WorkspaceName="name => this.UpdateWorkspaceName(name)"/> WorkspaceName="name => this.UpdateWorkspaceName(name)"/>
@@ -22,6 +22,17 @@ public partial class Chat : MSGComponentBase
private ChatThread? chatThread; private ChatThread? chatThread;
private AIStudio.Settings.Provider providerSettings = AIStudio.Settings.Provider.NONE; private AIStudio.Settings.Provider providerSettings = AIStudio.Settings.Provider.NONE;
/// <summary>
/// Whether an answer is being written for the open chat, as the chat component last told.
/// </summary>
/// <remarks>
/// The provider selection is locked meanwhile. Another provider would not reach the answer, which
/// keeps the one it started with, but the chat would show the other one while the answer is still
/// being written.
/// </remarks>
private bool isAnswerBeingWritten;
private bool workspaceOverlayVisible; private bool workspaceOverlayVisible;
private bool workspaceSearchVisible; private bool workspaceSearchVisible;
private string currentWorkspaceName = string.Empty; private string currentWorkspaceName = string.Empty;
@@ -3666,6 +3666,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3619858297"] = "zuzüglic
-- Select a provider first -- Select a provider first
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Wähle zuerst einen Anbieter aus" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Wähle zuerst einen Anbieter aus"
-- Profile changes are locked while a response is running.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3662116565"] = "Profiländerungen sind gesperrt, solange eine Antwort generiert wird."
-- Data source changes are locked while a response is running. Your current selection is shown below and applies again from the next message once the run is finished.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3682097866"] = "Änderungen an den Datenquellen sind gesperrt, solange eine Antwort generiert wird. Ihre aktuelle Auswahl wird unten angezeigt und gilt ab der nächsten Nachricht wieder, sobald der Vorgang abgeschlossen ist."
-- Start new chat in workspace "{0}" -- Start new chat in workspace "{0}"
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Neuen Chat im Arbeitsbereich '{0}' starten" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Neuen Chat im Arbeitsbereich '{0}' starten"
@@ -5526,6 +5532,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3672981145"] = "Arbeitsberei
-- Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost. -- Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3699436634"] = "Möchten Sie diesen Chat kopieren? Die Kopie wird anschließend geöffnet, daher gehen alle ungespeicherten Änderungen des aktuell geöffneten Chats verloren." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3699436634"] = "Möchten Sie diesen Chat kopieren? Die Kopie wird anschließend geöffnet, daher gehen alle ungespeicherten Änderungen des aktuell geöffneten Chats verloren."
-- The workspace '{0}' cannot be deleted while one of its chats is being answered or transcribed. Please try again once that is finished.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3765015122"] = "Der Arbeitsbereich „{0}“ kann nicht gelöscht werden, solange in einem seiner Chats gerade eine Antwort generiert oder eine Transkription erstellt wird. Bitte versuchen Sie es erneut, sobald der Vorgang abgeschlossen ist."
-- Chat Name -- Chat Name
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3891063690"] = "Chatname" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3891063690"] = "Chatname"
@@ -3666,6 +3666,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3619858297"] = "plus {0}
-- Select a provider first -- Select a provider first
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Select a provider first" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Select a provider first"
-- Profile changes are locked while a response is running.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3662116565"] = "Profile changes are locked while a response is running."
-- Data source changes are locked while a response is running. Your current selection is shown below and applies again from the next message once the run is finished.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3682097866"] = "Data source changes are locked while a response is running. Your current selection is shown below and applies again from the next message once the run is finished."
-- Start new chat in workspace "{0}" -- Start new chat in workspace "{0}"
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Start new chat in workspace \"{0}\"" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Start new chat in workspace \"{0}\""
@@ -5526,6 +5532,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3672981145"] = "Add Workspac
-- Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost. -- Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3699436634"] = "Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3699436634"] = "Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost."
-- The workspace '{0}' cannot be deleted while one of its chats is being answered or transcribed. Please try again once that is finished.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3765015122"] = "The workspace '{0}' cannot be deleted while one of its chats is being answered or transcribed. Please try again once that is finished."
-- Chat Name -- Chat Name
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3891063690"] = "Chat Name" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3891063690"] = "Chat Name"
@@ -660,7 +660,7 @@ public static class WorkspaceBehaviour
} }
} }
public static async Task RemoveWorkspaceFromCacheAsync(Guid workspaceId) private static async Task RemoveWorkspaceFromCacheAsync(Guid workspaceId)
{ {
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(); await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
try try
@@ -816,6 +816,34 @@ public static class WorkspaceBehaviour
} }
} }
/// <summary>
/// Whether the given workspace exists on the disk.
/// </summary>
/// <remarks>
/// The temporary chats belong to no workspace, so for them, the empty ID, this is always true.
/// </remarks>
/// <param name="workspaceId">The workspace to look for.</param>
public static bool IsWorkspaceExisting(Guid workspaceId) => workspaceId == Guid.Empty || Directory.Exists(Path.Join(WorkspaceRootDirectory, workspaceId.ToString()));
/// <summary>
/// Reads the IDs of the chats the given workspace holds on the disk.
/// </summary>
/// <param name="workspaceId">The workspace to read.</param>
/// <returns>The IDs of its chats; none when the workspace does not exist.</returns>
public static IReadOnlyList<Guid> ReadChatIdsOfWorkspace(Guid workspaceId)
{
var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString());
if (!Directory.Exists(workspacePath))
return [];
var chatIds = new List<Guid>();
foreach (var chatPath in Directory.EnumerateDirectories(workspacePath))
if (Guid.TryParse(Path.GetFileName(chatPath), out var chatId))
chatIds.Add(chatId);
return chatIds;
}
public static bool IsChatExisting(LoadChat loadChat) public static bool IsChatExisting(LoadChat loadChat)
{ {
var chatPath = loadChat.WorkspaceId == Guid.Empty var chatPath = loadChat.WorkspaceId == Guid.Empty
@@ -1398,6 +1426,38 @@ public static class WorkspaceBehaviour
return true; return true;
} }
/// <summary>
/// Deletes the given workspace together with all of its chats, unless one of them is busy.
/// </summary>
/// <param name="workspaceId">The workspace to delete.</param>
/// <param name="isChatBusy">Whether an answer or a transcript is being made for the chat with the given ID.</param>
/// <returns>True when the workspace is gone, which includes it never having been there. False when a busy chat kept it.</returns>
/// <remarks>
/// The work on a busy chat stores the chat once more when it is done. That would create the
/// directory of the chat anew and the one of the workspace with it, which then comes back as an
/// unnamed workspace. Deleting all chats but the busy one would be no better: the user asked for
/// the workspace to go, not for some of its chats. Hence, a busy chat keeps the whole workspace.<br/><br/>
///
/// Asking the user beforehand is up to the caller, and so is taking an open chat of the workspace
/// off the screen.
/// </remarks>
public static async Task<bool> DeleteWorkspaceAsync(Guid workspaceId, Func<Guid, bool> isChatBusy)
{
var chatIds = ReadChatIdsOfWorkspace(workspaceId);
if (chatIds.Any(isChatBusy))
return false;
var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString());
if (Directory.Exists(workspacePath))
Directory.Delete(workspacePath, true);
await RemoveWorkspaceFromCacheAsync(workspaceId);
foreach (var chatId in chatIds)
ForgetChatSemaphore(workspaceId, chatId);
return true;
}
private static async Task EnsureWorkspace(Guid workspaceId, string workspaceName) private static async Task EnsureWorkspace(Guid workspaceId, string workspaceName)
{ {
var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString()); var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString());
@@ -45,9 +45,11 @@
- Fixed the workspace list no longer showing the chats of your workspaces after you moved a chat to another workspace. - Fixed the workspace list no longer showing the chats of your workspaces after you moved a chat to another workspace.
- Fixed a chat you moved from the disappearing chats into a workspace still being listed among the disappearing chats. - Fixed a chat you moved from the disappearing chats into a workspace still being listed among the disappearing chats.
- Fixed a new chat not appearing in the workspace list while AI Studio transcribed an audio or video file you had attached to it. - Fixed a new chat not appearing in the workspace list while AI Studio transcribed an audio or video file you had attached to it.
- Fixed a deleted workspace coming back as "Unnamed workspace" when one of its chats was still open or being answered. AI Studio now deletes a workspace only when none of its chats is being answered or transcribed, and it closes the chat you had open in it.
- Fixed a chat you started by attaching an audio or video file getting a German name, regardless of the language you use AI Studio in. - Fixed a chat you started by attaching an audio or video file getting a German name, regardless of the language you use AI Studio in.
- Fixed AI Studio sometimes warning about unsaved changes when you left a chat in which you had only selected a chat template, without writing anything yourself. - Fixed AI Studio sometimes warning about unsaved changes when you left a chat in which you had only selected a chat template, without writing anything yourself.
- Fixed regenerating an answer clearing the message you had started to write. - Fixed regenerating an answer clearing the message you had started to write.
- Fixed AI Studio waiting for an answer you had removed while it was still being written, so you could not send another message. While an answer is being written, the messages, attachments, provider, profile, and data sources of the chat now stay as they are until you stop the answer or it is complete. Thanks, Bernhard, for reporting this issue.
- Fixed the warning about unsaved changes in a chat appearing only by chance when you save your chats manually or have turned off workspaces. AI Studio now reliably asks before you open another chat, create a new one, or leave the chat page, so you no longer lose your changes by accident. - Fixed the warning about unsaved changes in a chat appearing only by chance when you save your chats manually or have turned off workspaces. AI Studio now reliably asks before you open another chat, create a new one, or leave the chat page, so you no longer lose your changes by accident.
- Fixed the security notice about removed instructions (prompt injection) appearing only minutes later while AI Studio updated a data source in the background. The notice also mixed your own attachments with files of the data source. Now each notice appears as soon as AI Studio has read the content, for example, the attachments of your message, and it names only that content. - Fixed the security notice about removed instructions (prompt injection) appearing only minutes later while AI Studio updated a data source in the background. The notice also mixed your own attachments with files of the data source. Now each notice appears as soon as AI Studio has read the content, for example, the attachments of your message, and it names only that content.
- Fixed the security notice about removed instructions (prompt injection) appearing several times for one answer when AI Studio checks which passages of your data sources fit your question: once for every passage it checked, and once more for all of them together. You now get one notice per answer, and a file counts once, however many of its passages were affected. - Fixed the security notice about removed instructions (prompt injection) appearing several times for one answer when AI Studio checks which passages of your data sources fit your question: once for every passage it checked, and once more for all of them together. You now get one notice per answer, and a file counts once, however many of its passages were affected.
+254
View File
@@ -0,0 +1,254 @@
using AIStudio.Chat;
namespace AIStudio.Tests.Chat;
/// <summary>
/// Checks that the messages of a chat stay as they are 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. A message the
/// user removes then does not come back, but the chat waits for an answer nobody sees, and the job
/// stores the conversation while it changes. So every change to a message is turned down until the
/// answer is complete or stopped, and works as before once it is.
/// </remarks>
[TestFixture]
public sealed class ChatBlockActionsTests
{
private static readonly DateTimeOffset START = new(2026, 10, 13, 10, 0, 0, TimeSpan.Zero);
[Test]
public void RemovingTheAnswerBeingWrittenLeavesTheChatAsItIs()
{
var question = Block(ChatRole.USER, "Question", 1);
var answer = Block(ChatRole.AI, "Answer being", 2);
var chat = Chat(question, answer);
var removed = WhileWriting(chat).TryRemove(chat, answer.Content!);
Assert.Multiple(() =>
{
Assert.That(removed, Is.False);
Assert.That(chat.Blocks.Select(x => x.Content), Is.EqualTo(new[] { question.Content, answer.Content }), "The answer stays, since the job goes on writing into it.");
});
}
[Test]
public void OlderMessagesStayWhileAnAnswerIsBeingWritten()
{
var firstQuestion = Block(ChatRole.USER, "First question", 1);
var firstAnswer = Block(ChatRole.AI, "First answer", 2);
var chat = Chat(firstQuestion, firstAnswer, Block(ChatRole.USER, "Second question", 3), Block(ChatRole.AI, "Second answer being", 4));
var blocks = chat.Blocks.ToList();
var actions = WhileWriting(chat);
Assert.Multiple(() =>
{
Assert.That(actions.TryRemove(chat, firstQuestion.Content!), Is.False);
Assert.That(actions.TryRemove(chat, firstAnswer.Content!), Is.False);
Assert.That(actions.TryRemoveForRegenerate(chat, firstAnswer.Content!), Is.False);
Assert.That(chat.Blocks, Is.EqualTo(blocks), "The job stores the conversation meanwhile, so no message may change, not even an older one.");
});
}
[Test]
public void EditingTheQuestionChangesNeitherTheChatNorTheDraft()
{
var question = Block(ChatRole.USER, "Question", 1);
var answer = Block(ChatRole.AI, "Answer being", 2);
var chat = Chat(question, answer);
var actions = WhileWriting(chat);
Assert.Multiple(() =>
{
Assert.That(actions.TryEditLastUserBlock(chat, question.Content!), Is.False);
Assert.That(actions.TryEditLastBlock(chat, question.Content!), Is.False);
Assert.That(chat.Blocks, Has.Count.EqualTo(2), "Both the question and the answer being written stay.");
Assert.That(chat.Draft.IsEmpty, Is.True, "The question does not become the draft, since it could not be sent anyway.");
});
}
[Test]
public void RegeneratingKeepsTheAnswerBeingWritten()
{
var answer = Block(ChatRole.AI, "Answer being", 2);
var chat = Chat(Block(ChatRole.USER, "Question", 1), answer);
var removed = WhileWriting(chat).TryRemoveForRegenerate(chat, answer.Content!);
Assert.Multiple(() =>
{
Assert.That(removed, Is.False, "Regenerating would leave a second answer next to the one being written.");
Assert.That(chat.Blocks, Has.Count.EqualTo(2));
});
}
[Test]
public void RollingBackIsTurnedDownWhileAnAnswerIsBeingWritten()
{
var firstAnswer = Block(ChatRole.AI, "First answer", 2);
var chat = Chat(Block(ChatRole.USER, "First question", 1), firstAnswer, Block(ChatRole.USER, "Second question", 3), Block(ChatRole.AI, "Second answer being", 4));
var rolledBack = WhileWriting(chat).TryRollBack(chat, firstAnswer.Content!);
Assert.Multiple(() =>
{
Assert.That(rolledBack, Is.False);
Assert.That(chat.Blocks, Has.Count.EqualTo(4));
});
}
[Test]
public void RemovingAMessageWorksOnceTheAnswerIsComplete()
{
var question = Block(ChatRole.USER, "Question", 1);
var answer = Block(ChatRole.AI, "Answer", 2);
var chat = Chat(question, answer);
var removed = Done().TryRemove(chat, answer.Content!);
Assert.Multiple(() =>
{
Assert.That(removed, Is.True);
Assert.That(chat.Blocks.Select(x => x.Content), Is.EqualTo(new[] { question.Content }));
});
}
[Test]
public void RegeneratingWorksOnceTheAnswerIsComplete()
{
var question = Block(ChatRole.USER, "Question", 1);
var answer = Block(ChatRole.AI, "Answer", 2);
var chat = Chat(question, answer);
var removed = Done().TryRemoveForRegenerate(chat, answer.Content!);
Assert.Multiple(() =>
{
Assert.That(removed, Is.True);
Assert.That(chat.Blocks.Select(x => x.Content), Is.EqualTo(new[] { question.Content }), "The question stays, to be answered anew.");
});
}
[Test]
public void RollingBackWorksOnceTheAnswerIsComplete()
{
var firstQuestion = Block(ChatRole.USER, "First question", 1);
var firstAnswer = Block(ChatRole.AI, "First answer", 2);
var chat = Chat(firstQuestion, firstAnswer, Block(ChatRole.USER, "Second question", 3), Block(ChatRole.AI, "Second answer", 4));
var rolledBack = Done().TryRollBack(chat, firstAnswer.Content!);
Assert.Multiple(() =>
{
Assert.That(rolledBack, Is.True);
Assert.That(chat.Blocks.Select(x => x.Content), Is.EqualTo(new[] { firstQuestion.Content, firstAnswer.Content }));
});
}
[Test]
public void EditingTheQuestionWorksOnceTheAnswerIsComplete()
{
var question = Block(ChatRole.USER, "Question", 1);
var chat = Chat(question, Block(ChatRole.AI, "Answer", 2));
var edited = Done().TryEditLastUserBlock(chat, question.Content!);
Assert.Multiple(() =>
{
Assert.That(edited, Is.True);
Assert.That(chat.Blocks, Is.Empty, "The question goes back into the composer, and the answer to it goes.");
Assert.That(chat.Draft.Text, Is.EqualTo("Question"));
});
}
[Test]
public void EditingAQuestionWithoutAnswerWorksOnceTheAnswerIsGone()
{
var question = Block(ChatRole.USER, "Question", 1);
var chat = Chat(question);
var edited = Done().TryEditLastBlock(chat, question.Content!);
Assert.Multiple(() =>
{
Assert.That(edited, Is.True);
Assert.That(chat.Blocks, Is.Empty);
Assert.That(chat.Draft.Text, Is.EqualTo("Question"));
});
}
[Test]
public void EditingAQuestionOfAnotherChatKeepsTheAnswer()
{
var answer = Block(ChatRole.AI, "Answer", 2);
var chat = Chat(Block(ChatRole.USER, "Question", 1), answer);
var edited = Done().TryEditLastUserBlock(chat, new ContentText { Text = "Question of another chat" });
Assert.Multiple(() =>
{
Assert.That(edited, Is.False);
Assert.That(chat.Blocks, Has.Count.EqualTo(2), "Removing the last answer only makes sense together with the question to it.");
Assert.That(chat.Draft.IsEmpty, Is.True);
});
}
[Test]
public void AnAnswerBeingWrittenInAnotherChatLocksNothingHere()
{
var answer = Block(ChatRole.AI, "Answer", 2);
var chat = Chat(Block(ChatRole.USER, "Question", 1), answer);
var otherChatId = Guid.NewGuid();
var askedChatIds = new List<Guid>();
var actions = new ChatBlockActions(chatId =>
{
askedChatIds.Add(chatId);
return chatId == otherChatId;
});
var removed = actions.TryRemove(chat, answer.Content!);
Assert.Multiple(() =>
{
Assert.That(removed, Is.True);
Assert.That(askedChatIds, Is.EqualTo(new[] { chat.ChatId }), "Only the chat being changed decides.");
});
}
[Test]
public void WithoutAChatNothingIsAllowed()
{
var actions = Done();
var content = new ContentText { Text = "Question" };
Assert.Multiple(() =>
{
Assert.That(actions.AreAllowed(null), Is.False);
Assert.That(actions.TryRemove(null, content), Is.False);
Assert.That(actions.TryRemoveForRegenerate(null, content), Is.False);
Assert.That(actions.TryRollBack(null, content), Is.False);
Assert.That(actions.TryEditLastUserBlock(null, content), Is.False);
Assert.That(actions.TryEditLastBlock(null, content), Is.False);
});
}
/// <summary>
/// The actions while an answer is being written for the given chat.
/// </summary>
private static ChatBlockActions WhileWriting(ChatThread chat) => new(chatId => chatId == chat.ChatId);
/// <summary>
/// The actions while no answer is being written for any chat.
/// </summary>
private static ChatBlockActions Done() => new(_ => false);
private static ChatThread Chat(params ContentBlock[] blocks) => new() { ChatId = Guid.NewGuid(), Blocks = [..blocks] };
private static ContentBlock Block(ChatRole role, string text, int minute) => new()
{
Time = START.AddMinutes(minute),
ContentType = ContentType.TEXT,
Content = new ContentText { Text = text },
Role = role,
};
}
+143
View File
@@ -0,0 +1,143 @@
using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks when a workspace may be deleted together with its chats.
/// </summary>
/// <remarks>
/// The work on a busy chat, an answer or a transcript, stores the chat once more when it is done. Had
/// its workspace been deleted meanwhile, that brings the workspace back as an unnamed one. A busy chat
/// therefore keeps the whole workspace, while the chats of other workspaces have no say in it.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class WorkspaceDeleteTests
{
private string? previousDataDirectory;
private string testDirectory = string.Empty;
[SetUp]
public async Task PrepareDataDirectory()
{
//
// The data directory is static state of the whole application, which is why this fixture
// does not run alongside others. The workspace behaviour keeps the roots of the workspaces
// and of the temporary chats fixed, as the app never changes its data directory, so it gets
// told about the one of this test separately. The cache is static as well, so it starts
// over from what the new data directory holds: nothing.
//
this.previousDataDirectory = SettingsManager.DataDirectory;
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-workspace-delete-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.testDirectory);
SettingsManager.DataDirectory = this.testDirectory;
WorkspaceBehaviour.TestDataDirectory = this.testDirectory;
await WorkspaceBehaviour.ForceReloadWorkspaceTreeAsync();
}
[TearDown]
public void RemoveDataDirectory()
{
SettingsManager.DataDirectory = this.previousDataDirectory;
WorkspaceBehaviour.TestDataDirectory = null;
// Whoever reads the cache next must not get the workspaces of this test:
WorkspaceBehaviour.InvalidateWorkspaceTreeCache();
try
{
Directory.Delete(this.testDirectory, true);
}
catch (IOException)
{
// A temporary directory we could not remove says nothing about the code under test.
}
}
[Test]
public async Task ABusyChatKeepsTheWholeWorkspace()
{
var workspaceId = await CreateWorkspaceAsync("Projects");
var busyChat = await StoreChatAsync(workspaceId, "Meeting notes");
var idleChat = await StoreChatAsync(workspaceId, "Old notes");
var deleted = await WorkspaceBehaviour.DeleteWorkspaceAsync(workspaceId, chatId => chatId == busyChat.ChatId);
var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
Assert.Multiple(() =>
{
Assert.That(deleted, Is.False, "The caller has to learn that the workspace is still there.");
Assert.That(WorkspaceBehaviour.IsChatExisting(new LoadChat(workspaceId, busyChat.ChatId)), Is.True, "The answer or the transcript still needs the chat it is made for.");
Assert.That(WorkspaceBehaviour.IsChatExisting(new LoadChat(workspaceId, idleChat.ChatId)), Is.True, "The user asked for the whole workspace to go, not for some of its chats.");
Assert.That(snapshot.Workspaces.Select(workspace => workspace.WorkspaceId), Does.Contain(workspaceId), "The workspace list keeps showing the workspace, which still exists.");
});
}
[Test]
public async Task AWorkspaceWithoutBusyChatsIsGone()
{
var workspaceId = await CreateWorkspaceAsync("Projects");
var chat = await StoreChatAsync(workspaceId, "Meeting notes");
var deleted = await WorkspaceBehaviour.DeleteWorkspaceAsync(workspaceId, _ => false);
var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
Assert.Multiple(() =>
{
Assert.That(deleted, Is.True);
Assert.That(WorkspaceBehaviour.IsWorkspaceExisting(workspaceId), Is.False, "The workspace has to be gone from the disk, its chats included.");
Assert.That(WorkspaceBehaviour.IsChatExisting(new LoadChat(workspaceId, chat.ChatId)), Is.False);
Assert.That(snapshot.Workspaces.Select(workspace => workspace.WorkspaceId), Does.Not.Contain(workspaceId), "The workspace list must not show a workspace which is gone.");
});
}
[Test]
public async Task ABusyChatInAnotherWorkspaceDoesNotKeepTheWorkspace()
{
var workspaceId = await CreateWorkspaceAsync("Projects");
await StoreChatAsync(workspaceId, "Meeting notes");
var otherId = await CreateWorkspaceAsync("Archive");
var busyChat = await StoreChatAsync(otherId, "Old notes");
var deleted = await WorkspaceBehaviour.DeleteWorkspaceAsync(workspaceId, chatId => chatId == busyChat.ChatId);
Assert.Multiple(() =>
{
Assert.That(deleted, Is.True, "Only the chats of the workspace being deleted count.");
Assert.That(WorkspaceBehaviour.IsWorkspaceExisting(workspaceId), Is.False);
Assert.That(WorkspaceBehaviour.IsChatExisting(new LoadChat(otherId, busyChat.ChatId)), Is.True, "The other workspace has nothing to do with it and stays as it is.");
});
}
[Test]
public async Task AWorkspaceWhichIsNotThereCountsAsDeleted()
{
var deleted = await WorkspaceBehaviour.DeleteWorkspaceAsync(Guid.NewGuid(), _ => false);
Assert.That(deleted, Is.True, "There is nothing left to delete, so the caller may go on.");
}
[Test]
public void TheDisappearingChatsAlwaysExist()
{
// The temporary chats belong to no workspace, so a new chat among them asks with the empty ID, and it has to be stored:
Assert.That(WorkspaceBehaviour.IsWorkspaceExisting(Guid.Empty), Is.True);
}
private static async Task<Guid> CreateWorkspaceAsync(string name)
{
var result = await WorkspaceBehaviour.TryCreateWorkspaceAsync(name);
Assert.That(result.Success, Is.True, $"The workspace '{name}' could not be created.");
return result.Workspace.WorkspaceId;
}
private static async Task<ChatThread> StoreChatAsync(Guid workspaceId, string name)
{
var chat = new ChatThread { WorkspaceId = workspaceId, ChatId = Guid.NewGuid(), Name = name };
Assert.That(await WorkspaceBehaviour.StoreChatAsync(chat), Is.True, $"The chat '{name}' could not be stored.");
return chat;
}
}